Skip to content
Merged
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ VENV_PYTHON := $(VENV_DIR)/bin/python
VENV_PIP := $(VENV_PYTHON) -m pip

PROTO_REPO_URL := https://github.com/scalekit-inc/scalekit.git
PROTO_REF ?= v0.1.123.0
PROTO_REF ?= v0.1.127.0
PROTO_SUBDIR := proto
LOCAL_PROTO_REPO ?= ../scalekit

Expand Down
2 changes: 1 addition & 1 deletion scalekit/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Single source of truth for the SDK version.
# Import this in setup.py and scalekit/core.py — never hardcode the version elsewhere.
__version__ = "2.10.0"
__version__ = "2.11.0"
219 changes: 191 additions & 28 deletions scalekit/actions/actions.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from datetime import timedelta
from typing import Optional, Any, List, Dict, Union
import requests
from scalekit.actions.types import ToolRequest,ExecuteToolResponse,MagicLinkResponse,ListConnectedAccountsResponse,DeleteConnectedAccountResponse,GetConnectedAccountAuthResponse,GetConnectedAccountDetailsResponse,ToolInput, \
UpdateConnectedAccountResponse,CreateMcpConfigResponse,ListMcpConfigsResponse,UpdateMcpConfigResponse,DeleteMcpConfigResponse, \
EnsureMcpInstanceResponse,UpdateMcpInstanceResponse,GetMcpInstanceResponse,ListMcpInstancesResponse,DeleteMcpInstanceResponse,GetMcpInstanceAuthStateResponse, \
McpConfig,McpConfigConnectionToolMapping,VerifyConnectedAccountUserResponse, \
CreateCustomProviderRequest,UpdateCustomProviderRequest,ListProvidersRequest,DeleteCustomProviderRequest, \
CreateCustomProviderResponse,UpdateCustomProviderResponse,ListProvidersResponse,DeleteCustomProviderResponse
CreateCustomProviderResponse,UpdateCustomProviderResponse,ListProvidersResponse,DeleteCustomProviderResponse, \
ListMcpConnectedAccountsResponse,CreateMcpSessionTokenResponse,McpConnectionAuthState
from scalekit.actions.models.responses.create_connected_account_response import CreateConnectedAccountResponse
from scalekit.actions.models.requests.create_connected_account_request import CreateConnectedAccountRequest
from scalekit.actions.models.requests.update_connected_account_request import UpdateConnectedAccountRequest
Expand Down Expand Up @@ -242,37 +244,35 @@ def verify_connected_account_user(
return VerifyConnectedAccountUserResponse.from_proto(proto_response)

def list_connected_accounts(
self,
self,
connection_name: Optional[str] = None,
identifier: Optional[str] = None,
provider: Optional[str] = None,
connection_names: Optional[List[str]] = None,
**kwargs
) -> ListConnectedAccountsResponse:
"""List connected accounts with optional filtering.

Args:
connection_name: Filter by a single connector slug, e.g. ``"github"``.
Mapped to the ``connector`` field in the underlying request.
identifier: Filter by end-user identifier, e.g. email or opaque user ID.
provider: Filter by OAuth/API-key provider slug, e.g. ``"google"``.
connection_names: Filter results to connected accounts belonging to *any*
of these connection slugs. Useful when you want to check multiple
connectors at once, e.g. ``["github", "google-calendar", "slack"]``.
Can be combined with ``identifier`` to narrow results to a specific user.

Returns:
ListConnectedAccountsResponse containing the matching connected accounts.
"""
List connected accounts with optional filtering

:param connection_name: Connector identifier (optional)
:type: str
:param identifier: Identifier filter (optional)
:type: str
:param provider: Provider filter (optional)
:type: str

:returns:
ListConnectedAccountsResponse containing list of connected accounts
"""
# Call the existing connected_accounts method which returns (response, metadata) tuple
result_tuple = self.connected_accounts.list_connected_accounts(
connector=connection_name,
identifier=identifier,
provider=provider
provider=provider,
connection_names=connection_names,
)

# Extract the response[0] (the actual ListConnectedAccountsResponse proto object)
proto_response = result_tuple[0]

# Convert proto to our ListConnectedAccountsResponse class
return ListConnectedAccountsResponse.from_proto(proto_response)
return ListConnectedAccountsResponse.from_proto(result_tuple[0])

def delete_connected_account(
self,
Expand Down Expand Up @@ -543,6 +543,7 @@ def list_configs(
filter_id: Optional[str] = None,
filter_provider: Optional[str] = None,
filter_name: Optional[str] = None,
filter_mcp_server_url: Optional[str] = None,
search: Optional[str] = None,
**kwargs,
) -> ListMcpConfigsResponse:
Expand All @@ -554,6 +555,7 @@ def list_configs(
filter_id=filter_id,
filter_provider=filter_provider,
filter_name=filter_name,
filter_mcp_server_url=filter_mcp_server_url,
search=search,
)

Expand Down Expand Up @@ -900,23 +902,43 @@ def list_configs(
filter_id: Optional[str] = None,
filter_provider: Optional[str] = None,
filter_name: Optional[str] = None,
filter_mcp_server_url: Optional[str] = None,
search: Optional[str] = None,
) -> ListMcpConfigsResponse:
"""List MCP configurations with optional pagination and filtering.

Args:
page_size: Maximum number of configs to include in the current page.
page_token: Cursor token returned by a previous `list_configs` call.
filter_id: Restrict results to a specific configuration identifier.
filter_provider: Restrict results to configs for a given provider slug.
filter_name: Restrict results to configs whose names match exactly.
search: Free-form search query applied to name field.
Defaults to the server-side default (typically 20).
page_token: Cursor token returned by a previous ``list_configs`` call.
Pass this to fetch the next page of results.
filter_id: Restrict results to a specific configuration by its Scalekit ID,
e.g. ``"cfg_01abc123"``.
filter_provider: Restrict results to configs for a given provider slug,
e.g. ``"github"`` or ``"google-calendar"``.
filter_name: Restrict results to configs whose name matches exactly,
e.g. ``"My GitHub Config"``.
filter_mcp_server_url: Restrict results to configs whose MCP server URL
matches this value, e.g. ``"https://mcp.example.com/sse"``.
search: Free-form search query applied to the config name field.

Returns:
ListMcpConfigsResponse: Parsed wrapper around the proto response.
ListMcpConfigsResponse: Parsed wrapper around the proto response containing
a ``configs`` list, ``next_page_token``, and ``total_count``.

Raises:
ValueError: If an MCP client has not been configured on the action client.

Example::

page1 = client.actions.mcp.list_configs(page_size=10)
for cfg in page1.configs:
print(cfg.name, cfg.mcp_server_url)

if page1.next_page_token:
page2 = client.actions.mcp.list_configs(
page_size=10, page_token=page1.next_page_token
)
"""
client = self._client()
result_tuple = client.list_configs(
Expand All @@ -925,6 +947,7 @@ def list_configs(
filter_id=filter_id,
filter_provider=filter_provider,
filter_name=filter_name,
filter_mcp_server_url=filter_mcp_server_url,
search=search,
)
return ListMcpConfigsResponse.from_proto(result_tuple[0])
Expand Down Expand Up @@ -1160,6 +1183,146 @@ def get_instance_auth_state(
)
return GetMcpInstanceAuthStateResponse.from_proto(result_tuple[0])

def list_mcp_connected_accounts(
self,
config_id: str,
identifier: str,
include_auth_link: Optional[bool] = None,
) -> ListMcpConnectedAccountsResponse:
"""List the connected account auth state for all connections in an MCP config.

For each connection defined in the MCP configuration, this method returns the
current authorisation status of the end-user's connected account and,
optionally, a one-time link the user can open to authorise or re-authorise
the connection.

This is typically called server-side before serving MCP tool calls, to
determine whether the user still has valid credentials for every connector
the MCP config requires.

Args:
config_id: Scalekit ID of the MCP configuration to inspect,
e.g. ``"cfg_01abc123"``.
identifier: End-user identifier for whom to fetch auth state — usually
an email address or opaque user ID that was used when calling
``ensure_instance``, e.g. ``"alice@example.com"``.
include_auth_link: When ``True``, every connected account in the response
will include an ``authentication_link`` regardless of its current
status. Set this to ``True`` when building a connected-account
integration page for an MCP server — it lets the end user see the
status of all their connections and authorise or re-authorise any of
them in one pass.

When ``False`` (default), ``authentication_link`` is omitted from
the response. If a connected account does not yet exist for a given
connection, ``connected_account_id`` will be an empty string. In
that situation you can either call ``get_authorization_link`` for
the specific connection or re-call this method with
``include_auth_link=True``.

**Auth links are valid for 1 minute only** — generate them close
to the time you redirect the user.

Returns:
ListMcpConnectedAccountsResponse: Contains a ``connected_accounts`` list.
Each item is a :class:`McpConnectionAuthState` with fields:

- ``connection_name`` — slug of the connector (``"github"``)
- ``provider`` — OAuth provider (``"github"``)
- ``connected_account_id`` — Scalekit ID for the user's connected account;
empty string if no connected account exists yet for this connection
- ``connected_account_status`` — ``"active"``, ``"expired"``, or ``"disconnected"``
- ``authentication_link`` — auth/re-auth URL valid for 1 minute;
present for all connections when ``include_auth_link=True``,
otherwise omitted

Raises:
ValueError: If ``config_id`` or ``identifier`` is blank.

Example::

state = client.actions.mcp.list_mcp_connected_accounts(
config_id="cfg_01abc123",
identifier="alice@example.com",
include_auth_link=True,
)
for account in state.connected_accounts:
if account.connected_account_status != "active":
print(
f"{account.connection_name} needs auth: "
f"{account.authentication_link}"
)
"""
if not config_id:
raise ValueError("config_id is required")
if not identifier:
raise ValueError("identifier is required")
result_tuple = self._client().list_mcp_connected_accounts(
config_id=config_id,
identifier=identifier,
include_auth_link=include_auth_link,
)
return ListMcpConnectedAccountsResponse.from_proto(result_tuple[0])

def create_session_token(
self,
mcp_config_id: str,
identifier: str,
expiry: Optional[timedelta] = None,
) -> CreateMcpSessionTokenResponse:
"""Create a short-lived session token for a user to access an MCP server.

The token is scoped to a specific MCP configuration and end-user. Pass it
as a ``Bearer`` token in the ``Authorization`` header when making requests
to the MCP server URL associated with the config.

Args:
mcp_config_id: Scalekit ID of the MCP configuration the token should
grant access to, e.g. ``"cfg_01abc123"``.
identifier: End-user identifier for whom the token is minted — typically
the same email or opaque ID used when calling ``ensure_instance``,
e.g. ``"alice@example.com"``.
expiry: Requested lifetime for the token as a Python ``timedelta``.
When omitted, the server-side default TTL is applied (typically
1 hour). Example values:

- ``timedelta(minutes=30)`` — 30-minute token
- ``timedelta(hours=8)`` — 8-hour token (work-day session)
- ``timedelta(days=1)`` — 24-hour token

Returns:
CreateMcpSessionTokenResponse: Contains:

- ``token`` (``str``) — opaque bearer token string.
- ``expires_at`` (``datetime``) — UTC datetime when the token expires.

Raises:
ValueError: If ``mcp_config_id`` or ``identifier`` is blank.

Example::

from datetime import timedelta

resp = client.actions.mcp.create_session_token(
mcp_config_id="cfg_01abc123",
identifier="alice@example.com",
expiry=timedelta(hours=8),
)

headers = {"Authorization": f"Bearer {resp.token}"}
# Use headers when calling the MCP server URL
"""
if not mcp_config_id:
raise ValueError("mcp_config_id is required")
if not identifier:
raise ValueError("identifier is required")
result_tuple = self._client().create_session_token(
mcp_config_id=mcp_config_id,
identifier=identifier,
expiry=expiry,
)
return CreateMcpSessionTokenResponse.from_proto(result_tuple[0])


class ActionProviders:
"""Typed action layer over ProvidersClient for custom provider CRUD.
Expand Down
6 changes: 6 additions & 0 deletions scalekit/actions/models/mcp_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ class McpConfig(BaseModel):
default_factory=list,
description="Mappings that connect tools to underlying connections",
)
mcp_server_url: Optional[str] = Field(
None,
description="URL of the MCP server endpoint associated with this config (read-only)",
)

def to_proto(self) -> ProtoMcpConfig:
"""Convert the model into a protobuf MCP config."""
Expand Down Expand Up @@ -117,6 +121,7 @@ def from_proto(cls, proto_config: ProtoMcpConfig) -> "McpConfig":
McpConfigConnectionToolMapping.from_proto(mapping)
for mapping in proto_config.connection_tool_mappings
],
mcp_server_url=proto_config.mcp_server_url or None,
)

def to_dict(self) -> dict:
Expand All @@ -129,6 +134,7 @@ def to_dict(self) -> dict:
"connection_tool_mappings": [
mapping.model_dump() for mapping in self.connection_tool_mappings
],
"mcp_server_url": self.mcp_server_url,
}

class Config:
Expand Down
62 changes: 62 additions & 0 deletions scalekit/actions/models/mcp_connection_auth_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from typing import Optional

from pydantic import BaseModel, Field

from scalekit.v1.mcp.mcp_pb2 import McpConnectionAuthState as ProtoMcpConnectionAuthState


class McpConnectionAuthState(BaseModel):
"""Authentication status for a single connection within an MCP config.

Returned by ``list_mcp_connected_accounts`` to show whether each connection
backing an MCP configuration is authorised for a given user identifier.

Attributes:
connection_id: Internal Scalekit identifier for the connection.
connection_name: Slug name of the connection, e.g. ``"github"``.
provider: OAuth/API-key provider backing the connection, e.g. ``"github"``.
connected_account_id: Scalekit identifier for the user's connected account,
if one exists.
connected_account_status: Current authorisation status of the connected
account. Common values: ``"active"``, ``"expired"``, ``"disconnected"``.
authentication_link: One-time URL the end-user can open to authorise or
re-authorise the connection. Only populated when ``include_auth_link``
was ``True`` in the request and the account is not currently active.
"""

connection_id: Optional[str] = Field(None, description="Internal connection identifier")
connection_name: Optional[str] = Field(None, description="Slug name of the connection")
provider: Optional[str] = Field(None, description="Provider backing the connection")
connected_account_id: Optional[str] = Field(None, description="Scalekit connected account ID")
connected_account_status: Optional[str] = Field(
None,
description="Authorisation status: 'active', 'expired', 'disconnected'",
)
authentication_link: Optional[str] = Field(
None,
description="One-time auth URL; only present when include_auth_link=True and account is not active",
)

@classmethod
def from_proto(cls, proto_state: ProtoMcpConnectionAuthState) -> "McpConnectionAuthState":
return cls(
connection_id=proto_state.connection_id or None,
connection_name=proto_state.connection_name or None,
provider=proto_state.provider or None,
connected_account_id=proto_state.connected_account_id or None,
connected_account_status=proto_state.connected_account_status or None,
authentication_link=proto_state.authentication_link or None,
)

def to_dict(self) -> dict:
return {
"connection_id": self.connection_id,
"connection_name": self.connection_name,
"provider": self.provider,
"connected_account_id": self.connected_account_id,
"connected_account_status": self.connected_account_status,
"authentication_link": self.authentication_link,
}

class Config:
validate_assignment = True
Loading
Loading