diff --git a/.changes/unreleased/.gitkeep b/.changes/unreleased/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.changes/unreleased/added-20261009-153301-mcp-connection-session-token.yaml b/.changes/unreleased/added-20261009-153301-mcp-connection-session-token.yaml new file mode 100644 index 0000000..6ed919a --- /dev/null +++ b/.changes/unreleased/added-20261009-153301-mcp-connection-session-token.yaml @@ -0,0 +1,18 @@ +kind: added +body: | + `scalekit.actions.mcp.createSessionToken({ connectionName, identifier, expirySeconds?, accessLevel? })` 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 `connectionName` instead of `mcpConfigId`; if both are set, `mcpConfigId` is used. Existing `mcpConfigId` calls are unchanged. For either target, `accessLevel: '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. [Reference](https://github.com/scalekit-inc/scalekit-sdk-node/blob/main/REFERENCE.md#virtual-mcp-servers) + + ```ts + const session = await scalekit.actions.mcp.createSessionToken({ + connectionName: 'gmail', + identifier: 'user_123', + expirySeconds: 900, + accessLevel: 'READ_ONLY', + }); + const headers = { Authorization: `Bearer ${session.token}` }; + ``` +time: '2026-10-09T15:33:01.189094+00:00' +custom: + Breaking: 'No' + Ticket: SK-2195 + Migration: '' diff --git a/.prettierignore b/.prettierignore index b78022e..7c7b4c3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,4 +7,7 @@ CLAUDE.md jest.config.js README.md REFERENCE.md -src/pkg/grpc/ \ No newline at end of file +src/pkg/grpc/ +# Release notes: generated by release tooling +CHANGELOG.md +.changes/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..474ceed --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,430 @@ +# 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.19.0 were imported from [GitHub Releases](https://github.com/scalekit-inc/scalekit-sdk-node/releases). Their wording is kept, with small corrections. + +## [2.19.0] - 2026-10-05 + +### Changes + +- feat: accept multiple issuers in token validation (SK-2080) ([#233](https://github.com/scalekit-inc/scalekit-sdk-node/pull/233)) + +## [2.18.0] - 2026-09-29 + +### Changes + +- [SK-1989] Add Virtual MCP and custom connector clients, complete the actions surface ([#227](https://github.com/scalekit-inc/scalekit-sdk-node/pull/227)) + +## [2.17.0] - 2026-09-25 + +### Changes + +- [SK-2043] chore: update proto to v0.1.150.0 (v2.16.2) ([#229](https://github.com/scalekit-inc/scalekit-sdk-node/pull/229)) +- Add getLoginRequestDetails to AuthClient ([#231](https://github.com/scalekit-inc/scalekit-sdk-node/pull/231)) + +## [2.16.1] - 2026-09-21 + +### Changes + +- [SK-2034] fix(deps): resolve all open Dependabot alerts + bump to 2.16.1 ([#228](https://github.com/scalekit-inc/scalekit-sdk-node/pull/228)) + +## [2.16.0] - 2026-09-18 + +### Changes + +- Adding support for Managing ResourceClients ([#226](https://github.com/scalekit-inc/scalekit-sdk-node/pull/226)) + +## [2.15.0] - 2026-09-11 + +### Changes + +- Add listTools method to ActionsClient ([#222](https://github.com/scalekit-inc/scalekit-sdk-node/pull/222)) + +## [2.14.0] - 2026-09-09 + +### Changes + +- [SK-1912] feat: add resources client for listing and revoking user consents ([#221](https://github.com/scalekit-inc/scalekit-sdk-node/pull/221)) +- [SK-1291] feat(tools): add searchTools RPC support ([#217](https://github.com/scalekit-inc/scalekit-sdk-node/pull/217)) + +## [2.13.0] - 2026-09-08 + +### Changes + +- fix: stop transport-level ECONNRESET from surfacing as ScalekitConflictException ([#216](https://github.com/scalekit-inc/scalekit-sdk-node/pull/216)) + +## [2.12.0] - 2026-08-18 + +### Changes + +Scalekit v2.12.0 introduces encrypted-session middleware, mirroring the Python SDK’s middleware. + +This release also includes a security fix by bumping axios to a more secure version. The middleware is framework-agnostic, with adapters for Express and Next.js, and includes features like transparent session refresh, full logout, and configurable local or full logout. + +## [2.11.0] - 2026-07-28 + +### Changes + +- [SK-1339] feat: typed updateLoginUserDetails response + listEventsPaginated ([#210](https://github.com/scalekit-inc/scalekit-sdk-node/pull/210)) +- [SK-1357] Sanitize AxiosError to avoid leaking client_secret on token exchange failure ([#209](https://github.com/scalekit-inc/scalekit-sdk-node/pull/209)) + +## [2.10.0] - 2026-07-17 + +### Changes + +- chore: update proto to v0.1.137.0 ([#206](https://github.com/scalekit-inc/scalekit-sdk-node/pull/206)) + +## [2.9.0] - 2026-07-13 + +### Changes + +- [SK-1227] Add listConnections to Actions and Connection clients ([#201](https://github.com/scalekit-inc/scalekit-sdk-node/pull/201)) +- [SK-1227] Re-export proto Timestamp as AppConnectionTimestamp ([#204](https://github.com/scalekit-inc/scalekit-sdk-node/pull/204)) + +## [2.8.0] - 2026-07-12 + +### Changes + +#### Release Notes + +This release introduces configurable timeouts for management APIs and tool-call APIs. + +- New timeoutMs option — timeout for management API calls (organizations, users, connections, etc.). Default: 20 seconds. +- New toolTimeoutMs option — timeout for tool-call APIs (tools.*, actions.executeTool, actions.request), which can run longer as they call third-party providers. Default: 60 seconds. + +```typescript +const scalekit = new ScalekitClient(envUrl, clientId, clientSecret, { + timeoutMs: 20_000, + toolTimeoutMs: 60_000, +}); +``` + +- Calls that exceed the timeout now throw ScalekitGatewayTimeoutException instead of waiting indefinitely. +- Invalid timeout values (zero or negative) throw an error at client creation. +- actions.request default timeout changed from 30 to 60 seconds; it can still be overridden per call with timeoutMs. + +## [2.7.0] - 2026-07-07 + +### Changes + +- fix(SK-819, SK-821): provider error differentiation, blind retry fix, and upsert credentials ([#200](https://github.com/scalekit-inc/scalekit-sdk-node/pull/200)) + +## [2.6.3] - 2026-06-12 + +### Changes + +- [SK-623] chore(deps): bump 5 low-risk Node deps ([#189](https://github.com/scalekit-inc/scalekit-sdk-node/pull/189)) +- [SK-625] chore(deps): bump axios from 1.13.5 to 1.17.0 ([#190](https://github.com/scalekit-inc/scalekit-sdk-node/pull/190)) +- [SK-626] chore(deps): bump @bufbuild stack (protobuf + buf + protoc-gen-es) ([#192](https://github.com/scalekit-inc/scalekit-sdk-node/pull/192)) +- [SK-628] chore(deps): bump dotenv from 16.6.1 to 17.4.2 ([#193](https://github.com/scalekit-inc/scalekit-sdk-node/pull/193)) +- [SK-630] chore(deps): bump jest + @types/jest from 29 to 30 ([#194](https://github.com/scalekit-inc/scalekit-sdk-node/pull/194)) +- [SK-632] chore(deps): bump @types/node from 20 to 25 ([#195](https://github.com/scalekit-inc/scalekit-sdk-node/pull/195)) +- [SK-633] chore(deps): bump jose from 5 to 6 ([#196](https://github.com/scalekit-inc/scalekit-sdk-node/pull/196)) +- [SK-643] chore(deps): bump typescript from 5 to 6 ([#197](https://github.com/scalekit-inc/scalekit-sdk-node/pull/197)) +- chore: release v2.6.3 ([#198](https://github.com/scalekit-inc/scalekit-sdk-node/pull/198)) + +## [2.6.2] - 2026-05-22 + +### Changes + +- feat: organization session policy SDK methods ([#177](https://github.com/scalekit-inc/scalekit-sdk-node/pull/177)) +- feat: regenerate protos from v0.1.123.0 and add slug/logoUrl to createOrganization ([#184](https://github.com/scalekit-inc/scalekit-sdk-node/pull/184)) +- feat: add new organization and user SDK methods with external_id support ([#180](https://github.com/scalekit-inc/scalekit-sdk-node/pull/180)) +- Release tag 2.6.2 ([#185](https://github.com/scalekit-inc/scalekit-sdk-node/pull/185)) + +## [2.6.0] - 2026-04-24 + +### Changes + +- [SK-2658] feat(token): add updateToken method ([#156](https://github.com/scalekit-inc/scalekit-sdk-node/pull/156)) +- [SK-2664] feat(m2m): add M2MClient (ClientService org-client CRUD) ([#157](https://github.com/scalekit-inc/scalekit-sdk-node/pull/157)) +- [SK-2668] feat(users): add listUserRoles and listUserPermissions ([#158](https://github.com/scalekit-inc/scalekit-sdk-node/pull/158)) +- [SK-2671] feat(roles): add updateDefaultRoles and listDependentRoles ([#159](https://github.com/scalekit-inc/scalekit-sdk-node/pull/159)) +- feat(connected-accounts): add user verification and magic link enhancements ([#161](https://github.com/scalekit-inc/scalekit-sdk-node/pull/161)) +- Release version 20260402 ([#166](https://github.com/scalekit-inc/scalekit-sdk-node/pull/166)) +- build(deps-dev): bump handlebars from 4.7.8 to 4.7.9 ([#163](https://github.com/scalekit-inc/scalekit-sdk-node/pull/163)) +- build(deps-dev): bump picomatch from 2.3.1 to 2.3.2 ([#167](https://github.com/scalekit-inc/scalekit-sdk-node/pull/167)) +- docs: add AGENTKIT.md for AgentKit APIs ([#169](https://github.com/scalekit-inc/scalekit-sdk-node/pull/169)) +- feat(tools): sync proto v0.1.114.0 — is_custom_mcp + Filter fields for custom MCP ([#171](https://github.com/scalekit-inc/scalekit-sdk-node/pull/171)) +- Adding Release Pipeline ([#172](https://github.com/scalekit-inc/scalekit-sdk-node/pull/172)) +- Upgrade npm just before publishing ([#174](https://github.com/scalekit-inc/scalekit-sdk-node/pull/174)) + +## [2.5.0] - 2026-03-19 + +### Changes + +- Adds support for Agent Auth And Connected Accounts. + +## [2.4.0] - 2026-03-10 + +### Changes + +- Updates for ci ([#152](https://github.com/scalekit-inc/scalekit-sdk-node/pull/152)) +- Added Connection API - Add/Delete +- Release v2.4.0 ([#155](https://github.com/scalekit-inc/scalekit-sdk-node/pull/155)) + +## [2.3.0] - 2026-03-10 + +### Changes + +- API Tokens support added. + +## [2.2.2] - 2026-02-23 + +### Changes + +- Bump axios from 1.13.4 to 1.13.5 ([#142](https://github.com/scalekit-inc/scalekit-sdk-node/pull/142)) +- Using speckit: verify accuracy of snippets + make hyperlinks non-relative to base URL ([#145](https://github.com/scalekit-inc/scalekit-sdk-node/pull/145)) +- Bump qs from 6.14.1 to 6.14.2 ([#144](https://github.com/scalekit-inc/scalekit-sdk-node/pull/144)) + +- Bump axios and qs versions ([#147](https://github.com/scalekit-inc/scalekit-sdk-node/pull/147)) + +## [2.2.0] - 2026-02-10 + +### Changes + +- Migrate SDK to Connect/Protobuf v2 and cleanup ([#140](https://github.com/scalekit-inc/scalekit-sdk-node/pull/140)) + +## [2.1.9] - 2026-01-14 + +### Changes + +- added changes for domain type ([#131](https://github.com/scalekit-inc/scalekit-sdk-node/pull/131)) + +#### Release Notes + +#### Version 2.1.9 + +#### New Feature: Domain List API Type Filtering + +Added optional type filtering to `listDomains()` method. Filter domains by `ALLOWED_EMAIL_DOMAIN` or `ORGANIZATION_DOMAIN` type. + +**API:** +```typescript +// List all domains (no filter) +await client.domain.listDomains('org_123456'); + +// Filter by type +await client.domain.listDomains('org_123456', { + domainType: DomainType.ALLOWED_EMAIL_DOMAIN // or 'ORGANIZATION_DOMAIN' +}); +``` + +**Highlights:** +- ✅ Fully backward compatible +- ✅ Supports enum and string values +- ✅ Comprehensive test coverage +- ✅ No migration required + +#### Changes + +**Added:** +- Type filtering parameter in `listDomains()` +- `resolveDomainType()` helper method +- Test coverage for all filtering scenarios + +**Improved:** +- Error messages for invalid domain types +- Type safety for domain parameters + +--- + +#### Migration + +**No migration needed** - existing code works unchanged. The `domainType` parameter is optional. + +--- + +**Documentation:** [Domain API Docs](https://docs.scalekit.com/apis/#tag/domains) + +## [2.1.8] - 2026-01-04 + +### Changes + +- Bump js-yaml from 3.14.1 to 3.14.2 in https://github.com/scalekit-inc/scalekit-sdk-node/pull/127 +- Add WebAuthn support in https://github.com/scalekit-inc/scalekit-sdk-node/pull/130 +- Bump qs from 6.13.0 to 6.14.1 in https://github.com/scalekit-inc/scalekit-sdk-node/pull/134 + +## [2.1.7] - 2025-12-23 + +### Changes + +- Add upsertUserManagementSettings method to OrganizationClient in https://github.com/scalekit-inc/scalekit-sdk-node/pull/128 + +## [2.1.6] - 2025-11-19 + +### Changes + +*Generate proto files to support given_name and family_name in user object in https://github.com/scalekit-inc/scalekit-sdk-node/pull/126 + +## [2.1.5] - 2025-11-13 + +### Changes + +- add verifyInterceptorPayload method in https://github.com/scalekit-inc/scalekit-sdk-node/pull/119 + +## [2.1.4] - 2025-10-13 + +### Changes + +- Add session management sdk methods https://github.com/scalekit-inc/scalekit-sdk-node/pull/111 + +## [2.1.3] - 2025-09-18 + +### Changes + +#### Enhancements +- Add getDomain and deleteDomain methods to DomainClient ([#108](https://github.com/scalekit-inc/scalekit-sdk-node/pull/108)) +- Add role and permission management sdk methods + +## [2.1.1] - 2025-09-02 + +### Changes + +- Add domainType option to createDomain method and updated proto files ([#107](https://github.com/scalekit-inc/scalekit-sdk-node/pull/107)) + +## [2.1.0] - 2025-08-22 + +### Changes + +- Update Error Handling for Node SDK ([#105](https://github.com/scalekit-inc/scalekit-sdk-node/pull/105)) +- Rename sendActivationEmail to sendInvitationEmail and add resendInvite sdk method ([#106](https://github.com/scalekit-inc/scalekit-sdk-node/pull/106)) + +## [2.0.1] - 2025-07-16 + +### Changes + +- Implemented issuer, audience, and scope validation in tokens ([#103](https://github.com/scalekit-inc/scalekit-sdk-node/pull/103)) + +## [2.0.0] - 2025-07-10 + +### Changes + +- Implemented user CRUD methods and refresh token handling in SDK ([#96](https://github.com/scalekit-inc/scalekit-sdk-node/pull/96)) + +## [1.0.14] - 2025-07-03 + +### Changes + +- Upgrade axios version from 1.7.5 to 1.10.0 ([#101](https://github.com/scalekit-inc/scalekit-sdk-node/pull/101)) +- add @bufbuild/protobuf as peerDependencies ([#100](https://github.com/scalekit-inc/scalekit-sdk-node/pull/100)) +- Upgrade undici version to 5.29.0 ([#102](https://github.com/scalekit-inc/scalekit-sdk-node/pull/102)) + +## [1.0.13] - 2025-07-03 + +### Changes + +- made changes to support templateVariables in sendPasswordlessEmail method ([#99](https://github.com/scalekit-inc/scalekit-sdk-node/pull/99)) + +## [1.0.12] - 2025-06-03 + +### Changes + +- Modified the SDK to handle passwordless authentication using magic links. ([#98](https://github.com/scalekit-inc/scalekit-sdk-node/pull/98)) + +## [1.0.11] - 2025-06-02 + +### Changes + +- Add minimum requirements section ([#83](https://github.com/scalekit-inc/scalekit-sdk-node/pull/83)) +- Implemented methods for passwordless in the sdk ([#97](https://github.com/scalekit-inc/scalekit-sdk-node/pull/97)) + +## [1.0.10] - 2025-01-24 + +### Changes + +- Bump undici from 5.28.4 to 5.28.5 ([#71](https://github.com/scalekit-inc/scalekit-sdk-node/pull/71)) + +## [1.0.9] - 2025-01-23 + +### Changes + +- Nodejs minimum version ([#69](https://github.com/scalekit-inc/scalekit-sdk-node/pull/69)) +- [SK-1062] Remove Get and Delete Portal Link Methods ([#67](https://github.com/scalekit-inc/scalekit-sdk-node/pull/67)) + +## [1.0.8] - 2024-11-16 + +### Changes + +- Bump axios from 1.7.3 to 1.7.5 ([#30](https://github.com/scalekit-inc/scalekit-sdk-node/pull/30)) +- Bump @bufbuild/buf from 1.36.0 to 1.42.0 ([#44](https://github.com/scalekit-inc/scalekit-sdk-node/pull/44)) +- Export Scalekit for backward compatibility ([#32](https://github.com/scalekit-inc/scalekit-sdk-node/pull/32)) +- Directory sync changes ([#47](https://github.com/scalekit-inc/scalekit-sdk-node/pull/47)) + +## [1.0.6] - 2024-08-09 + +### Changes + +- Fix connection provider ([#22](https://github.com/scalekit-inc/scalekit-sdk-node/pull/22)) +- IDP Initiated SSO DX v2 ([#23](https://github.com/scalekit-inc/scalekit-sdk-node/pull/23)) + +## [1.0.5] - 2024-07-18 + +### Changes + +- Bump typescript from 5.4.5 to 5.5.3 ([#10](https://github.com/scalekit-inc/scalekit-sdk-node/pull/10)) +- Release/v1.0.5 ([#11](https://github.com/scalekit-inc/scalekit-sdk-node/pull/11)) +- Bump @types/node from 20.12.7 to 20.14.10 ([#9](https://github.com/scalekit-inc/scalekit-sdk-node/pull/9)) +- Bump @types/node from 20.14.10 to 20.14.11 ([#12](https://github.com/scalekit-inc/scalekit-sdk-node/pull/12)) + +## [1.0.4] - 2024-06-24 + +### Changes + +- Improve readme with examples +- Add enable/disable connection +- Add email as default scope + +## [1.0.1] - 2024-06-13 + +### Changes + +- First Release of the official Scalekit Node SDK + +[2.19.0]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.19.0 +[2.18.0]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.18.0 +[2.17.0]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.17.0 +[2.16.1]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.16.1 +[2.16.0]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.16.0 +[2.15.0]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.15.0 +[2.14.0]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.14.0 +[2.13.0]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.13.0 +[2.12.0]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.12.0 +[2.11.0]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.11.0 +[2.10.0]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.10.0 +[2.9.0]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.9.0 +[2.8.0]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.8.0 +[2.7.0]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.7.0 +[2.6.3]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/2.6.3 +[2.6.2]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/2.6.2 +[2.6.0]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/2.6.0 +[2.5.0]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.5.0 +[2.4.0]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.4.0 +[2.3.0]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.3.0 +[2.2.2]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.2.2 +[2.2.0]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.2.0 +[2.1.9]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.1.9 +[2.1.8]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.1.8 +[2.1.7]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.1.7 +[2.1.6]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.1.6 +[2.1.5]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.1.5 +[2.1.4]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.1.4 +[2.1.3]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.1.3 +[2.1.1]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.1.1 +[2.1.0]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.1.0 +[2.0.1]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.0.1 +[2.0.0]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v2.0.0 +[1.0.14]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v1.0.14 +[1.0.13]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v1.0.13 +[1.0.12]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v1.0.12 +[1.0.11]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v1.0.11 +[1.0.10]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v1.0.10 +[1.0.9]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v1.0.9 +[1.0.8]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v1.0.8 +[1.0.6]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v1.0.6 +[1.0.5]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v1.0.5 +[1.0.4]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v1.0.4 +[1.0.1]: https://github.com/scalekit-inc/scalekit-sdk-node/releases/tag/v1.0.1 diff --git a/CLAUDE.md b/CLAUDE.md index 6956792..9646dd9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,15 +28,15 @@ TypeScript 5.x (documentation target; examples mus: Follow standard conventions -## Release - -Package: `@scalekit-sdk/node` on npm. Workflow: `.github/workflows/release.yml`. - -1. Bump the version in `package.json` and `package-lock.json`. Use a minor or patch bump. -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.12.0` for `2.12.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 npm. +## Release notes and releases + +- Package: `@scalekit-sdk/node` on npm. Workflow: `.github/workflows/release.yml`. +- Every change that users can notice adds a release-notes file under `.changes/unreleased/`. +- Never hand-edit the version (`package.json`, `package-lock.json`) 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 npm. diff --git a/README.md b/README.md index 72aff42..f2bc1f1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ -

Official Node.js SDK for Scalekit — the auth stack for agents.
+

Official Node.js SDK for Scalekit.
Authentication, authorization, and tool-calling for human-in-the-loop and autonomous agent flows.

[![npm version](https://img.shields.io/npm/v/@scalekit-sdk/node.svg)](https://www.npmjs.com/package/@scalekit-sdk/node) @@ -21,7 +21,7 @@ Authentication, authorization, and tool-calling for human-in-the-loop and autono --- -This is the official Node.js SDK for [Scalekit](https://scalekit.com), — the auth stack for agents. 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 is the official Node.js SDK for [Scalekit](https://scalekit.com). 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 Node.js SDK enables both traditional B2B authentication and cutting-edge agentic workflows. #### Agent-First Features - **Agent Identity** — Agents as first-class actors with human ownership and org context diff --git a/REFERENCE.md b/REFERENCE.md index 57f11a1..88de61a 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -7493,7 +7493,7 @@ console.log(connectedAccount?.status === ConnectorStatus.ACTIVE); ## Virtual MCP Servers -A Virtual MCP server exposes a chosen set of connections and tools over the Model Context Protocol, so any MCP-capable agent can call them. Create one configuration per agent role (not per user), and mint a short-lived session token per user per run. The server URL stays the same; the token carries the user's identity. +A Virtual MCP server exposes a chosen set of connections and tools over the Model Context Protocol, so any MCP-capable agent can call them. Create one configuration per agent role (not per user), and mint a short-lived session token per user per run. The server URL stays the same; the token carries the user's identity. Each AgentKit connection also has its own MCP server with all of its tools; mint tokens for it with `createSessionToken({ connectionName, ... })`. Only the generally available `McpConfig` API is covered. The older `Mcp` and `McpInstance` families are marked PREVIEW and are not exposed. @@ -7921,9 +7921,16 @@ for (const account of pending) {
-Mints a session token for one user against one configuration. +Mints a session token for one user, for either a Virtual MCP server or a connection's own MCP server. Set a target; if both are set, `mcpConfigId` is used: -The token carries the user's identity. Mint a fresh one before every agent run, never reuse one across runs, and set the expiry longer than the run is expected to take. +- `mcpConfigId`: the token works on that configuration's `config.mcpServerUrl`. +- `connectionName`: the token works on the connection's own MCP server at `/mcp/v3/connections/`, which exposes all of that connection's tools without an MCP configuration. The name is matched without regard to case, but the URL path is case-sensitive: build it from the connection's name exactly as stored. + +A token works only on the server it was minted for. The token carries the user's identity. Mint a fresh one before every agent run, never reuse one across runs, and set the expiry longer than the run is expected to take. + +TypeScript rejects a call with neither target at compile time. At run time, only the connection form is checked before the request is sent: a non-string `connectionName` throws, and so does an `identifier` that is not a non-empty string (`identifier is required`). Calls with `mcpConfigId` are unchanged: they are not checked beyond `expirySeconds`, so a missing or empty ID is sent and rejected by the server, and `connectionName` is ignored when a non-empty `mcpConfigId` is set. + +For a connection, the user should have an active connected account on it. When they do not, depending on the environment, the call either fails with `ScalekitBadRequestException`, or succeeds after creating a pending connected account, in which case tool calls report the account as not connected. The call fails with `ScalekitNotFoundException` when no active connection has that name, and with `ScalekitBadRequestException` when the connection is not an AgentKit connection or the identifier or expiry is rejected.
@@ -7938,6 +7945,7 @@ The token carries the user's identity. Mint a fresh one before every agent run,
```typescript +// Virtual MCP server built from a configuration const { config } = await scalekitClient.actions.mcp.getConfig(''); const session = await scalekitClient.actions.mcp.createSessionToken({ mcpConfigId: '', @@ -7948,6 +7956,20 @@ const session = await scalekitClient.actions.mcp.createSessionToken({ // Hand both to your MCP client console.log(config?.mcpServerUrl, session.token, session.expiresAt); ``` + +```typescript +// A connection's own MCP server +const session = await scalekitClient.actions.mcp.createSessionToken({ + connectionName: 'gmail', + identifier: 'user_123', + expirySeconds: 900, + accessLevel: 'READ_ONLY', +}); + +// Hand both to your MCP client. The URL path is case-sensitive: use the connection's stored name. +const serverUrl = `${process.env.SCALEKIT_ENVIRONMENT_URL}/mcp/v3/connections/gmail`; +console.log(serverUrl, session.token, session.expiresAt); +```
@@ -7955,34 +7977,43 @@ console.log(config?.mcpServerUrl, session.token, session.expiresAt); #### ⚙️ Parameters +Type: `CreateMcpSessionTokenParams`. Set `mcpConfigId` or `connectionName`; if both are set, `mcpConfigId` is used. +
-**params.mcpConfigId:** `string` - The configuration +**params.mcpConfigId?:** `string` - The configuration. If both targets are set, this one is used and `connectionName` is ignored.
-**params.identifier:** `string` - Your application's identifier for the end user +**params.connectionName?:** `string` - The connection name: the same value used as `connectionName` elsewhere in the SDK. Used when `mcpConfigId` is omitted or empty. It is matched without regard to case, and the token is always issued for the server URL built from the connection's stored name. That URL is case-sensitive, so connect using the stored name exactly as it appears. + +
+
+
+
+ +**params.identifier:** `string` - Your application's identifier for the end user, 1 to 255 characters. For a connection, the user whose connected account the token acts as; required (non-empty) for a connection before the request is sent.
-**params.expirySeconds?:** `number` - Token lifetime in whole seconds. Must be a positive integer; any other value, such as `900.5`, `0` or `NaN`, throws before the request is sent. The server enforces the allowed range. +**params.expirySeconds?:** `number` - Token lifetime in whole seconds. Must be a positive integer; any other value, such as `900.5`, `0` or `NaN`, throws before the request is sent. The server accepts 60 seconds to 24 hours (86400), 1 hour (3600) by default.
-**params.accessLevel?:** `'FULL' | 'READ_ONLY'` - Tools the token can use. `'READ_ONLY'` limits the token to tools annotated read-only (`read_only_hint`): other tools are left out of the tool list and refused when called. `'FULL'`, or omitting it, exposes every tool the configuration exposes. +**params.accessLevel?:** `'FULL' | 'READ_ONLY'` - Tools the token can use. `'READ_ONLY'` limits the token to tools annotated read-only (`read_only_hint`): other tools are left out of the tool list and refused when called. `'FULL'`, or omitting it, exposes every tool the configuration or connection exposes.
diff --git a/lib/index.d.ts b/lib/index.d.ts index e48fe06..7ca96de 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -16,7 +16,7 @@ export { ToolReadinessState } from './pkg/grpc/scalekit/v1/tools/tools_pb'; export type { SearchToolsResponse, SearchedTool, ConnectionReadiness, } from './pkg/grpc/scalekit/v1/tools/tools_pb'; export type { ListToolsResponse, Tool, } from './pkg/grpc/scalekit/v1/tools/tools_pb'; export type { ActionTool, ListToolsResult } from './actions'; -export type { McpSessionTokenAccessLevel } from './mcp'; +export type { McpSessionTokenAccessLevel, CreateMcpSessionTokenParams, } from './mcp'; export type { McpConfig, McpConfigConnectionToolMapping, CreateMcpConfigResponse, GetMcpConfigResponse, ListMcpConfigsResponse, UpdateMcpConfigResponse, DeleteMcpConfigResponse, ListMcpConnectedAccountsResponse, CreateMcpSessionTokenResponse, } from './pkg/grpc/scalekit/v1/mcp/mcp_pb'; export type { AuthPattern, AuthField, Provider, CreateProviderResponse, UpdateProviderResponse, ListProvidersResponse, } from './providers'; export { ProviderType } from './pkg/grpc/scalekit/v1/providers/providers_pb'; diff --git a/lib/index.js.map b/lib/index.js.map index a45ff27..fcb2b8d 100644 --- a/lib/index.js.map +++ b/lib/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA,0DAAwC;AAE/B,yBAFF,kBAAc,CAEE;AACI,mBAHpB,kBAAc,CAGc;AACnC,kBAAe,kBAAc,CAAC;AAE9B,mDAAiC;AACjC,+CAA6B;AAE7B,2CAAyB;AAEzB,uEAAuE;AACvE,2EAA2E;AAC3E,yEAAyE;AACzE,wEAAyE;AAAhE,0GAAA,YAAY,OAAA;AAQrB,iFAAiF;AACjF,mFAAmF;AACnF,8EAA8E;AAC9E,qEAA6E;AAApE,uGAAA,UAAU,OAAA;AAAE,mGAAA,MAAM,OAAA;AAG3B,2EAA2E;AAC3E,mFAAmF;AACnF,oFAAoF;AACpF,mFAAmF;AACnF,kFAAkF;AAClF,oFAAoF;AACpF,yGAGyE;AAFvE,wHAAA,eAAe,OAAA;AACf,sHAAA,aAAa,OAAA;AAGf,8EAA8E;AAC9E,iFAAiF;AACjF,0EAA0E;AAC1E,0EAA0E;AAC1E,oFAI2D;AAHzD,gHAAA,cAAc,OAAA;AACd,oHAAA,kBAAkB,OAAA;AAClB,oHAAA,kBAAkB,OAAA;AAYpB,uEAAuE;AACvE,iFAAiF;AACjF,2FAA2F;AAC3F,+EAA+E;AAC/E,kEAA2E;AAAlE,8GAAA,kBAAkB,OAAA;AAkD3B,8EAA6E;AAApE,4GAAA,YAAY,OAAA"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA,0DAAwC;AAE/B,yBAFF,kBAAc,CAEE;AACI,mBAHpB,kBAAc,CAGc;AACnC,kBAAe,kBAAc,CAAC;AAE9B,mDAAiC;AACjC,+CAA6B;AAE7B,2CAAyB;AAEzB,uEAAuE;AACvE,2EAA2E;AAC3E,yEAAyE;AACzE,wEAAyE;AAAhE,0GAAA,YAAY,OAAA;AAQrB,iFAAiF;AACjF,mFAAmF;AACnF,8EAA8E;AAC9E,qEAA6E;AAApE,uGAAA,UAAU,OAAA;AAAE,mGAAA,MAAM,OAAA;AAG3B,2EAA2E;AAC3E,mFAAmF;AACnF,oFAAoF;AACpF,mFAAmF;AACnF,kFAAkF;AAClF,oFAAoF;AACpF,yGAGyE;AAFvE,wHAAA,eAAe,OAAA;AACf,sHAAA,aAAa,OAAA;AAGf,8EAA8E;AAC9E,iFAAiF;AACjF,0EAA0E;AAC1E,0EAA0E;AAC1E,oFAI2D;AAHzD,gHAAA,cAAc,OAAA;AACd,oHAAA,kBAAkB,OAAA;AAClB,oHAAA,kBAAkB,OAAA;AAYpB,uEAAuE;AACvE,iFAAiF;AACjF,2FAA2F;AAC3F,+EAA+E;AAC/E,kEAA2E;AAAlE,8GAAA,kBAAkB,OAAA;AAqD3B,8EAA6E;AAApE,4GAAA,YAAY,OAAA"} \ No newline at end of file diff --git a/lib/mcp.d.ts b/lib/mcp.d.ts index 5cf1453..097ffa2 100644 --- a/lib/mcp.d.ts +++ b/lib/mcp.d.ts @@ -7,6 +7,29 @@ import { CreateMcpConfigResponse, CreateMcpSessionTokenResponse, DeleteMcpConfig * for tools annotated read-only only. */ export type McpSessionTokenAccessLevel = 'FULL' | 'READ_ONLY'; +/** + * Parameters for {@link McpClient.createSessionToken}. Set a target: + * `mcpConfigId` for a Virtual MCP server built from a configuration, or + * `connectionName` for a connection's own MCP server. If both are set, + * `mcpConfigId` is used. + */ +export type CreateMcpSessionTokenParams = { + /** Your application's unique identifier for the user, 1 to 255 characters. */ + identifier: string; + /** Token lifetime in whole seconds, 60 to 86400. Defaults to 3600. */ + expirySeconds?: number; + /** Tools the token can use. Omit for `'FULL'`. */ + accessLevel?: McpSessionTokenAccessLevel; +} & ({ + /** ID of the MCP configuration whose server the token is for. */ + mcpConfigId: string; + /** Ignored when `mcpConfigId` is set. */ + connectionName?: string; +} | { + /** Name of the AgentKit connection whose MCP server the token is for. */ + connectionName: string; + mcpConfigId?: undefined; +}); /** * Client for Virtual MCP servers. * @@ -113,26 +136,82 @@ export default class McpClient { includeAuthLink?: boolean; }): Promise; /** - * Mints a session token for one user against one configuration. + * Mints a session token for one user, for either a Virtual MCP server or a + * connection's own MCP server. * - * The server URL is static; this token is what carries user identity. Mint a - * fresh one before every agent run and never reuse one across runs. Set the - * expiry longer than the run is expected to take. + * Set a target; if both are set, `mcpConfigId` is used: * - * @param params.mcpConfigId ID of the configuration. - * @param params.identifier Your application's unique identifier for the user. - * @param params.expirySeconds Token lifetime in whole seconds. + * - `mcpConfigId`: the token works on that configuration's + * `config.mcpServerUrl` (see {@link McpClient.getConfig}). + * - `connectionName`: the token works on the connection's own MCP server at + * `/mcp/v3/connections/`, which exposes + * all of that connection's tools without an MCP configuration. The name is + * matched without regard to case, but the URL path is case-sensitive: + * build it from the connection's name exactly as stored. + * + * A token works only on the server it was minted for. The server URL is + * static; the token is what carries user identity. Mint a fresh one before + * every agent run and never reuse one across runs. Set the expiry longer + * than the run is expected to take. + * + * For a connection, the user should have an active connected account on it. + * When they do not, the server either rejects the call, or creates a pending + * connected account and returns a token whose tool calls report the account + * as not connected, depending on the environment. + * + * @param params.mcpConfigId ID of the configuration. If both targets are + * set, this one is used and `connectionName` is ignored. + * @param params.connectionName Name of an AgentKit connection: the same + * value used as `connectionName` elsewhere in the SDK. Used when + * `mcpConfigId` is omitted or empty. + * @param params.identifier Your application's unique identifier for the user, + * 1 to 255 characters. For a connection it is checked before the request: + * an empty value throws `identifier is required`. + * @param params.expirySeconds Token lifetime in whole seconds, from 60 + * seconds to 24 hours (86400); 1 hour (3600) by default. * @param params.accessLevel 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. - * @throws {Error} If `expirySeconds` is not a positive integer. + * configuration or connection exposes. + * @returns The session `token` and its `expiresAt` time. + * @throws {Error} If `expirySeconds` is not a positive integer, or, for a + * connection only, if `connectionName` is not a string or `identifier` is + * empty. No request is sent. Calls with `mcpConfigId` are not checked + * beyond `expirySeconds` and behave as they always have: a missing or empty + * ID is sent and rejected by the server. + * @throws {ScalekitNotFoundException} If `connectionName` matches no active + * connection. + * @throws {ScalekitBadRequestException} If the request is otherwise rejected: + * for example the connection is not an AgentKit connection, the identifier + * or expiry is out of range, or (in environments that do not create pending + * accounts) the user has no active connected account on the connection. * @throws {ScalekitServerException} If a network or server error occurs. + * + * @example + * ```typescript + * // Virtual MCP server built from a configuration + * const { config } = await scalekitClient.actions.mcp.getConfig(configId); + * const session = await scalekitClient.actions.mcp.createSessionToken({ + * mcpConfigId: configId, + * identifier: 'user_123', + * expirySeconds: 900, + * }); + * // Hand config?.mcpServerUrl and session.token to your MCP client + * ``` + * + * @example + * ```typescript + * // A connection's own MCP server + * const session = await scalekitClient.actions.mcp.createSessionToken({ + * connectionName: 'gmail', + * identifier: 'user_123', + * expirySeconds: 900, + * accessLevel: 'READ_ONLY', + * }); + * // The URL path is case-sensitive: use the connection's stored name. + * const serverUrl = `${process.env.SCALEKIT_ENVIRONMENT_URL}/mcp/v3/connections/gmail`; + * // Hand serverUrl and session.token to your MCP client + * ``` */ - createSessionToken(params: { - mcpConfigId: string; - identifier: string; - expirySeconds?: number; - accessLevel?: McpSessionTokenAccessLevel; - }): Promise; + createSessionToken(params: CreateMcpSessionTokenParams): Promise; } diff --git a/lib/mcp.js b/lib/mcp.js index 47f30c4..0e9a742 100644 --- a/lib/mcp.js +++ b/lib/mcp.js @@ -139,38 +139,137 @@ class McpClient { }); } /** - * Mints a session token for one user against one configuration. + * Mints a session token for one user, for either a Virtual MCP server or a + * connection's own MCP server. * - * The server URL is static; this token is what carries user identity. Mint a - * fresh one before every agent run and never reuse one across runs. Set the - * expiry longer than the run is expected to take. + * Set a target; if both are set, `mcpConfigId` is used: * - * @param params.mcpConfigId ID of the configuration. - * @param params.identifier Your application's unique identifier for the user. - * @param params.expirySeconds Token lifetime in whole seconds. + * - `mcpConfigId`: the token works on that configuration's + * `config.mcpServerUrl` (see {@link McpClient.getConfig}). + * - `connectionName`: the token works on the connection's own MCP server at + * `/mcp/v3/connections/`, which exposes + * all of that connection's tools without an MCP configuration. The name is + * matched without regard to case, but the URL path is case-sensitive: + * build it from the connection's name exactly as stored. + * + * A token works only on the server it was minted for. The server URL is + * static; the token is what carries user identity. Mint a fresh one before + * every agent run and never reuse one across runs. Set the expiry longer + * than the run is expected to take. + * + * For a connection, the user should have an active connected account on it. + * When they do not, the server either rejects the call, or creates a pending + * connected account and returns a token whose tool calls report the account + * as not connected, depending on the environment. + * + * @param params.mcpConfigId ID of the configuration. If both targets are + * set, this one is used and `connectionName` is ignored. + * @param params.connectionName Name of an AgentKit connection: the same + * value used as `connectionName` elsewhere in the SDK. Used when + * `mcpConfigId` is omitted or empty. + * @param params.identifier Your application's unique identifier for the user, + * 1 to 255 characters. For a connection it is checked before the request: + * an empty value throws `identifier is required`. + * @param params.expirySeconds Token lifetime in whole seconds, from 60 + * seconds to 24 hours (86400); 1 hour (3600) by default. * @param params.accessLevel 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. - * @throws {Error} If `expirySeconds` is not a positive integer. + * configuration or connection exposes. + * @returns The session `token` and its `expiresAt` time. + * @throws {Error} If `expirySeconds` is not a positive integer, or, for a + * connection only, if `connectionName` is not a string or `identifier` is + * empty. No request is sent. Calls with `mcpConfigId` are not checked + * beyond `expirySeconds` and behave as they always have: a missing or empty + * ID is sent and rejected by the server. + * @throws {ScalekitNotFoundException} If `connectionName` matches no active + * connection. + * @throws {ScalekitBadRequestException} If the request is otherwise rejected: + * for example the connection is not an AgentKit connection, the identifier + * or expiry is out of range, or (in environments that do not create pending + * accounts) the user has no active connected account on the connection. * @throws {ScalekitServerException} If a network or server error occurs. + * + * @example + * ```typescript + * // Virtual MCP server built from a configuration + * const { config } = await scalekitClient.actions.mcp.getConfig(configId); + * const session = await scalekitClient.actions.mcp.createSessionToken({ + * mcpConfigId: configId, + * identifier: 'user_123', + * expirySeconds: 900, + * }); + * // Hand config?.mcpServerUrl and session.token to your MCP client + * ``` + * + * @example + * ```typescript + * // A connection's own MCP server + * const session = await scalekitClient.actions.mcp.createSessionToken({ + * connectionName: 'gmail', + * identifier: 'user_123', + * expirySeconds: 900, + * accessLevel: 'READ_ONLY', + * }); + * // The URL path is case-sensitive: use the connection's stored name. + * const serverUrl = `${process.env.SCALEKIT_ENVIRONMENT_URL}/mcp/v3/connections/gmail`; + * // Hand serverUrl and session.token to your MCP client + * ``` */ createSessionToken(params) { return __awaiter(this, void 0, void 0, function* () { - if (params.expirySeconds !== undefined && - !(Number.isInteger(params.expirySeconds) && params.expirySeconds > 0)) { - // BigInt() below would otherwise throw an unhelpful RangeError. - throw new Error(`expirySeconds must be a positive integer, got ${params.expirySeconds}`); - } - return this.coreClient.connectExec(this.client.createMcpSessionToken, (0, protobuf_1.create)(mcp_pb_1.CreateMcpSessionTokenRequestSchema, Object.assign(Object.assign({ mcpConfigId: params.mcpConfigId, identifier: params.identifier }, (params.expirySeconds !== undefined && { - expiry: (0, protobuf_1.create)(wkt_1.DurationSchema, { - seconds: BigInt(params.expirySeconds), - }), - })), (params.accessLevel !== undefined && { + // Expiry first, so mcpConfigId calls fail exactly as they always have. + const expiry = expiryField(params.expirySeconds); + const target = sessionTokenTarget(params); + return this.coreClient.connectExec(this.client.createMcpSessionToken, (0, protobuf_1.create)(mcp_pb_1.CreateMcpSessionTokenRequestSchema, Object.assign(Object.assign(Object.assign(Object.assign({}, target), { identifier: params.identifier }), expiry), (params.accessLevel !== undefined && { accessLevel: params.accessLevel, })))); }); } } exports.default = McpClient; +/** + * Returns the request's target field: `mcpConfigId` for a configuration, + * `keyId` for a connection. The server requires exactly one of the two, so + * the other is never set. + * + * A non-empty `mcpConfigId` wins, and so does the configuration form when no + * non-empty `connectionName` is given: `mcpConfigId` is then passed through + * as given, unchecked, so existing callers keep their behaviour (a missing or + * empty ID still reaches the server, and `connectionName` is ignored). Only + * the connection form is checked here. + */ +function sessionTokenTarget(params) { + // Read as unknown: plain JavaScript callers bypass the union type. + const { mcpConfigId, connectionName, identifier } = params; + const isConnectionCall = (mcpConfigId == null || mcpConfigId === '') && + connectionName != null && + connectionName !== ''; + if (!isConnectionCall) { + return { mcpConfigId: params.mcpConfigId }; + } + if (typeof connectionName !== 'string') { + throw new Error('connectionName must be a non-empty string'); + } + if (typeof identifier !== 'string' || identifier.trim() === '') { + throw new Error('identifier is required'); + } + return { keyId: connectionName }; +} +/** + * Validates `expirySeconds` and returns the request's `expiry` field, or no + * field when it is omitted so the server applies its default. + */ +function expiryField(expirySeconds) { + if (expirySeconds === undefined) { + return {}; + } + if (!(Number.isInteger(expirySeconds) && expirySeconds > 0)) { + // BigInt() below would otherwise throw an unhelpful RangeError. + throw new Error(`expirySeconds must be a positive integer, got ${expirySeconds}`); + } + return { + expiry: (0, protobuf_1.create)(wkt_1.DurationSchema, { seconds: BigInt(expirySeconds) }), + }; +} //# sourceMappingURL=mcp.js.map \ No newline at end of file diff --git a/lib/mcp.js.map b/lib/mcp.js.map index fd89f46..c9dfea2 100644 --- a/lib/mcp.js.map +++ b/lib/mcp.js.map @@ -1 +1 @@ -{"version":3,"file":"mcp.js","sourceRoot":"","sources":["../src/mcp.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"mcp.js","sourceRoot":"","sources":["../src/mcp.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index fb83c32..5c36fbd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -78,7 +78,10 @@ export type { // Normalized types surfaced in the public `actions.listTools` API. export type { ActionTool, ListToolsResult } from './actions'; -export type { McpSessionTokenAccessLevel } from './mcp'; +export type { + McpSessionTokenAccessLevel, + CreateMcpSessionTokenParams, +} from './mcp'; // Types surfaced by the public `mcp` client (Virtual MCP servers). Only the // generally available McpConfig surface is re-exported; the PREVIEW `Mcp` and diff --git a/src/mcp.ts b/src/mcp.ts index 1bed4d4..b281f94 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -28,6 +28,33 @@ import { */ export type McpSessionTokenAccessLevel = 'FULL' | 'READ_ONLY'; +/** + * Parameters for {@link McpClient.createSessionToken}. Set a target: + * `mcpConfigId` for a Virtual MCP server built from a configuration, or + * `connectionName` for a connection's own MCP server. If both are set, + * `mcpConfigId` is used. + */ +export type CreateMcpSessionTokenParams = { + /** Your application's unique identifier for the user, 1 to 255 characters. */ + identifier: string; + /** Token lifetime in whole seconds, 60 to 86400. Defaults to 3600. */ + expirySeconds?: number; + /** Tools the token can use. Omit for `'FULL'`. */ + accessLevel?: McpSessionTokenAccessLevel; +} & ( + | { + /** ID of the MCP configuration whose server the token is for. */ + mcpConfigId: string; + /** Ignored when `mcpConfigId` is set. */ + connectionName?: string; + } + | { + /** Name of the AgentKit connection whose MCP server the token is for. */ + connectionName: string; + mcpConfigId?: undefined; + } +); + /** * Client for Virtual MCP servers. * @@ -207,47 +234,95 @@ export default class McpClient { } /** - * Mints a session token for one user against one configuration. + * Mints a session token for one user, for either a Virtual MCP server or a + * connection's own MCP server. * - * The server URL is static; this token is what carries user identity. Mint a - * fresh one before every agent run and never reuse one across runs. Set the - * expiry longer than the run is expected to take. + * Set a target; if both are set, `mcpConfigId` is used: * - * @param params.mcpConfigId ID of the configuration. - * @param params.identifier Your application's unique identifier for the user. - * @param params.expirySeconds Token lifetime in whole seconds. + * - `mcpConfigId`: the token works on that configuration's + * `config.mcpServerUrl` (see {@link McpClient.getConfig}). + * - `connectionName`: the token works on the connection's own MCP server at + * `/mcp/v3/connections/`, which exposes + * all of that connection's tools without an MCP configuration. The name is + * matched without regard to case, but the URL path is case-sensitive: + * build it from the connection's name exactly as stored. + * + * A token works only on the server it was minted for. The server URL is + * static; the token is what carries user identity. Mint a fresh one before + * every agent run and never reuse one across runs. Set the expiry longer + * than the run is expected to take. + * + * For a connection, the user should have an active connected account on it. + * When they do not, the server either rejects the call, or creates a pending + * connected account and returns a token whose tool calls report the account + * as not connected, depending on the environment. + * + * @param params.mcpConfigId ID of the configuration. If both targets are + * set, this one is used and `connectionName` is ignored. + * @param params.connectionName Name of an AgentKit connection: the same + * value used as `connectionName` elsewhere in the SDK. Used when + * `mcpConfigId` is omitted or empty. + * @param params.identifier Your application's unique identifier for the user, + * 1 to 255 characters. For a connection it is checked before the request: + * an empty value throws `identifier is required`. + * @param params.expirySeconds Token lifetime in whole seconds, from 60 + * seconds to 24 hours (86400); 1 hour (3600) by default. * @param params.accessLevel 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. - * @throws {Error} If `expirySeconds` is not a positive integer. + * configuration or connection exposes. + * @returns The session `token` and its `expiresAt` time. + * @throws {Error} If `expirySeconds` is not a positive integer, or, for a + * connection only, if `connectionName` is not a string or `identifier` is + * empty. No request is sent. Calls with `mcpConfigId` are not checked + * beyond `expirySeconds` and behave as they always have: a missing or empty + * ID is sent and rejected by the server. + * @throws {ScalekitNotFoundException} If `connectionName` matches no active + * connection. + * @throws {ScalekitBadRequestException} If the request is otherwise rejected: + * for example the connection is not an AgentKit connection, the identifier + * or expiry is out of range, or (in environments that do not create pending + * accounts) the user has no active connected account on the connection. * @throws {ScalekitServerException} If a network or server error occurs. + * + * @example + * ```typescript + * // Virtual MCP server built from a configuration + * const { config } = await scalekitClient.actions.mcp.getConfig(configId); + * const session = await scalekitClient.actions.mcp.createSessionToken({ + * mcpConfigId: configId, + * identifier: 'user_123', + * expirySeconds: 900, + * }); + * // Hand config?.mcpServerUrl and session.token to your MCP client + * ``` + * + * @example + * ```typescript + * // A connection's own MCP server + * const session = await scalekitClient.actions.mcp.createSessionToken({ + * connectionName: 'gmail', + * identifier: 'user_123', + * expirySeconds: 900, + * accessLevel: 'READ_ONLY', + * }); + * // The URL path is case-sensitive: use the connection's stored name. + * const serverUrl = `${process.env.SCALEKIT_ENVIRONMENT_URL}/mcp/v3/connections/gmail`; + * // Hand serverUrl and session.token to your MCP client + * ``` */ - async createSessionToken(params: { - mcpConfigId: string; - identifier: string; - expirySeconds?: number; - accessLevel?: McpSessionTokenAccessLevel; - }): Promise { - if ( - params.expirySeconds !== undefined && - !(Number.isInteger(params.expirySeconds) && params.expirySeconds > 0) - ) { - // BigInt() below would otherwise throw an unhelpful RangeError. - throw new Error( - `expirySeconds must be a positive integer, got ${params.expirySeconds}` - ); - } + async createSessionToken( + params: CreateMcpSessionTokenParams + ): Promise { + // Expiry first, so mcpConfigId calls fail exactly as they always have. + const expiry = expiryField(params.expirySeconds); + const target = sessionTokenTarget(params); return this.coreClient.connectExec( this.client.createMcpSessionToken, create(CreateMcpSessionTokenRequestSchema, { - mcpConfigId: params.mcpConfigId, + ...target, identifier: params.identifier, - ...(params.expirySeconds !== undefined && { - expiry: create(DurationSchema, { - seconds: BigInt(params.expirySeconds), - }), - }), + ...expiry, ...(params.accessLevel !== undefined && { accessLevel: params.accessLevel, }), @@ -255,3 +330,60 @@ export default class McpClient { ); } } + +/** + * Returns the request's target field: `mcpConfigId` for a configuration, + * `keyId` for a connection. The server requires exactly one of the two, so + * the other is never set. + * + * A non-empty `mcpConfigId` wins, and so does the configuration form when no + * non-empty `connectionName` is given: `mcpConfigId` is then passed through + * as given, unchecked, so existing callers keep their behaviour (a missing or + * empty ID still reaches the server, and `connectionName` is ignored). Only + * the connection form is checked here. + */ +function sessionTokenTarget( + params: CreateMcpSessionTokenParams +): { mcpConfigId?: string } | { keyId: string } { + // Read as unknown: plain JavaScript callers bypass the union type. + const { mcpConfigId, connectionName, identifier } = params as { + mcpConfigId?: unknown; + connectionName?: unknown; + identifier?: unknown; + }; + const isConnectionCall = + (mcpConfigId == null || mcpConfigId === '') && + connectionName != null && + connectionName !== ''; + if (!isConnectionCall) { + return { mcpConfigId: params.mcpConfigId }; + } + if (typeof connectionName !== 'string') { + throw new Error('connectionName must be a non-empty string'); + } + if (typeof identifier !== 'string' || identifier.trim() === '') { + throw new Error('identifier is required'); + } + return { keyId: connectionName }; +} + +/** + * Validates `expirySeconds` and returns the request's `expiry` field, or no + * field when it is omitted so the server applies its default. + */ +function expiryField(expirySeconds: number | undefined): { + expiry?: MessageInitShape; +} { + if (expirySeconds === undefined) { + return {}; + } + if (!(Number.isInteger(expirySeconds) && expirySeconds > 0)) { + // BigInt() below would otherwise throw an unhelpful RangeError. + throw new Error( + `expirySeconds must be a positive integer, got ${expirySeconds}` + ); + } + return { + expiry: create(DurationSchema, { seconds: BigInt(expirySeconds) }), + }; +} diff --git a/tests/actions.test.ts b/tests/actions.test.ts index af3ed45..dd52f7b 100644 --- a/tests/actions.test.ts +++ b/tests/actions.test.ts @@ -1,7 +1,10 @@ import ScalekitClient from '../src/scalekit'; import { create } from '@bufbuild/protobuf'; import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; -import { ScalekitServerException } from '../src/errors'; +import { + ScalekitNotFoundException, + ScalekitServerException, +} from '../src/errors'; import { TestDataGenerator, TestOrganizationManager } from './utils/test-data'; import { AuthorizationDetailsSchema, @@ -185,6 +188,72 @@ describe('Actions', () => { }); }); + describe('mcp.createSessionToken with connectionName', () => { + it('should mint a read-only token for the connection MCP server', async () => { + // The call uses the shared ACTIVE fixture account, which must survive. + // Where the fixture is missing, the server may create a pending + // connected account instead; delete only an account this test created. + const accountExisted = await client.actions + .getConnectedAccount({ + connectionName: GMAIL_CONNECTION_NAME, + identifier: GMAIL_IDENTIFIER, + }) + .then( + () => true, + (error: unknown) => { + if (error instanceof ScalekitNotFoundException) return false; + throw error; + } + ); + + try { + const response = await client.actions.mcp.createSessionToken({ + connectionName: GMAIL_CONNECTION_NAME, + identifier: GMAIL_IDENTIFIER, + expirySeconds: 300, + accessLevel: 'READ_ONLY', + }); + + expect(typeof response.token).toBe('string'); + expect(response.token.length).toBeGreaterThan(0); + expect(response.expiresAt).toBeDefined(); + } finally { + if (!accountExisted) { + await client.actions + .deleteConnectedAccount({ + connectionName: GMAIL_CONNECTION_NAME, + identifier: GMAIL_IDENTIFIER, + }) + .catch((error: unknown) => { + // Nothing was created (the call was rejected): nothing to clean. + if (!(error instanceof ScalekitNotFoundException)) throw error; + }); + } + } + }); + + it('should reject an unknown connection with ScalekitNotFoundException', async () => { + await expect( + client.actions.mcp.createSessionToken({ + connectionName: `missing-connection-${Date.now()}`, + identifier: GMAIL_IDENTIFIER, + }) + ).rejects.toBeInstanceOf(ScalekitNotFoundException); + }); + + it('should use mcpConfigId when both targets are set', async () => { + // mcpConfigId wins, as in v2.19.0: an unknown configuration is rejected + // by the server even though the connection itself is valid. + await expect( + client.actions.mcp.createSessionToken({ + mcpConfigId: `missing-config-${Date.now()}`, + connectionName: GMAIL_CONNECTION_NAME, + identifier: GMAIL_IDENTIFIER, + }) + ).rejects.toBeInstanceOf(ScalekitServerException); + }); + }); + describe('getAuthorizationLink', () => { it('should call underlying magic link API', async () => { try { diff --git a/tests/mcp-session-token.test.ts b/tests/mcp-session-token.test.ts index babcc44..7750b85 100644 --- a/tests/mcp-session-token.test.ts +++ b/tests/mcp-session-token.test.ts @@ -1,11 +1,23 @@ /** - * McpClient.createSessionToken must send `accessLevel` only when the caller - * sets it. An omitted access level leaves the field empty on the wire, which - * the server treats as full access, so existing callers keep their tools. + * McpClient.createSessionToken targets either an MCP configuration + * (`mcpConfigId`) or a connection's own MCP server (`connectionName`, sent as + * `keyId`). The server requires exactly one of the two; the SDK never sets + * the other field. A non-empty mcpConfigId wins, as it always did; only the + * connectionName form is checked before sending, and mcpConfigId calls build + * the same request they always did, unchecked. + * + * `accessLevel` is sent only when the caller sets it. An omitted access level + * leaves the field empty on the wire, which the server treats as full access, + * so existing callers keep their tools. */ import { describe, it, expect, jest } from '@jest/globals'; +import { create } from '@bufbuild/protobuf'; +import { Code, ConnectError } from '@connectrpc/connect'; import CoreClient from '../src/core'; import McpClient from '../src/mcp'; +import type { CreateMcpSessionTokenParams } from '../src'; +import { ScalekitBadRequestException, ScalekitException } from '../src/errors'; +import { CreateMcpSessionTokenRequestSchema } from '../src/pkg/grpc/scalekit/v1/mcp/mcp_pb'; function makeMcpClient() { const coreClient = new CoreClient( @@ -22,36 +34,375 @@ function makeMcpClient() { return { mcp: new McpClient(grpcConnect, coreClient), createMcpSessionToken }; } -describe('McpClient.createSessionToken accessLevel', () => { - it('leaves accessLevel empty when omitted', async () => { +describe('McpClient.createSessionToken with mcpConfigId', () => { + it('sends mcpConfigId and never sets keyId', async () => { const { mcp, createMcpSessionToken } = makeMcpClient(); - await mcp.createSessionToken({ mcpConfigId: 'cfg_1', identifier: 'u1' }); + const res = await mcp.createSessionToken({ + mcpConfigId: 'cfg_1', + identifier: 'u1', + }); + expect(res.token).toBe('tok'); + expect(createMcpSessionToken).toHaveBeenCalledTimes(1); const req = createMcpSessionToken.mock.calls[0][0]; - expect(req.accessLevel).toBe(''); expect(req.mcpConfigId).toBe('cfg_1'); + expect(req.keyId).toBe(''); expect(req.identifier).toBe('u1'); + expect(req.expiry).toBeUndefined(); + expect(req.accessLevel).toBe(''); }); - it.each(['READ_ONLY', 'FULL'] as const)('sends %s', async (level) => { + it('accepts a params object typed the way existing callers wrote it', async () => { + // Existing callers may hold the params in a variable of this shape; it + // must stay assignable to the new union type. + const legacy: { + mcpConfigId: string; + identifier: string; + expirySeconds?: number; + accessLevel?: 'FULL' | 'READ_ONLY'; + } = { mcpConfigId: 'cfg_1', identifier: 'u1', expirySeconds: 60 }; + const { mcp, createMcpSessionToken } = makeMcpClient(); + await mcp.createSessionToken(legacy); + const req = createMcpSessionToken.mock.calls[0][0]; + expect(req.mcpConfigId).toBe('cfg_1'); + expect(req.expiry?.seconds).toBe(BigInt(60)); + }); + + it.each(['READ_ONLY', 'FULL'] as const)( + 'sends accessLevel %s', + async (level) => { + const { mcp, createMcpSessionToken } = makeMcpClient(); + await mcp.createSessionToken({ + mcpConfigId: 'cfg_1', + identifier: 'u1', + accessLevel: level, + }); + expect(createMcpSessionToken.mock.calls[0][0].accessLevel).toBe(level); + } + ); + + it('sends accessLevel alongside expiry', async () => { const { mcp, createMcpSessionToken } = makeMcpClient(); await mcp.createSessionToken({ mcpConfigId: 'cfg_1', identifier: 'u1', - accessLevel: level, + expirySeconds: 900, + accessLevel: 'READ_ONLY', }); - expect(createMcpSessionToken.mock.calls[0][0].accessLevel).toBe(level); + const req = createMcpSessionToken.mock.calls[0][0]; + expect(req.accessLevel).toBe('READ_ONLY'); + expect(req.expiry?.seconds).toBe(BigInt(900)); }); +}); - it('sends accessLevel alongside expiry', async () => { +describe('McpClient.createSessionToken with connectionName', () => { + it('targets the connection by keyId and never sets mcpConfigId', async () => { + const { mcp, createMcpSessionToken } = makeMcpClient(); + const res = await mcp.createSessionToken({ + connectionName: 'gmail', + identifier: 'u1', + }); + expect(res.token).toBe('tok'); + expect(createMcpSessionToken).toHaveBeenCalledTimes(1); + const req = createMcpSessionToken.mock.calls[0][0]; + expect(req.keyId).toBe('gmail'); + expect(req.mcpConfigId).toBe(''); + expect(req.identifier).toBe('u1'); + expect(req.expiry).toBeUndefined(); + expect(req.accessLevel).toBe(''); + }); + + it('sends the name unchanged (the server matches it)', async () => { const { mcp, createMcpSessionToken } = makeMcpClient(); await mcp.createSessionToken({ - mcpConfigId: 'cfg_1', + connectionName: 'Gmail-Work', + identifier: 'u1', + }); + expect(createMcpSessionToken.mock.calls[0][0].keyId).toBe('Gmail-Work'); + }); + + it('forwards expiry and accessLevel', async () => { + const { mcp, createMcpSessionToken } = makeMcpClient(); + await mcp.createSessionToken({ + connectionName: 'gmail', identifier: 'u1', expirySeconds: 900, accessLevel: 'READ_ONLY', }); const req = createMcpSessionToken.mock.calls[0][0]; - expect(req.accessLevel).toBe('READ_ONLY'); + expect(req.keyId).toBe('gmail'); + expect(req.mcpConfigId).toBe(''); expect(req.expiry?.seconds).toBe(BigInt(900)); + expect(req.accessLevel).toBe('READ_ONLY'); + }); + + it('sends FULL when set explicitly', async () => { + const { mcp, createMcpSessionToken } = makeMcpClient(); + await mcp.createSessionToken({ + connectionName: 'gmail', + identifier: 'u1', + accessLevel: 'FULL', + }); + expect(createMcpSessionToken.mock.calls[0][0].accessLevel).toBe('FULL'); + }); +}); + +describe('McpClient.createSessionToken connectionName validation', () => { + // Plain JavaScript callers bypass the union type, so these go through `any`. + const invalid: Array<[string, Record, string]> = [ + [ + 'a non-string connectionName', + { connectionName: 42, identifier: 'u1' }, + 'connectionName must be a non-empty string', + ], + [ + 'a non-string connectionName with an empty mcpConfigId', + { mcpConfigId: '', connectionName: 42, identifier: 'u1' }, + 'connectionName must be a non-empty string', + ], + [ + 'a missing identifier', + { connectionName: 'gmail' }, + 'identifier is required', + ], + [ + 'an empty identifier', + { connectionName: 'gmail', identifier: '' }, + 'identifier is required', + ], + [ + 'a blank identifier', + { connectionName: 'gmail', identifier: ' ' }, + 'identifier is required', + ], + [ + 'a non-string identifier', + { connectionName: 'gmail', identifier: 7 }, + 'identifier is required', + ], + ]; + + it.each(invalid)( + 'rejects %s before any request', + async (_label, params, message) => { + const { mcp, createMcpSessionToken } = makeMcpClient(); + await expect(mcp.createSessionToken(params as any)).rejects.toThrow( + message + ); + expect(createMcpSessionToken).not.toHaveBeenCalled(); + } + ); + + it.each([ + ['null', null], + ['empty', ''], + ])( + 'uses connectionName when mcpConfigId is %s', + async (_label, mcpConfigId) => { + const { mcp, createMcpSessionToken } = makeMcpClient(); + await mcp.createSessionToken({ + mcpConfigId, + connectionName: 'gmail', + identifier: 'u1', + } as any); + const req = createMcpSessionToken.mock.calls[0][0]; + expect(req.keyId).toBe('gmail'); + expect(req.mcpConfigId).toBe(''); + } + ); +}); + +describe('McpClient.createSessionToken with both targets', () => { + it('uses mcpConfigId for a spread context that carries connectionName', async () => { + // Compiled and minted a configuration token in v2.19.0: spread + // properties are not excess-checked, so this must keep doing both. + const ctx = { connectionName: 'gmail', identifier: 'u1' }; + const { mcp, createMcpSessionToken } = makeMcpClient(); + await mcp.createSessionToken({ ...ctx, mcpConfigId: 'cfg_1' }); + expect(createMcpSessionToken).toHaveBeenCalledTimes(1); + const req = createMcpSessionToken.mock.calls[0][0]; + expect(req).toEqual( + create(CreateMcpSessionTokenRequestSchema, { + mcpConfigId: 'cfg_1', + identifier: 'u1', + }) + ); + expect(req.keyId).toBe(''); + }); + + it('uses mcpConfigId when both are set and ignores identifier checks', async () => { + const { mcp, createMcpSessionToken } = makeMcpClient(); + await mcp.createSessionToken({ + mcpConfigId: 'cfg_1', + connectionName: 'gmail', + identifier: '', + accessLevel: 'READ_ONLY', + }); + const req = createMcpSessionToken.mock.calls[0][0]; + expect(req.mcpConfigId).toBe('cfg_1'); + expect(req.keyId).toBe(''); + expect(req.identifier).toBe(''); + expect(req.accessLevel).toBe('READ_ONLY'); + }); + + it('ignores a non-string connectionName when mcpConfigId is set', async () => { + const { mcp, createMcpSessionToken } = makeMcpClient(); + await mcp.createSessionToken({ + mcpConfigId: 'cfg_1', + connectionName: 42, + identifier: 'u1', + } as any); + const req = createMcpSessionToken.mock.calls[0][0]; + expect(req.mcpConfigId).toBe('cfg_1'); + expect(req.keyId).toBe(''); + }); +}); + +describe('McpClient.createSessionToken mcpConfigId calls are unchecked', () => { + // Without a non-empty connectionName the SDK adds no checks beyond + // expirySeconds: the request is built exactly as it always was, from the + // values given, and the server decides. These pin that behaviour. + function configRequest(mcpConfigId: unknown) { + return create(CreateMcpSessionTokenRequestSchema, { + mcpConfigId: mcpConfigId as string, + identifier: 'u1', + }); + } + + it.each([ + ['no target', {}, undefined], + [ + 'both targets undefined', + { mcpConfigId: undefined, connectionName: undefined }, + undefined, + ], + ['an empty mcpConfigId', { mcpConfigId: '' }, ''], + ['a null mcpConfigId', { mcpConfigId: null }, null], + [ + 'a stray empty connectionName', + { mcpConfigId: 'cfg_1', connectionName: '' }, + 'cfg_1', + ], + [ + 'a null connectionName', + { mcpConfigId: 'cfg_1', connectionName: null }, + 'cfg_1', + ], + ['only an empty connectionName', { connectionName: '' }, undefined], + [ + 'a non-string mcpConfigId with connectionName', + { mcpConfigId: 42, connectionName: 'gmail' }, + 42, + ], + ])('sends the request for %s', async (_label, target, mcpConfigId) => { + const { mcp, createMcpSessionToken } = makeMcpClient(); + await mcp.createSessionToken({ ...target, identifier: 'u1' } as any); + expect(createMcpSessionToken).toHaveBeenCalledTimes(1); + const req = createMcpSessionToken.mock.calls[0][0]; + expect(req).toEqual(configRequest(mcpConfigId)); + expect(req.keyId).toBe(''); + }); + + it('surfaces the server rejection as a ScalekitException subclass', async () => { + const { mcp, createMcpSessionToken } = makeMcpClient(); + createMcpSessionToken.mockImplementation(async () => { + throw new ConnectError('mcp_config_id is required', Code.InvalidArgument); + }); + const call = mcp.createSessionToken({ identifier: 'u1' } as any); + await expect(call).rejects.toBeInstanceOf(ScalekitBadRequestException); + await expect(call).rejects.toBeInstanceOf(ScalekitException); + expect(createMcpSessionToken).toHaveBeenCalledTimes(1); + }); + + it('checks expirySeconds before the target', async () => { + const { mcp, createMcpSessionToken } = makeMcpClient(); + await expect( + mcp.createSessionToken({ + connectionName: 42, + identifier: '', + expirySeconds: 0, + } as any) + ).rejects.toThrow('expirySeconds must be a positive integer, got 0'); + expect(createMcpSessionToken).not.toHaveBeenCalled(); + }); +}); + +describe('McpClient.createSessionToken expiry validation', () => { + it.each([0, -1, 900.5, NaN, Infinity])( + 'rejects expirySeconds=%p for a configuration before any request', + async (expirySeconds) => { + const { mcp, createMcpSessionToken } = makeMcpClient(); + await expect( + mcp.createSessionToken({ + mcpConfigId: 'cfg_1', + identifier: 'u1', + expirySeconds, + }) + ).rejects.toThrow('expirySeconds must be a positive integer'); + expect(createMcpSessionToken).not.toHaveBeenCalled(); + } + ); + + it.each([0, -1, 900.5, NaN, Infinity])( + 'rejects expirySeconds=%p for a connection before any request', + async (expirySeconds) => { + const { mcp, createMcpSessionToken } = makeMcpClient(); + await expect( + mcp.createSessionToken({ + connectionName: 'gmail', + identifier: 'u1', + expirySeconds, + }) + ).rejects.toThrow('expirySeconds must be a positive integer'); + expect(createMcpSessionToken).not.toHaveBeenCalled(); + } + ); +}); + +describe('CreateMcpSessionTokenParams type', () => { + // Compile-time checks: ts-jest type-checks this file, so if the union ever + // accepts an invalid shape below, the unused expect-error directive fails + // the suite. + it('requires a target and an identifier', () => { + const byConfig: CreateMcpSessionTokenParams = { + mcpConfigId: 'cfg_1', + identifier: 'u1', + }; + const byConnection: CreateMcpSessionTokenParams = { + connectionName: 'gmail', + identifier: 'u1', + accessLevel: 'READ_ONLY', + }; + // Both targets compile (mcpConfigId wins), as spreads did in v2.19.0. + const both: CreateMcpSessionTokenParams = { + mcpConfigId: 'cfg_1', + connectionName: 'gmail', + identifier: 'u1', + }; + const ctx = { connectionName: 'gmail', identifier: 'u1' }; + const spread: CreateMcpSessionTokenParams = { ...ctx, mcpConfigId: 'c' }; + // @ts-expect-error neither target set + const neither: CreateMcpSessionTokenParams = { identifier: 'u1' }; + // @ts-expect-error neither target set, with only optional fields + const neitherOptional: CreateMcpSessionTokenParams = { + identifier: 'u1', + expirySeconds: 60, + }; + + // @ts-expect-error identifier is required + const noIdentifier: CreateMcpSessionTokenParams = { connectionName: 'g' }; + // @ts-expect-error identifier is required with mcpConfigId too + const noIdentifierConfig: CreateMcpSessionTokenParams = { + mcpConfigId: 'c', + }; + expect([ + byConfig, + byConnection, + both, + spread, + neither, + neitherOptional, + noIdentifier, + noIdentifierConfig, + ]).toHaveLength(8); }); });