diff --git a/.changes/unreleased/.gitkeep b/.changes/unreleased/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.changes/unreleased/added-20261009-153535-mcp-connection-session-token.yaml b/.changes/unreleased/added-20261009-153535-mcp-connection-session-token.yaml new file mode 100644 index 0000000..8847719 --- /dev/null +++ b/.changes/unreleased/added-20261009-153535-mcp-connection-session-token.yaml @@ -0,0 +1,20 @@ +kind: added +body: | + `client.actions.mcp.create_session_token(connection_name=..., identifier=...)` mints a session token for a connection's own MCP server (`/mcp/v3/connections/`), which serves all of that connection's tools without an MCP configuration. Pass exactly one of `mcp_config_id` or the new keyword-only `connection_name`; `expiry` and `access_level` work for both, and `access_level="READ_ONLY"` limits the token to read-only tools. The URL path is case-sensitive, so connect with the connection's name exactly as stored. Existing calls with `mcp_config_id`, positional or keyword, are unchanged. `client.mcp.create_session_token` takes the same arguments and returns the raw `(response, call)` tuple. [Reference](https://github.com/scalekit-inc/scalekit-sdk-python/blob/main/AGENTKIT.md#mcp-mcpclient) + + ```python + from datetime import timedelta + + resp = client.actions.mcp.create_session_token( + connection_name="gmail", + identifier="user_123", + expiry=timedelta(minutes=15), + access_level="READ_ONLY", + ) + headers = {"Authorization": f"Bearer {resp.token}"} + ``` +time: '2026-10-09T15:35:35.140631+00:00' +custom: + Breaking: 'No' + Ticket: SK-2195 + Migration: '' diff --git a/AGENTKIT.md b/AGENTKIT.md index 87ee01b..3ead8ae 100644 --- a/AGENTKIT.md +++ b/AGENTKIT.md @@ -829,7 +829,7 @@ The `ActionClient` also exposes `list_configs`, `create_config`, `update_config` ### `ActionMcp` helper -Access via `client.connect.mcp` / `client.actions.mcp`. Requires `McpClient` to be initialized on the parent `ScalekitClient`. Methods include `list_configs`, `create_config` (builds `McpConfig` from `name` / `description` / mappings), `update_config`, `delete_config`, `ensure_instance`, `update_instance`, `get_instance`, `list_instances`, `delete_instance`, and `get_instance_auth_state`, returning parsed wrapper types instead of raw gRPC tuples. +Access via `client.connect.mcp` / `client.actions.mcp`. Requires `McpClient` to be initialized on the parent `ScalekitClient`. Methods include `list_configs`, `create_config` (builds `McpConfig` from `name` / `description` / mappings), `update_config`, `delete_config`, `ensure_instance`, `update_instance`, `get_instance`, `list_instances`, `delete_instance`, `get_instance_auth_state`, and `create_session_token`, returning parsed wrapper types instead of raw gRPC tuples. ## MCP (`McpClient`) @@ -942,3 +942,40 @@ Returns authorization state for connectors used by the instance; optional fresh +
client.mcp.create_session_token(mcp_config_id?, identifier, expiry?, access_level?, *, connection_name?) -> (CreateMcpSessionTokenResponse, grpc.Call) +
+ +### ๐Ÿ“ Description + +Mints a short-lived session token for an end user. Pass exactly one of `mcp_config_id` or `connection_name` (keyword-only); the token works only on the MCP server of that target: + +- `mcp_config_id`: the MCP configuration's server, at the config's `mcp_server_url`, serving the tools the configuration exposes. +- `connection_name`: the connection's own MCP server, at `/mcp/v3/connections/`, serving every tool of that connection with no MCP configuration. The name is matched without regard to case, but the URL path is case-sensitive: connect using the name exactly as stored, e.g. copied from the dashboard. + +`identifier` (1 to 255 characters) is required: the user of the configuration's instance, or the user whose connected account on the connection is used. `expiry` is a `timedelta` from 60 seconds to 24 hours (default 1 hour). `access_level` is `"FULL"` (default) or `"READ_ONLY"`, which limits the token to tools annotated read-only: other tools are left out of the tool list and refused when called. + +Raises `ValueError` before any request when neither `mcp_config_id` nor `connection_name` is passed, or, with `connection_name`, when `mcp_config_id` is also given, `connection_name` is empty, or `identifier` is missing or empty. With `mcp_config_id`, `client.mcp` sends the arguments as given and the server validates them; `client.actions.mcp` raises `ValueError` when `mcp_config_id` or `identifier` is empty, as before. Raises `ScalekitNotFoundException` when no active connection has that name, and `ScalekitBadRequestException` for any other invalid request, e.g. an invalid `identifier`, `expiry` or `access_level`, or a connection that is not an AgentKit connection. Depending on the environment's configuration, a connection token also requires `identifier` to have an active connected account on the connection; otherwise the server creates a pending connected account for `identifier` if it has none on the connection, a token is minted, and the account is reported as not connected when tools are called. + +### ๐Ÿ”Œ Usage + +```python +from datetime import timedelta + +# MCP configuration (positional arguments keep working) +response, _ = scalekit_client.mcp.create_session_token("cfg_01abc123", "user_123") + +# A connection's MCP server, read-only tools for 15 minutes +response, _ = scalekit_client.mcp.create_session_token( + connection_name="gmail", + identifier="user_123", + expiry=timedelta(minutes=15), + access_level="READ_ONLY", +) +headers = {"Authorization": f"Bearer {response.token}"} +``` + +`client.connect.mcp.create_session_token(...)` / `client.actions.mcp.create_session_token(...)` take the same arguments and return a parsed `CreateMcpSessionTokenResponse` with `token` and `expires_at` (a naive `datetime` in UTC). + +
+
+ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8841099 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,516 @@ +# Changelog + +All notable changes to this SDK are documented in this file. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow +[Semantic Versioning](https://semver.org/). + +Sections up to and including 2.20.0 were imported from [GitHub Releases](https://github.com/scalekit-inc/scalekit-sdk-python/releases). Their wording is kept, with small corrections. + +## [2.20.0] - 2026-10-05 + +### Changes + +- [SK-2030] fix: accept every auth-pattern shape the API serves (was Literal-constrained) ([#206](https://github.com/scalekit-inc/scalekit-sdk-python/pull/206)) +- [SK-2043] chore: update proto to v0.1.150.0 (v2.19.2) ([#207](https://github.com/scalekit-inc/scalekit-sdk-python/pull/207)) +- chore: add PyPI alias packages for scalekit and scalekit-sdk (SK-2063) ([#208](https://github.com/scalekit-inc/scalekit-sdk-python/pull/208)) +- fix: forward page_size and page_token in actions.list_connected_accounts ([#211](https://github.com/scalekit-inc/scalekit-sdk-python/pull/211)) +- Add Create/Update/Delete/List/Get to ResourceClient ([#204](https://github.com/scalekit-inc/scalekit-sdk-python/pull/204)) +- feat: accept multiple issuers in token validation (SK-2080) ([#212](https://github.com/scalekit-inc/scalekit-sdk-python/pull/212)) + +## [2.19.1] - 2026-09-11 + +### Changes + +- [SK-1912] feat: add resources client for listing and revoking user consents ([#200](https://github.com/scalekit-inc/scalekit-sdk-python/pull/200)) +- [SK-1867] fix: add client-side idle-connection timeout (grpc.client_idle_timeout_ms) ([#203](https://github.com/scalekit-inc/scalekit-sdk-python/pull/203)) + +## [2.19.0] - 2026-09-10 + +### Changes + +- [SK-1291] feat(tools): add SearchTools RPC support ([#197](https://github.com/scalekit-inc/scalekit-sdk-python/pull/197)) +- [SK-1867] fix: add configurable per-call gRPC timeout to grpc_exec ([#198](https://github.com/scalekit-inc/scalekit-sdk-python/pull/198)) +- Add list_tools method to actions facade with ListToolsResponse ([#201](https://github.com/scalekit-inc/scalekit-sdk-python/pull/201)) + +## [2.18.0] - 2026-09-03 + +### Changes + +- [SK-1867] fix: add gRPC keepalive options to detect dead idle connections ([#195](https://github.com/scalekit-inc/scalekit-sdk-python/pull/195)) + +## [2.17.0] - 2026-08-18 + +### Changes + +#### Native middleware support for (Flask, FastAPI, Django) based Python webapps! + +## [2.16.0] - 2026-07-27 + +### Changes + +- [SK-111] Add new members as code owners ([#186](https://github.com/scalekit-inc/scalekit-sdk-python/pull/186)) +- [SK-1339] feat: typed UpdateLoginUserDetails response + listEventsPaginated ([#187](https://github.com/scalekit-inc/scalekit-sdk-python/pull/187)) + +## [2.15.0] - 2026-07-17 + +### Changes + +- chore: update proto to v0.1.138.0 (v2.15.0); add metadata/icon_src to actions provider facade ([#182](https://github.com/scalekit-inc/scalekit-sdk-python/pull/182)) + +## [2.14.0] - 2026-07-17 + +### Changes + +- chore(deps): bump proto version to v0.1.136.1 and regenerate SDK ([#177](https://github.com/scalekit-inc/scalekit-sdk-python/pull/177)) +- [SK-1276] Bump proto to v0.1.137.0 and expand custom-connector support (NO_AUTH, icon_src/metadata, auth_header_key_override) ([#179](https://github.com/scalekit-inc/scalekit-sdk-python/pull/179)) +- [SK-1276] Bump SDK version to 2.14.0 and API version to 20260716 ([#180](https://github.com/scalekit-inc/scalekit-sdk-python/pull/180)) + +## [2.13.0] - 2026-07-07 + +### Changes + +- [SK-631] chore(deps): bump 5 low-risk Python deps ([#167](https://github.com/scalekit-inc/scalekit-sdk-python/pull/167)) +- [SK-636] chore(deps): align grpcio + grpcio-status to >=1.81.0 ([#168](https://github.com/scalekit-inc/scalekit-sdk-python/pull/168)) +- [SK-641] chore(deps): unpin cryptography to >=46.0.6,<49 ([#169](https://github.com/scalekit-inc/scalekit-sdk-python/pull/169)) +- [SK-642] chore(deps): bump protobuf upper bound to >=5.29.5,<8.0.0 ([#170](https://github.com/scalekit-inc/scalekit-sdk-python/pull/170)) +- [SK-646] chore(deps): bump 5 low-risk Python deps ([#171](https://github.com/scalekit-inc/scalekit-sdk-python/pull/171)) +- fix(SK-819, SK-821): provider error differentiation, blind retry fix, and upsert credentials ([#174](https://github.com/scalekit-inc/scalekit-sdk-python/pull/174)) + +## [2.12.0] - 2026-06-08 + +### Changes + +- Redshift connectors ([#165](https://github.com/scalekit-inc/scalekit-sdk-python/pull/165)) + +## [2.11.0] - 2026-06-04 + +### Changes + +- feat: regenerate protos from v0.1.123.0 and add slug/logo_url to CreateOrganizationOptions ([#162](https://github.com/scalekit-inc/scalekit-sdk-python/pull/162)) +- feat: SDK support for proto v0.1.127.0 โ€” MCP session tokens, connected account auth state, and filter improvements ([#164](https://github.com/scalekit-inc/scalekit-sdk-python/pull/164)) + +## [2.10.0] - 2026-05-13 + +### Changes + +- docs: add AGENTKIT.md for AgentKit APIs ([#142](https://github.com/scalekit-inc/scalekit-sdk-python/pull/142)) +- Update protos with v0.1.121.2 ([#157](https://github.com/scalekit-inc/scalekit-sdk-python/pull/157)) +- feat: custom provider CRUD for MCP connectors ([#159](https://github.com/scalekit-inc/scalekit-sdk-python/pull/159)) +- feat: add Google Domain-Wide Delegation (GOOGLE_DWD) auth support ([#153](https://github.com/scalekit-inc/scalekit-sdk-python/pull/153)) +- feat: organization session policy SDK methods ([#152](https://github.com/scalekit-inc/scalekit-sdk-python/pull/152)) + +## [2.9.0] - 2026-04-29 + +### Changes + +- Support XML/SOAP proxy requests and expose connected account details ([#148](https://github.com/scalekit-inc/scalekit-sdk-python/pull/148)) + +## [2.8.0] - 2026-04-22 + +### Changes + +- Fix Makefile and bump up api_version ([#146](https://github.com/scalekit-inc/scalekit-sdk-python/pull/146)) +- Sync proto updates for providers/tools and add custom MCP tests ([#147](https://github.com/scalekit-inc/scalekit-sdk-python/pull/147)) +- Add GitHub Actions release workflow to publish to PyPI ([#145](https://github.com/scalekit-inc/scalekit-sdk-python/pull/145)) + +## [2.7.3] - 2026-04-16 + +### Changes + +- add-enum-m2m ([#143](https://github.com/scalekit-inc/scalekit-sdk-python/pull/143)) +- Regenerate pb2 to include OAUTH_M2M; bump to 2.7.3 ([#144](https://github.com/scalekit-inc/scalekit-sdk-python/pull/144)) + +## [2.7.1] - 2026-04-15 + +### Changes + +- make authorization optional ([#132](https://github.com/scalekit-inc/scalekit-sdk-python/pull/132)) +- docs: rename reference.md to REFERENCE.md ([#134](https://github.com/scalekit-inc/scalekit-sdk-python/pull/134)) +- [SK-2671] feat(roles): add update_default_roles and list_dependent_roles ([#135](https://github.com/scalekit-inc/scalekit-sdk-python/pull/135)) +- [SK-2668] feat(users): add list_user_roles and list_user_permissions ([#133](https://github.com/scalekit-inc/scalekit-sdk-python/pull/133)) +- [SK-2659] feat(token): add update_token method ([#130](https://github.com/scalekit-inc/scalekit-sdk-python/pull/130)) +- fix: docstring fixes, method additions, and version bump to 2.6.0 ([#131](https://github.com/scalekit-inc/scalekit-sdk-python/pull/131)) +- Removed redundant connected account test cases ([#138](https://github.com/scalekit-inc/scalekit-sdk-python/pull/138)) +- Updated dependency versions to fix vulnerabilities reported by dependabot ([#141](https://github.com/scalekit-inc/scalekit-sdk-python/pull/141)) +- Add connected account user verification flow ([#136](https://github.com/scalekit-inc/scalekit-sdk-python/pull/136)) + +## [2.6.0] - 2026-03-10 + +### Changes + +- Fix incorrect :returns: docstrings +- feat: add delete_role_base for env-level role inheritance removal +- fix: make page_size optional in list_organizations to match other SDKs +- feat: add get_client_access_token to Python SDK +- fix: generate_client_token returns token string, not full response dict +- fix: correct role.py docstrings (role_id -> role_name) and fix tearDown error handling +- fix: tolerate INVALID_ARGUMENT and NOT_FOUND in external_id tearDown cleanup + +## [2.5.0] - 2026-02-27 + +### Changes + +Add Proxy Tool Calling - Beta. +API Tokens + +## [2.4.16] - 2026-01-14 + +### Changes + +- update mcp module version ([#113](https://github.com/scalekit-inc/scalekit-sdk-python/pull/113)) +- remove requirements.txt as its unused ([#114](https://github.com/scalekit-inc/scalekit-sdk-python/pull/114)) +- Appended expiry time to response obj from authenticate_with_code() ([#116](https://github.com/scalekit-inc/scalekit-sdk-python/pull/116)) +- Add WebAuthn client ([#115](https://github.com/scalekit-inc/scalekit-sdk-python/pull/115)) +- Add domain type for create and list domain apis ([#117](https://github.com/scalekit-inc/scalekit-sdk-python/pull/117)) + +#### Domain API Changes - Release Notes + +#### Enhanced Domain Management + +#### Domain Type Support +- Added support for domain types to distinguish between different domain configurations: + - `ALLOWED_EMAIL_DOMAIN`: Trusted domains used to suggest organizations in the organization switcher during sign-in/sign-up + - `ORGANIZATION_DOMAIN`: SSO discovery domains used to route users to the correct SSO provider and enforce SSO + - `UNSPECIFIED`: Default type for backward compatibility + +#### API Enhancements + +**`create_domain()`** +- Added optional `domain_type` parameter (accepts string or `DomainType` enum) +- When not specified, defaults to `ORGANIZATION_DOMAIN` for backward compatibility +- Supports flexible input: strings like `"ALLOWED_EMAIL_DOMAIN"` or enum values + +**`list_domains()`** +- Added optional `domain_type` parameter for filtering domains by type +- When not specified, returns all domains regardless of type +- Enables targeted queries for specific domain configurations + +## [2.4.14] - 2025-12-22 + +### Changes + +- Update pyjwt requirement from <2.10,>=2.8 to >=2.8,<2.11 in https://github.com/scalekit-inc/scalekit-sdk-python/pull/48 +- Library Upgrades & minor performance improvements in https://github.com/scalekit-inc/scalekit-sdk-python/pull/112 + +## [2.4.13] - 2025-11-19 + +### Changes + +#### New sdk methods + +- Add upsert_user_management_settings sdk method to OrganizationClient ([#110](https://github.com/scalekit-inc/scalekit-sdk-python/pull/110)) + +## [2.4.12] - 2025-11-13 + +### Changes + +- Add AuthClient for updating login user details and generate proto files to support given_name and family_name in user object in https://github.com/scalekit-inc/scalekit-sdk-python/pull/109 + +## [2.4.11] - 2025-11-06 + +### Changes + +- Actions: Dynamic MCP support ([#106](https://github.com/scalekit-inc/scalekit-sdk-python/pull/106)) + +## [2.4.10] - 2025-10-30 + +### Changes + +- fix authenticate with code ([#105](https://github.com/scalekit-inc/scalekit-sdk-python/pull/105)) + +## [2.4.9] - 2025-10-30 + +### Changes + +- Refresh Expired Client Access Token + +## [2.4.8] - 2025-10-30 + +### Changes + +- add RevokeAllUserSessions method and enhance getUserSessions with filter options ([#100](https://github.com/scalekit-inc/scalekit-sdk-python/pull/100)) + +## [2.4.7] - 2025-10-10 + +### Changes + +- Google adk support ([#98](https://github.com/scalekit-inc/scalekit-sdk-python/pull/98)) +- Connected account update ([#101](https://github.com/scalekit-inc/scalekit-sdk-python/pull/101)) + +## [2.4.4] - 2025-09-18 + +### Changes + +- Generated proto files - rename invited_by to inviter_email + +## [2.4.3] - 2025-09-16 + +### Changes + +- Upgrade pydantic dependency constraint from ~2.10.6 to ~2.11.2 ([#94](https://github.com/scalekit-inc/scalekit-sdk-python/pull/94)) +- Bump version to 2.4.3 ([#95](https://github.com/scalekit-inc/scalekit-sdk-python/pull/95)) + +## [2.4.2] - 2025-09-15 + +### Changes + +- Use string literal type annotations for TemplateType and DomainType ([#86](https://github.com/scalekit-inc/scalekit-sdk-python/pull/86)) +- Enhance README with Agent-First Positioning ([#88](https://github.com/scalekit-inc/scalekit-sdk-python/pull/88)) +- Change Connection to actions ([#90](https://github.com/scalekit-inc/scalekit-sdk-python/pull/90)) +- Add Permission and Org Role sdk methods ([#89](https://github.com/scalekit-inc/scalekit-sdk-python/pull/89)) +- Add SessionsClient sdk methods ([#91](https://github.com/scalekit-inc/scalekit-sdk-python/pull/91)) + +## [2.4.0] - 2025-09-03 + +### Changes + +- Feature connected accounts ([#73](https://github.com/scalekit-inc/scalekit-sdk-python/pull/73)) +- Add resend_invite method ([#77](https://github.com/scalekit-inc/scalekit-sdk-python/pull/77)) +- [SK-2059] Update Test Cases to utilize new error handling capability ([#76](https://github.com/scalekit-inc/scalekit-sdk-python/pull/76)) +- Add Langchain support for Scalekit SDK ([#79](https://github.com/scalekit-inc/scalekit-sdk-python/pull/79)) +- Relax python-dotenv constraint to >=1.1.0 ([#83](https://github.com/scalekit-inc/scalekit-sdk-python/pull/83)) +- Add delete domain sdk method & domain_type parameter to create_domain method and update proto files ([#82](https://github.com/scalekit-inc/scalekit-sdk-python/pull/82)) + +## [2.3.0] - 2025-08-01 + +### Changes + +- add passwordless sdk methods ([#65](https://github.com/scalekit-inc/scalekit-sdk-python/pull/65)) +- Update Error Handling for Python SDK ([#66](https://github.com/scalekit-inc/scalekit-sdk-python/pull/66)) +- add scope validation in validate_token method ([#68](https://github.com/scalekit-inc/scalekit-sdk-python/pull/68)) + +## [2.2.1] - 2025-07-16 + +### Changes + +- Implemented sdk methods for environment roles ([#61](https://github.com/scalekit-inc/scalekit-sdk-python/pull/61)) + +## [2.2.0] - 2025-07-18 + +### Changes + +- add list_organization_clients sdk method ([#64](https://github.com/scalekit-inc/scalekit-sdk-python/pull/64)) + +## [2.0.1] - 2025-07-09 + +### Changes + +- Upgrade setuptools version to >=78.1.1 ([#58](https://github.com/scalekit-inc/scalekit-sdk-python/pull/58)) +- return refresh token and handle errors in authenticate_with_code ,skip audience validation in validate_access_token if audience is not passed ([#60](https://github.com/scalekit-inc/scalekit-sdk-python/pull/60)) + +## [2.0.0] - 2025-06-26 + +### Changes + +#### ๐Ÿš€ What's New? + +This is a major release that introduces **Full Stack Authentication (FSA)** capabilities to the Python SDK. This empowers developers to build comprehensive, enterprise-ready authentication and user management into their applications with minimal code. + +With this release, developers can now programmatically: +- Implement passwordless sign-in using magic links and one-time passcodes. +- Connect to any enterprise identity provider via SAML and OIDC for Single Sign-On (SSO). +- Manage the entire user lifecycle, including invitations, role-based access control (RBAC), and provisioning. +- Enforce advanced security policies such as session duration and idle timeouts at the organization level. + +#### ๐Ÿ†• Full Stack Authentication Support Added + +The SDK has been significantly updated to provide a complete interface for Scalekit's Full Stack Authentication platform. This includes: + +- **Authentication Methods**: New clients and methods for handling passwordless, social, and enterprise SSO authentication flows in a unified manner. +- **User Management**: A comprehensive set of APIs to create, read, update, and delete users and their role assignments. +- **Organization Management**: Full programmatic control over organization settings, including security policies and domain management. + +Please refer to the [official documentation](https://docs.scalekit.com) and migration guide to upgrade your application to `v2.0.0`. + +## [1.1.0] - 2025-05-16 + +### Changes + +#### :rocket: What's New? +- The `authenticate_with_code` method now returns `organization_id` and `connection_id` in addition to user details and tokens. This provides more context to the frontend upon successful authentication. +--- +#### :sparkles: Enhancements +- **Authentication Data Enrichment:** The `authenticate_with_code` method in `scalekit/client.py` has been updated to include `organization_id` and `connection_id` in its return value. This change was implemented to better support frontend requirements by providing these identifiers directly after the authentication process. +- **Type Hint Correction:** Corrected the return type hint for the `list_organizations` method in `scalekit/organization.py` from `CreateOrganizationResponse` to `ListOrganizationsResponse` for better accuracy. + +## [1.0.9] - 2025-05-16 + +### Changes + +#### ๐Ÿš€ What's New? + +- Developers can now programmatically: + - Create and manage M2M clients + - Handle organization client secrets + - Validate M2M tokens + - Update organization client configurations + +--- + +#### ๐Ÿ†• M2M Client Support Added + +- Introduced new Machine-to-Machine (M2M) authentication features to the Python SDK +- Added a new `M2MClient` class (`scalekit/m2m_client.py`) for managing M2M authentication +- Updated `ScalekitClient` to include M2M client management capabilities +- New methods for organization client management in `scalekit/client.py` + +--- + +#### ๐Ÿงช Testing Improvements + +- Replaced unit tests with comprehensive integration tests +- Introduced Makefile for automated rebuild process +- Enhanced test coverage for M2M client functionality +- Added test documentation in `tests/TEST_README.md` + +--- + +#### โœจ Enhancements + +- Added token validation method for enhanced security +- Improved client secret management capabilities +- Minor bug fixes and refactoring across multiple files + +--- + +#### ๐Ÿ“š Notes + +- Applications integrating with the SDK can now utilize M2M authentication +- Review new classes and methods for M2M client support + +## [1.0.6] - 2025-05-16 + +### Changes + +#### ๐Ÿš€ What's New + +Developers can now delete SSO connections and SCIM directories through the SDK. + +--- + +#### ๐Ÿ› ๏ธ Enhancements & Changes + +- Added `delete_connection()` to the Connections API. +- Added `delete_directory()` to the Directories API. +- These methods allow programmatic deletion of connections and directories for a given organization. + +## [1.0.5] - 2025-05-16 + +### Changes + +#### ๐Ÿš€ What's New? + +- Developers can now create a SSO connections and directory sync through SDK. +- Generate admin portal specific to an enterprise connection โ€” either an SSO or Directory connection. + +--- + +#### โœจ Enhancements + +- The generate_portal_link method now supports generating feature-specific portal links. +- Added a create_connection method for streamlined creation of organization connections. +- Added a create_directory method for easier directory creation by organization ID. + +## [1.0.4] - 2025-05-16 + +### Changes + +#### ๐Ÿš€ What's New? + +- Developers can now programmatically: + - List users and groups from directory. + - Enable and disable directory sync. + - Get directory details +- The [security vulnerability](https://cryptography.io/en/42.0.8/security/) identified by crypotography dependency is fixed promptly. + +--- + +#### ๐Ÿ†• SCIM Support Added + +- Introduced new SCIM (System for Cross-domain Identity Management) features to the Python SDK. +- Added a new `DirectoryClient` class (`scalekit/directory.py`) for managing directories, users, and groups. +- Updated `ScalekitClient` to include directory management capabilities. +- New utility classes for directory handling in `scalekit/utils/directory.py`. + +--- + +#### ๐Ÿ”’ Security Improvements + +- Fixed OpenSSL vulnerability in core authentication flow. +- Improved error handling during client authentication (`scalekit/core.py`). + +--- + +#### โœจ Enhancements + +- Webhook signature verification logic added to `ScalekitClient`. +- Organization settings update method added to `OrganizationClient`. +- Protocol buffer definitions updated for improved client and directory management. +- Minor bug fixes and refactoring across multiple files. + +## [1.0.3] - 2024-08-27 + +### Changes + +- Fix version ([#14](https://github.com/scalekit-inc/scalekit-sdk-python/pull/14)) +- Feat idp initiated login claim ([#23](https://github.com/scalekit-inc/scalekit-sdk-python/pull/23)) +- Release v1.0.3 ([#28](https://github.com/scalekit-inc/scalekit-sdk-python/pull/28)) + +## [1.0.2] - 2024-07-18 + +### Changes + +- Update readme ([#5](https://github.com/scalekit-inc/scalekit-sdk-python/pull/5)) +- Add dependabot ([#6](https://github.com/scalekit-inc/scalekit-sdk-python/pull/6)) +- Update proto and add provider support for social connections ([#7](https://github.com/scalekit-inc/scalekit-sdk-python/pull/7)) + +## [1.0.1] - 2024-06-13 + +### Changes + +- First Release of the official Scalekit Python SDK + +[2.20.0]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.20.0 +[2.19.1]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.19.1 +[2.19.0]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.19.0 +[2.18.0]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.18.0 +[2.17.0]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.17.0 +[2.16.0]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.16.0 +[2.15.0]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.15.0 +[2.14.0]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.14.0 +[2.13.0]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.13.0 +[2.12.0]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/V2.12.0 +[2.11.0]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.11.0 +[2.10.0]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.10.0 +[2.9.0]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.9.0 +[2.8.0]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.8.0 +[2.7.3]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.7.3 +[2.7.1]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.7.1 +[2.6.0]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.6.0 +[2.5.0]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.5.0 +[2.4.16]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/2.4.16 +[2.4.14]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.4.14 +[2.4.13]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.4.13 +[2.4.12]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.4.12 +[2.4.11]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.4.11 +[2.4.10]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.4.10 +[2.4.9]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.4.9 +[2.4.8]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.4.8 +[2.4.7]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.4.7 +[2.4.4]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.4.4 +[2.4.3]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.4.3 +[2.4.2]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.4.2 +[2.4.0]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.4.0 +[2.3.0]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.3.0 +[2.2.1]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.2.1 +[2.2.0]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.2.0 +[2.0.1]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.0.1 +[2.0.0]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v2.0.0 +[1.1.0]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v1.1.0 +[1.0.9]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v1.0.9 +[1.0.6]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v1.0.6 +[1.0.5]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v1.0.5 +[1.0.4]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v1.0.4 +[1.0.3]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v1.0.3 +[1.0.2]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v1.0.2 +[1.0.1]: https://github.com/scalekit-inc/scalekit-sdk-python/releases/tag/v1.0.1 diff --git a/CLAUDE.md b/CLAUDE.md index e5f991e..ab76c0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,13 +15,12 @@ make test # unittest discover make generate # Regenerate from proto (needs buf) ``` -## Release - -Package: `scalekit-sdk-python` on PyPI. Workflow: `.github/workflows/release.yml`. - -1. Bump `scalekit/_version.py`. Use a minor or patch bump. `setup.py` reads this file. -2. Review the unreleased changes. If proto or generated API files changed, extra generation steps apply. Those steps are not documented yet. Do not invent them. Ask before you regenerate. -3. Merge the release branch to `main`. -4. Create a git tag that matches the version (`v2.17.0` for `2.17.0`). Draft a GitHub Release for that tag. -5. Publishing does not start on its own. Open the Actions run for the release workflow. Any peer can approve it. The `release` environment gates deploy. -6. After approval, the workflow publishes to PyPI. +## Release notes and releases + +- Package: `scalekit-sdk-python` on PyPI. Workflow: `.github/workflows/release.yml`. +- Every change that users can notice adds a release-notes file under `.changes/unreleased/`. +- Never hand-edit the version (`scalekit/_version.py`) or `CHANGELOG.md` sections: release + tooling bumps the version and renders the CHANGELOG section from those files. +- Publishing a GitHub Release for a merged version starts the release workflow. It waits for + an approval on the `release` environment from someone other than the person who started + it, then publishes to PyPI. diff --git a/README.md b/README.md index f0f280a..9a06851 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Python versions](https://img.shields.io/pypi/pyversions/scalekit-sdk-python.svg?style=flat-square)](https://pypi.org/project/scalekit-sdk-python/) -Scalekit is the **auth stack for AI apps** - from human authentication to agent authorization. Build secure AI products faster with authentication for humans (SSO, passwordless, full-stack auth) and agents (MCP/APIs, delegated actions), all unified on one platform. This Python SDK enables both traditional B2B authentication and cutting-edge agentic workflows. +Scalekit provides auth and actions on behalf of users, with 500+ connectors and 20,000+ tools. Build secure AI products faster with authentication for humans (SSO, passwordless, full-stack auth) and agents (MCP/APIs, delegated actions), all unified on one platform. This Python SDK enables both traditional B2B authentication and cutting-edge agentic workflows. ## ๐Ÿค– Agent-First Features diff --git a/scalekit/actions/actions.py b/scalekit/actions/actions.py index dcfebe7..022e6bf 100644 --- a/scalekit/actions/actions.py +++ b/scalekit/actions/actions.py @@ -1,5 +1,5 @@ from datetime import timedelta -from typing import Optional, Any, List, Dict, Union +from typing import Optional, Any, List, Dict, Union, overload import requests from scalekit.actions.types import ToolRequest,ExecuteToolResponse,MagicLinkResponse,ListConnectedAccountsResponse,ListToolsResponse,DeleteConnectedAccountResponse,GetConnectedAccountAuthResponse,GetConnectedAccountDetailsResponse,ToolInput, \ UpdateConnectedAccountResponse,CreateMcpConfigResponse,ListMcpConfigsResponse,UpdateMcpConfigResponse,DeleteMcpConfigResponse, \ @@ -16,6 +16,7 @@ apply_pre_modifiers, apply_post_modifiers ) from scalekit.common.exceptions import ScalekitNotFoundException +from scalekit.mcp import _check_connection_token_args from scalekit.v1.tools.tools_pb2 import Filter from google.protobuf.wrappers_pb2 import BoolValue @@ -1366,28 +1367,59 @@ def list_mcp_connected_accounts( ) return ListMcpConnectedAccountsResponse.from_proto(result_tuple[0]) + @overload def create_session_token( self, mcp_config_id: str, identifier: str, - expiry: Optional[timedelta] = None, - access_level: Optional[str] = None, + expiry: timedelta | None = None, + access_level: str | None = None, + ) -> CreateMcpSessionTokenResponse: ... + + @overload + def create_session_token( + self, + *, + connection_name: str, + identifier: str, + expiry: timedelta | None = None, + access_level: str | None = None, + ) -> CreateMcpSessionTokenResponse: ... + + def create_session_token( + self, + mcp_config_id: str | None = None, + identifier: str | None = None, + expiry: timedelta | None = None, + access_level: str | None = None, + *, + connection_name: str | None = 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. + Pass exactly one of ``mcp_config_id`` or ``connection_name``; the + token works only on the MCP server of that target: + + - ``mcp_config_id``: the MCP configuration's server, at the config's + ``mcp_server_url``. It serves the tools the configuration exposes. + - ``connection_name``: the connection's own MCP server, at + ``/mcp/v3/connections/``. It + serves every tool of that connection, with no MCP configuration. + + Send the token as a ``Bearer`` token in the ``Authorization`` header + when calling that URL. 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"``. + mcp_config_id: Scalekit ID of the MCP configuration the token + grants access to, e.g. ``"cfg_01abc123"``. + identifier: End-user identifier the token acts as. For a + configuration, typically the same email or opaque ID used when + calling ``ensure_instance``, e.g. ``"alice@example.com"``; for + a connection, the user whose connected account on it is used. + 1 to 255 characters. Required. expiry: Requested lifetime for the token as a Python ``timedelta``. - When omitted, the server-side default TTL is applied (typically - 1 hour). Example values: + The server accepts 60 seconds to 24 hours and applies 1 hour + when omitted. Fractions of a second are dropped. Example values: - ``timedelta(minutes=30)`` โ€” 30-minute token - ``timedelta(hours=8)`` โ€” 8-hour token (work-day session) @@ -1395,40 +1427,80 @@ def create_session_token( access_level: Tools the token can use. ``"READ_ONLY"`` limits it to tools annotated read-only: other tools are left out of the tool list and refused when called. ``"FULL"``, or omitting it, exposes - every tool the configuration exposes. + every tool of the target. Values are case-sensitive. + connection_name: Name of the connection whose MCP server the token + grants access to, e.g. ``"gmail"``. Keyword-only. Matched + without regard to case, but the server URL path is + case-sensitive: connect using the name exactly as stored, e.g. + copied from the dashboard. Returns: CreateMcpSessionTokenResponse: Contains: - ``token`` (``str``) โ€” opaque bearer token string. - - ``expires_at`` (``datetime``) โ€” UTC datetime when the token expires. + - ``expires_at`` (``datetime``) โ€” naive datetime, in UTC, when the + token expires. Raises: - ValueError: If ``mcp_config_id`` or ``identifier`` is blank. + ValueError: Raised before any request. Without + ``connection_name``: ``mcp_config_id`` or ``identifier`` is + empty or missing. With ``connection_name``: ``mcp_config_id`` + is also given, ``connection_name`` is empty, or ``identifier`` + is missing or empty. + ScalekitNotFoundException: No active connection has + ``connection_name``. + ScalekitBadRequestException: Any other invalid request, e.g. an + invalid ``identifier``, ``expiry`` or ``access_level``, or a + connection that is not an AgentKit connection. Depending on + the environment's configuration, a connection token also + requires ``identifier`` to have an active connected account on + the connection; otherwise the server creates a pending + connected account for ``identifier`` if it has none on the + connection, a token is minted, and the account is reported as + not connected when tools are called. Example:: from datetime import timedelta + # MCP configuration resp = client.actions.mcp.create_session_token( mcp_config_id="cfg_01abc123", identifier="alice@example.com", expiry=timedelta(hours=8), ) + # A connection's MCP server, read-only tools for 15 minutes + resp = client.actions.mcp.create_session_token( + connection_name="gmail", + identifier="user_123", + expiry=timedelta(minutes=15), + access_level="READ_ONLY", + ) + 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, - access_level=access_level, - ) + if connection_name is None: + # Checks and messages unchanged from before connection_name existed. + 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, + access_level=access_level, + ) + else: + identifier = _check_connection_token_args(mcp_config_id, connection_name, identifier) + result_tuple = self._client().create_session_token( + connection_name=connection_name, + identifier=identifier, + expiry=expiry, + access_level=access_level, + ) return CreateMcpSessionTokenResponse.from_proto(result_tuple[0]) diff --git a/scalekit/mcp.py b/scalekit/mcp.py index b148fe6..e6f18c5 100644 --- a/scalekit/mcp.py +++ b/scalekit/mcp.py @@ -1,6 +1,8 @@ +import enum from datetime import timedelta -from typing import Optional, List +from typing import List, Optional, overload +import grpc from google.protobuf.duration_pb2 import Duration from scalekit.core import CoreClient @@ -8,6 +10,44 @@ from scalekit.v1.mcp.mcp_pb2_grpc import McpServiceStub +class _Unset(enum.Enum): + """Type of ``_UNSET``: marks an argument the caller did not pass.""" + + UNSET = enum.auto() + + +_UNSET = _Unset.UNSET + + +def _check_connection_token_args( + mcp_config_id: str | None | _Unset, + connection_name: str, + identifier: str | None, +) -> str: + """Check the arguments of a connection session token before any request. + + Only the connection form is checked here: the MCP configuration form keeps + the checks each facade had before ``connection_name`` existed. + + Returns: + The identifier, known to be non-empty. + + Raises: + ValueError: ``mcp_config_id`` is also given, ``connection_name`` is + empty, or ``identifier`` is missing or empty. + """ + if mcp_config_id is not _UNSET and mcp_config_id is not None: + raise ValueError( + "Pass either mcp_config_id or connection_name, not both: a session token " + "is for one MCP configuration or for one connection's MCP server" + ) + if not connection_name: + raise ValueError("connection_name must not be empty") + if not identifier: + raise ValueError("identifier is required") + return identifier + + class McpClient: """Class definition for MCP Client""" @@ -282,38 +322,128 @@ def list_mcp_connected_accounts( request, ) + @overload def create_session_token( self, mcp_config_id: str, identifier: str, - expiry: Optional[timedelta] = None, - access_level: Optional[str] = None, - ) -> CreateMcpSessionTokenResponse: - """ - Create a short-lived session token for a user to authenticate against an MCP server. + expiry: timedelta | None = None, + access_level: str | None = None, + ) -> tuple[CreateMcpSessionTokenResponse, grpc.Call]: ... - :param mcp_config_id : ID of the MCP configuration the session token is scoped to - :type : ``` str ``` - :param identifier : End-user identifier (e.g. email or opaque user ID) for whom - the token is being minted - :type : ``` str ``` - :param expiry : Lifetime of the token as a Python ``timedelta``. When omitted, - the server-side default TTL is applied. - Example: ``timedelta(hours=1)`` - :type : ``` timedelta ``` - :param access_level : Tools the token can use. ``"READ_ONLY"`` limits it to tools - annotated read-only: other tools are left out of the tool - list and refused when called. ``"FULL"``, or omitting it, - exposes every tool the configuration exposes. - :type : ``` str ``` + @overload + def create_session_token( + self, + *, + connection_name: str, + identifier: str, + expiry: timedelta | None = None, + access_level: str | None = None, + ) -> tuple[CreateMcpSessionTokenResponse, grpc.Call]: ... - :returns: - CreateMcpSessionTokenResponse โ€” contains ``token`` (str) and ``expires_at`` (Timestamp) + def create_session_token( + self, + mcp_config_id: str | None | _Unset = _UNSET, + identifier: str | None = None, + expiry: timedelta | None = None, + access_level: str | None = None, + *, + connection_name: str | None = None, + ) -> tuple[CreateMcpSessionTokenResponse, grpc.Call]: + """Create a short-lived session token for a user to access an MCP server. + + Pass exactly one of ``mcp_config_id`` or ``connection_name``; the + token works only on the MCP server of that target: + + - ``mcp_config_id``: the MCP configuration's server, at the config's + ``mcp_server_url``. It serves the tools the configuration exposes. + - ``connection_name``: the connection's own MCP server, at + ``/mcp/v3/connections/``. It + serves every tool of that connection, with no MCP configuration. + + Send the token as a ``Bearer`` token in the ``Authorization`` header + when calling that URL. + + Args: + mcp_config_id: ID of the MCP configuration the token grants access + to, e.g. ``"cfg_01abc123"``. + identifier: End-user identifier the token acts as, e.g. + ``"user_123"``: the user of the configuration's instance, or + the user whose connected account on the connection is used. + 1 to 255 characters. Required. + expiry: Lifetime of the token. The server accepts 60 seconds to + 24 hours and applies 1 hour when omitted. Fractions of a + second are dropped. + access_level: Tools the token can use. ``"READ_ONLY"`` limits it + to tools annotated read-only: other tools are left out of the + tool list and refused when called. ``"FULL"``, or omitting it, + exposes every tool of the target. Values are case-sensitive. + connection_name: Name of the connection whose MCP server the token + grants access to, e.g. ``"gmail"``. Keyword-only. Matched + without regard to case, but the server URL path is + case-sensitive: connect using the name exactly as stored, e.g. + copied from the dashboard. + + Returns: + The ``(response, call)`` tuple, like the other methods of this + client. ``response.token`` is the bearer token and + ``response.expires_at`` (Timestamp) is when it expires. + + Raises: + ValueError: Raised before any request when neither + ``mcp_config_id`` nor ``connection_name`` is passed, or, with + ``connection_name``: ``mcp_config_id`` is also given, + ``connection_name`` is empty, or ``identifier`` is missing or + empty. With ``mcp_config_id``, the arguments are sent as given + and the server validates them. + ScalekitNotFoundException: No active connection has + ``connection_name``. + ScalekitBadRequestException: Any other invalid request, e.g. an + invalid ``identifier``, ``expiry`` or ``access_level``, or a + connection that is not an AgentKit connection. Depending on + the environment's configuration, a connection token also + requires ``identifier`` to have an active connected account on + the connection; otherwise the server creates a pending + connected account for ``identifier`` if it has none on the + connection, a token is minted, and the account is reported as + not connected when tools are called. + + Example:: + + from datetime import timedelta + + # MCP configuration + response, _ = client.mcp.create_session_token("cfg_01abc123", "user_123") + + # A connection's MCP server, read-only tools for 15 minutes + response, _ = client.mcp.create_session_token( + connection_name="gmail", + identifier="user_123", + expiry=timedelta(minutes=15), + access_level="READ_ONLY", + ) + headers = {"Authorization": f"Bearer {response.token}"} """ - request = CreateMcpSessionTokenRequest( - mcp_config_id=mcp_config_id, - identifier=identifier, - ) + if connection_name is not None: + identifier = _check_connection_token_args(mcp_config_id, connection_name, identifier) + request = CreateMcpSessionTokenRequest(key_id=connection_name, identifier=identifier) + elif mcp_config_id is _UNSET: + raise ValueError("Either mcp_config_id or connection_name is required") + else: + # Built exactly as before connection_name existed: the server + # validates the configuration ID and identifier. + request = CreateMcpSessionTokenRequest( + mcp_config_id=mcp_config_id, identifier=identifier + ) + return self._mint_session_token(request, expiry, access_level) + + def _mint_session_token( + self, + request: CreateMcpSessionTokenRequest, + expiry: timedelta | None, + access_level: str | None, + ) -> tuple[CreateMcpSessionTokenResponse, grpc.Call]: + """Set the optional fields shared by both session-token targets and send the request.""" if access_level is not None: request.access_level = access_level if expiry is not None: diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 2924526..8f2808e 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -4,6 +4,8 @@ from faker import Faker from basetest import BaseTest +from scalekit.common.exceptions import ScalekitNotFoundException + from scalekit.v1.mcp.mcp_pb2 import ( Mcp, McpConfig, @@ -299,3 +301,47 @@ def test_create_session_token_with_custom_expiry(self): finally: if created_config_id: self.scalekit_client.mcp.delete_config(config_id=created_config_id) + + def test_create_session_token_for_connection(self): + """create_session_token(connection_name=...) mints a token for a connection's MCP server.""" + now = datetime.now(tz=timezone.utc) + token_response = self.scalekit_client.mcp.create_session_token( + connection_name="GMAIL", + identifier=self.test_connected_account_identifier, + expiry=timedelta(minutes=10), + access_level="READ_ONLY", + ) + self.assertEqual(token_response[1].code().name, "OK") + self.assertTrue(token_response[0].token, "Expected a non-empty token string") + expires_at = token_response[0].expires_at.ToDatetime(tzinfo=timezone.utc) + self.assertGreater(expires_at, now + timedelta(minutes=5)) + self.assertLess(expires_at, now + timedelta(minutes=15)) + + def test_create_session_token_unknown_connection(self): + """An unknown connection name raises ScalekitNotFoundException.""" + with self.assertRaises(ScalekitNotFoundException): + self.scalekit_client.mcp.create_session_token( + connection_name=f"py-test-missing-{uuid.uuid4().hex[:8]}", + identifier=self.test_user_identifier, + ) + + def test_create_session_token_positional_config(self): + """The positional (mcp_config_id, identifier, expiry) form keeps working.""" + mcp_config = self._create_test_mcp_config() + mcp_config.name = f"py-test-session-positional-{uuid.uuid4().hex[:8]}" + created_config_id = None + + try: + create_response = self.scalekit_client.mcp.create_config(mcp_config=mcp_config) + created_config_id = create_response[0].config.id + + token_response = self.scalekit_client.mcp.create_session_token( + created_config_id, + self.test_user_identifier, + timedelta(minutes=10), + ) + self.assertEqual(token_response[1].code().name, "OK") + self.assertTrue(token_response[0].token, "Expected a non-empty token string") + finally: + if created_config_id: + self.scalekit_client.mcp.delete_config(config_id=created_config_id) diff --git a/tests/test_mcp_connection_session_token.py b/tests/test_mcp_connection_session_token.py new file mode 100644 index 0000000..84fdc8b --- /dev/null +++ b/tests/test_mcp_connection_session_token.py @@ -0,0 +1,351 @@ +"""Offline tests: create_session_token for an MCP configuration or a connection. + +``create_session_token`` targets either an MCP configuration (``mcp_config_id``) +or a connection's MCP server (``connection_name``, sent as ``key_id`` on the +wire), and never sets the other field. Only the connection form gets new +client-side checks; the config form behaves exactly as in v2.20.0 (client.mcp +sends it to the server as given, client.actions.mcp keeps its old checks). +""" + +import unittest +from datetime import timedelta +from unittest.mock import MagicMock + +from google.protobuf.timestamp_pb2 import Timestamp + +from scalekit.actions.actions import ActionClient +from scalekit.common.exceptions import ScalekitNotFoundException +from scalekit.mcp import McpClient +from scalekit.v1.mcp.mcp_pb2 import CreateMcpSessionTokenResponse + +# Connection-token argument combinations that fail before any request, on both facades. +INVALID_CONNECTION_ARGS = { + "both": {"mcp_config_id": "cfg_1", "connection_name": "GMAIL", "identifier": "u1"}, + "both, config empty": {"mcp_config_id": "", "connection_name": "GMAIL", "identifier": "u1"}, + "empty connection": {"connection_name": "", "identifier": "u1"}, + "connection, missing identifier": {"connection_name": "GMAIL"}, + "connection, None identifier": {"connection_name": "GMAIL", "identifier": None}, + "connection, empty identifier": {"connection_name": "GMAIL", "identifier": ""}, +} + +# Config-ID calls that client.mcp sends to the server unchanged, as in v2.20.0. +# Each case: (args, kwargs, expected mcp_config_id, expected identifier on the wire). +MCP_CLIENT_PASS_THROUGH = { + "empty config": (("", "u1"), {}, "", "u1"), + "whitespace config": ((" ", "u1"), {}, " ", "u1"), + "explicit None config": ((None, "u1"), {}, "", "u1"), + "keyword None config": ((), {"mcp_config_id": None, "identifier": "u1"}, "", "u1"), + "empty identifier": (("cfg_1", ""), {}, "cfg_1", ""), + "whitespace identifier": (("cfg_1", " "), {}, "cfg_1", " "), + "None identifier": (("cfg_1", None), {}, "cfg_1", ""), +} + +# client.actions.mcp keeps its v2.20.0 checks and messages for the config form. +ACTIONS_CONFIG_ERRORS = { + "omitted config": ({"identifier": "u1"}, "mcp_config_id is required"), + "None config": ({"mcp_config_id": None, "identifier": "u1"}, "mcp_config_id is required"), + "empty config": ({"mcp_config_id": "", "identifier": "u1"}, "mcp_config_id is required"), + "missing identifier": ({"mcp_config_id": "cfg_1"}, "identifier is required"), + "empty identifier": ({"mcp_config_id": "cfg_1", "identifier": ""}, "identifier is required"), +} + + +def _not_found(): + return ScalekitNotFoundException.__new__(ScalekitNotFoundException) + + +class TestMcpClientSessionTokenTargets(unittest.TestCase): + def setUp(self): + self.core_client = MagicMock() + self.core_client.grpc_exec.return_value = (CreateMcpSessionTokenResponse(token="tok"), None) + self.mcp = McpClient.__new__(McpClient) + self.mcp.core_client = self.core_client + self.mcp.mcp_service = MagicMock() + + def _sent_request(self): + return self.core_client.grpc_exec.call_args.args[1] + + # Back-compat: every existing config-token call shape keeps working. + + def test_positional_config_and_identifier(self): + self.mcp.create_session_token("cfg_1", "user_123") + request = self._sent_request() + self.assertEqual(request.mcp_config_id, "cfg_1") + self.assertEqual(request.identifier, "user_123") + self.assertEqual(request.key_id, "") + self.assertFalse(request.HasField("expiry")) + self.assertEqual(request.access_level, "") + + def test_positional_expiry_and_access_level(self): + self.mcp.create_session_token("cfg_1", "user_123", timedelta(minutes=5), "READ_ONLY") + request = self._sent_request() + self.assertEqual(request.mcp_config_id, "cfg_1") + self.assertEqual(request.expiry.seconds, 300) + self.assertEqual(request.access_level, "READ_ONLY") + self.assertEqual(request.key_id, "") + + def test_keyword_config(self): + self.mcp.create_session_token( + mcp_config_id="cfg_1", identifier="u1", expiry=timedelta(minutes=5) + ) + request = self._sent_request() + self.assertEqual(request.mcp_config_id, "cfg_1") + self.assertEqual(request.key_id, "") + self.assertEqual(request.expiry.seconds, 300) + + # Connection tokens. + + def test_connection_sent_as_key_id_not_config(self): + self.mcp.create_session_token(connection_name="GMAIL", identifier="u1") + request = self._sent_request() + self.assertEqual(request.key_id, "GMAIL") + self.assertEqual(request.mcp_config_id, "") + self.assertEqual(request.identifier, "u1") + + def test_connection_name_sent_as_given(self): + # The server matches names without regard to case; the SDK does not rewrite them. + self.mcp.create_session_token(connection_name="gMail", identifier="u1") + self.assertEqual(self._sent_request().key_id, "gMail") + + def test_connection_with_positional_identifier(self): + self.mcp.create_session_token(None, "u1", connection_name="GMAIL") + request = self._sent_request() + self.assertEqual(request.key_id, "GMAIL") + self.assertEqual(request.identifier, "u1") + + def test_calls_create_mcp_session_token_rpc(self): + self.mcp.create_session_token(connection_name="GMAIL", identifier="u1") + self.assertIs( + self.core_client.grpc_exec.call_args.args[0], + self.mcp.mcp_service.CreateMcpSessionToken.with_call, + ) + + def test_returns_grpc_exec_result(self): + result = self.mcp.create_session_token(connection_name="GMAIL", identifier="u1") + self.assertIs(result, self.core_client.grpc_exec.return_value) + + def test_connection_omitted_optionals_stay_unset(self): + self.mcp.create_session_token(connection_name="GMAIL", identifier="u1") + request = self._sent_request() + self.assertFalse(request.HasField("expiry")) + self.assertEqual(request.access_level, "") + + def test_connection_forwards_expiry_in_seconds(self): + self.mcp.create_session_token( + connection_name="GMAIL", identifier="u1", expiry=timedelta(minutes=15) + ) + request = self._sent_request() + self.assertTrue(request.HasField("expiry")) + self.assertEqual(request.expiry.seconds, 900) + + def test_expiry_drops_fractional_seconds(self): + self.mcp.create_session_token( + connection_name="GMAIL", identifier="u1", expiry=timedelta(seconds=90, milliseconds=500) + ) + request = self._sent_request() + self.assertEqual(request.expiry.seconds, 90) + self.assertEqual(request.expiry.nanos, 0) + + def test_connection_forwards_access_levels(self): + for level in ("READ_ONLY", "FULL"): + with self.subTest(level=level): + self.mcp.create_session_token( + connection_name="GMAIL", identifier="u1", access_level=level + ) + self.assertEqual(self._sent_request().access_level, level) + + def test_does_not_validate_access_level_client_side(self): + # The server is the authority on accepted values; the SDK passes them through. + self.mcp.create_session_token( + connection_name="GMAIL", identifier="u1", access_level="read_only" + ) + self.assertEqual(self._sent_request().access_level, "read_only") + + def test_connection_name_is_keyword_only(self): + # Five positionals would put connection_name in the slot after access_level. + with self.assertRaises(TypeError): + self.mcp.create_session_token(None, "u1", None, None, "GMAIL") + self.core_client.grpc_exec.assert_not_called() + + # Validation happens before any request, only for the connection form. + + def test_invalid_connection_args_rejected_before_request(self): + for case, kwargs in INVALID_CONNECTION_ARGS.items(): + with self.subTest(case=case): + with self.assertRaises(ValueError): + self.mcp.create_session_token(**kwargs) + self.core_client.grpc_exec.assert_not_called() + + def test_no_target_at_all_rejected_before_request(self): + with self.assertRaisesRegex(ValueError, "mcp_config_id or connection_name is required"): + self.mcp.create_session_token(identifier="u1") + self.core_client.grpc_exec.assert_not_called() + + def test_whitespace_connection_name_goes_to_server(self): + # Like identifier, only an empty name is rejected locally. + self.mcp.create_session_token(connection_name=" ", identifier="u1") + self.assertEqual(self._sent_request().key_id, " ") + + def test_whitespace_identifier_goes_to_server_for_connection(self): + self.mcp.create_session_token(connection_name="GMAIL", identifier=" ") + self.assertEqual(self._sent_request().identifier, " ") + + def test_connection_error_messages_name_the_problem(self): + with self.assertRaisesRegex(ValueError, "not both"): + self.mcp.create_session_token( + mcp_config_id="cfg_1", connection_name="GMAIL", identifier="u1" + ) + with self.assertRaisesRegex(ValueError, "connection_name must not be empty"): + self.mcp.create_session_token(connection_name="", identifier="u1") + with self.assertRaisesRegex(ValueError, "identifier is required"): + self.mcp.create_session_token(connection_name="GMAIL", identifier="") + + def test_config_form_passes_through_as_in_v2_20_0(self): + # No client-side validation for the config form: the server decides. + for case, (args, kwargs, config_id, identifier) in MCP_CLIENT_PASS_THROUGH.items(): + with self.subTest(case=case): + self.core_client.grpc_exec.reset_mock() + self.mcp.create_session_token(*args, **kwargs) + self.core_client.grpc_exec.assert_called_once() + request = self._sent_request() + self.assertEqual(request.mcp_config_id, config_id) + self.assertEqual(request.identifier, identifier) + self.assertEqual(request.key_id, "") + + def test_config_form_server_error_propagates(self): + self.core_client.grpc_exec.side_effect = _not_found() + with self.assertRaises(ScalekitNotFoundException): + self.mcp.create_session_token("", "u1") + + def test_server_error_propagates(self): + self.core_client.grpc_exec.side_effect = _not_found() + with self.assertRaises(ScalekitNotFoundException): + self.mcp.create_session_token(connection_name="missing", identifier="u1") + + +class TestActionsSessionTokenTargets(unittest.TestCase): + def setUp(self): + expires_at = Timestamp(seconds=1_800_000_000) + self.mcp_client = MagicMock() + self.mcp_client.create_session_token.return_value = ( + CreateMcpSessionTokenResponse(token="tok", expires_at=expires_at), + None, + ) + self.actions = ActionClient(MagicMock(), MagicMock(), mcp_client=self.mcp_client) + + def _forwarded(self): + call = self.mcp_client.create_session_token.call_args + self.assertEqual(call.args, ()) + return call.kwargs + + def test_positional_config_back_compat(self): + self.actions.mcp.create_session_token("cfg_1", "user_123", timedelta(minutes=5), "FULL") + self.assertEqual( + self._forwarded(), + { + "mcp_config_id": "cfg_1", + "identifier": "user_123", + "expiry": timedelta(minutes=5), + "access_level": "FULL", + }, + ) + + def test_keyword_config_does_not_forward_connection_name(self): + self.actions.mcp.create_session_token(mcp_config_id="cfg_1", identifier="u1") + kwargs = self._forwarded() + self.assertEqual(kwargs["mcp_config_id"], "cfg_1") + self.assertNotIn("connection_name", kwargs) + + def test_connection_forwards_all_arguments(self): + self.actions.mcp.create_session_token( + connection_name="GMAIL", + identifier="u1", + expiry=timedelta(hours=2), + access_level="READ_ONLY", + ) + self.assertEqual( + self._forwarded(), + { + "connection_name": "GMAIL", + "identifier": "u1", + "expiry": timedelta(hours=2), + "access_level": "READ_ONLY", + }, + ) + + def test_connection_omitted_optionals_forward_none(self): + self.actions.mcp.create_session_token(connection_name="GMAIL", identifier="u1") + kwargs = self._forwarded() + self.assertIsNone(kwargs["expiry"]) + self.assertIsNone(kwargs["access_level"]) + self.assertNotIn("mcp_config_id", kwargs) + + def test_returns_parsed_response(self): + result = self.actions.mcp.create_session_token(connection_name="GMAIL", identifier="u1") + self.assertEqual(result.token, "tok") + self.assertEqual(result.expires_at, Timestamp(seconds=1_800_000_000).ToDatetime()) + + def test_invalid_connection_args_rejected_before_call(self): + for case, kwargs in INVALID_CONNECTION_ARGS.items(): + with self.subTest(case=case): + with self.assertRaises(ValueError): + self.actions.mcp.create_session_token(**kwargs) + self.mcp_client.create_session_token.assert_not_called() + + def test_config_form_keeps_v2_20_0_messages(self): + for case, (kwargs, message) in ACTIONS_CONFIG_ERRORS.items(): + with self.subTest(case=case): + with self.assertRaises(ValueError) as ctx: + self.actions.mcp.create_session_token(**kwargs) + self.assertEqual(str(ctx.exception), message) + self.mcp_client.create_session_token.assert_not_called() + + def test_config_form_whitespace_goes_to_server(self): + # v2.20.0 checked emptiness only; whitespace is the server's to reject. + self.actions.mcp.create_session_token(mcp_config_id=" ", identifier=" ") + kwargs = self._forwarded() + self.assertEqual((kwargs["mcp_config_id"], kwargs["identifier"]), (" ", " ")) + + def test_config_form_validates_before_needing_mcp_client(self): + # As in v2.20.0, the argument check comes before the MCP client lookup. + actions = ActionClient(MagicMock(), MagicMock(), mcp_client=None) + with self.assertRaisesRegex(ValueError, "^mcp_config_id is required$"): + actions.mcp.create_session_token(mcp_config_id="", identifier="u1") + + def test_server_error_propagates(self): + self.mcp_client.create_session_token.side_effect = _not_found() + with self.assertRaises(ScalekitNotFoundException): + self.actions.mcp.create_session_token(connection_name="missing", identifier="u1") + + +class TestEndToEndThroughBothFacades(unittest.TestCase): + """The actions facade drives the real McpClient: the wire request is right.""" + + def setUp(self): + self.core_client = MagicMock() + self.core_client.grpc_exec.return_value = (CreateMcpSessionTokenResponse(token="tok"), None) + mcp = McpClient.__new__(McpClient) + mcp.core_client = self.core_client + mcp.mcp_service = MagicMock() + self.actions = ActionClient(MagicMock(), MagicMock(), mcp_client=mcp) + + def _sent_request(self): + return self.core_client.grpc_exec.call_args.args[1] + + def test_connection_token_wire_request(self): + result = self.actions.mcp.create_session_token( + connection_name="GMAIL", identifier="u1", access_level="READ_ONLY" + ) + request = self._sent_request() + self.assertEqual((request.key_id, request.mcp_config_id), ("GMAIL", "")) + self.assertEqual(request.access_level, "READ_ONLY") + self.assertEqual(result.token, "tok") + + def test_config_token_wire_request(self): + self.actions.mcp.create_session_token("cfg_1", "u1") + request = self._sent_request() + self.assertEqual((request.key_id, request.mcp_config_id), ("", "cfg_1")) + + +if __name__ == "__main__": + unittest.main()