From dad8381eb3afa5253bdccb7d4421c5968c6e1123 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:53:53 +0000 Subject: [PATCH 1/5] Support configurable vault providers and imported grants --- README.md | 7 +- docs/vault-payments.md | 108 +++- docs/vault-sdk-preview.md | 92 +++ src/lib/mcp/kernel-client.test.ts | 10 + src/lib/mcp/kernel-client.ts | 2 + src/lib/mcp/register.test.ts | 1 + src/lib/mcp/tools/vault-cards.ts | 2 +- src/lib/mcp/tools/vault-items.ts | 2 +- .../mcp/tools/vault-provider-configs.test.ts | 579 ++++++++++++++++++ src/lib/mcp/tools/vault-provider-configs.ts | 206 +++++++ src/lib/mcp/tools/vault-wallets.ts | 16 +- src/lib/mcp/tools/vaults.test.ts | 18 +- src/lib/mcp/tools/vaults.ts | 4 +- src/lib/mcp/vault-responses.test.ts | 6 +- src/lib/mcp/vault-responses.ts | 50 +- src/lib/mcp/vault-schemas.ts | 80 ++- 16 files changed, 1141 insertions(+), 42 deletions(-) create mode 100644 docs/vault-sdk-preview.md create mode 100644 src/lib/mcp/tools/vault-provider-configs.test.ts create mode 100644 src/lib/mcp/tools/vault-provider-configs.ts diff --git a/README.md b/README.md index c57a827f..7beccc88 100644 --- a/README.md +++ b/README.md @@ -316,12 +316,13 @@ Call `get_connection_context` before deciding whether to create or select a proj - `manage_auth_connections` - Create, list, get, update, delete, login, submit, inspect timelines, and wait for managed-auth connections in every client. Supports health-check and automatic re-auth settings, managed-auth browser configuration, and canonical interaction-bound field/choice submissions. Use domain-filtered `list` for discovery. App-capable clients additionally receive `open_auth_login`; the programmatic actions remain available there too. - `manage_credentials` - Create, list, get, update, and delete stored credentials; fetch a current TOTP code for credentials with a configured totp_secret. - `manage_credential_providers` - Create, list, get, update, and delete external credential providers (e.g. 1Password); list available items and test the provider connection. +- `manage_vault_provider_configs` - Create, list, get, rename, rotate secrets, and delete organization-owned Link and AgentCard configurations. Writes require organization scope. - `manage_vaults` - Create, list, get, and delete project-owned payment vaults. -- `manage_vault_wallets` - Connect Link or AgentCard wallets and inspect live payment methods. -- `manage_vault_cards` - Create card requests or replace their full specification; does not implicitly authorize Link cards. +- `manage_vault_wallets` - Connect Kernel-managed or configured Link/AgentCard wallets, import Link grants from a trusted backend, and inspect live payment methods. +- `manage_vault_cards` - Create or update card requests according to the API's lifecycle rules; does not implicitly authorize Link cards. - `manage_vault_items` - List, get, invoke advertised operations, observe events, and delete vault items. Provider approvals remain user actions; ready does not mean paid. -See [Vault payments](docs/vault-payments.md) for both provider flows, safety rules, and response shapes. `manage_browsers` accepts creation-only `vaults` references (max 20); existing sessions and pools cannot gain vault bindings. The four vault tools share the `vaults` toolset and prepare/observe credentials rather than submitting merchant payments. They are exposed only when `GET /org/entitlements` reports `features.vaults.enabled: true` for the current credential; missing or unavailable entitlements hide them. Toolset configuration cannot override this access check. +See [Vault payments](docs/vault-payments.md) for both provider flows, safety rules, and response shapes. `manage_browsers` accepts creation-only `vaults` references (max 20); existing sessions and pools cannot gain vault bindings. The five vault tools share the `vaults` toolset and prepare/observe credentials rather than submitting merchant payments. They are exposed only when `GET /org/entitlements` reports `features.vaults.enabled: true` for the current credential; missing or unavailable entitlements hide them. Toolset configuration cannot override this access check. Provider configuration support is awaiting a stable SDK release; see the [SDK validation and release gate](docs/vault-sdk-preview.md) before building or merging. ### Standalone tools diff --git a/docs/vault-payments.md b/docs/vault-payments.md index 2340f8e1..34f5d129 100644 --- a/docs/vault-payments.md +++ b/docs/vault-payments.md @@ -4,12 +4,17 @@ The vault tools prepare and observe payment credentials. They do **not** submit merchant payments, expose real card values, or complete provider approval actions. They use the same vault API as the Kernel CLI. -**These are live payment cards. Test-mode creation is unsupported.** Do not assume -that a development or staging MCP endpoint makes a card request a test transaction. +**Assume real payment effects.** Mode comes from the selected provider credentials; +there is no per-item test flag. AgentCard configuration responses report the +introspected `test_mode`. A development or staging MCP endpoint does not make a +card request a test transaction. + +For the unreleased provider configuration APIs, see the +[SDK validation and release gate](vault-sdk-preview.md). ## Tools and scope -The four vault tools are exposed only when the current credential's +The five vault tools are exposed only when the current credential's `GET /org/entitlements` response reports `features.vaults.enabled: true`. Access is rechecked on every authenticated MCP request, including tool calls, without caching grants across requests or connections. A missing field, malformed @@ -17,14 +22,19 @@ response, or failed lookup hides the vault tools but leaves other toolsets usabl The lookup has a five-second timeout, forwards cancellation, and is not retried. The `vaults` toolset configuration can further restrict access, never grant it. -| Tool | Actions | -| ---------------------- | ------------------------------------------- | -| `manage_vaults` | `create`, `list`, `get`, `delete` | -| `manage_vault_wallets` | `create`, `payment_methods` | -| `manage_vault_cards` | `create`, `update` | -| `manage_vault_items` | `list`, `get`, `invoke`, `events`, `delete` | +| Tool | Actions | +| ------------------------------- | ------------------------------------------- | +| `manage_vault_provider_configs` | `create`, `list`, `get`, `update`, `delete` | +| `manage_vaults` | `create`, `list`, `get`, `delete` | +| `manage_vault_wallets` | `create`, `payment_methods` | +| `manage_vault_cards` | `create`, `update` | +| `manage_vault_items` | `list`, `get`, `invoke`, `events`, `delete` | + +Provider configurations are organization-owned and do not accept a project +selector. Reads are available to project-scoped credentials; writes require an +organization-scoped connection. The API remains the authorization authority. -Every tool accepts an optional `project` name or ID. Vaults are project-owned; +The other four tools accept an optional `project` name or ID. Vaults are project-owned; omitting `project` uses the API's effective default project, **not** all projects. Project-scoped connections cannot switch projects. Use `get_connection_context` to inspect the connection's scope. @@ -51,6 +61,70 @@ For browser checkout automation too, use `vaults browsers playwright computer`. To hide the payment tools, set `KERNEL_MCP_DISABLED_TOOLSETS=vaults`. This filters discovery; API authorization still enforces resource access. +## Provider configurations and imported grants + +`manage_vault_provider_configs` supports both `link` and `agentcard`: + +- `create`: `name`, `provider`, and `credentials: {client_id, client_secret}`. + Duplicate names return a conflict, never a credential replacement. +- `get` / `delete`: `config` selects an ID or name. Deletion requires confirmation + and is blocked while any non-deleted item references the config. +- `list`: optional `limit` (1–100) and `offset` (0 or greater); returns one page + with `items`, `has_more`, and `next_offset`. +- `update`: `config` plus `name` and/or `credentials: {client_secret}`. Omitted + fields stay unchanged. Provider, client ID, and credential mode cannot change. + Secret rotation affects all wallets bound to the configuration. + +Client secrets and imported tokens are write-only inputs for a **trusted backend +or client**. Do not ask users to paste them into chat. Do not use a client that logs +MCP arguments. The server disables SDK payload logging and omits credentials from +output and analytics; validation and API failures do not return raw secret bodies. +Public configuration responses contain ID, name, provider, non-secret client ID, +timestamps, and AgentCard's introspected mode only. + +Configuration credentials identify an application; **they are not user grants**. +A customer-managed Link wallet requires the backend to complete Link OAuth first, +then call `manage_vault_wallets` with the following specification (placeholders are +not real credentials): + +```json +{ + "action": "create", + "vault": "checkout", + "key": "imported-wallet", + "provider": "link", + "spec": { + "authorization": { + "method": "oauth", + "client": { + "type": "customer_managed", + "provider_config": { "name": "my-link-client" } + }, + "tokens": { + "access_token": "", + "refresh_token": "" + } + } + } +} +``` + +Both tokens must belong to the referenced client and the same grant. Import +requires a valid access token; refresh expired access in the backend first. +After import, **Kernel owns refresh-token rotation**; stop refreshing that grant +in the backend. Configuration selection alone does not start hosted Link OAuth. + +Use exactly one config `id` or `name`. Responses preserve the resolved config ID; +renaming does not rebind wallets. An identical wallet create never replaces its +grant, even after rotation or degradation. Changing config requires a new wallet. +There is no in-place imported reauthorization: obtain a fresh grant and use a new +wallet key for **new payments only**. Existing cards remain bound to the old wallet; +retain unresolved attempts for provider/support reconciliation, not retries. + +For AgentCard, add `"provider_config": {"name": "my-agentcard"}` to the wallet +`spec`; no user OAuth tokens are accepted. Omit it to retain Kernel-managed +credentials. A reused `user_id` must belong to the same organization and config. + ## Link flow 1. Create or retrieve a vault with `manage_vaults`: @@ -157,7 +231,7 @@ then connect a wallet with `manage_vault_wallets`: ``` Complete the returned enrollment action. Alternatively, `spec.user_id` may refer -to a user already enrolled in this organization. Once connected, configure a card +to a user already enrolled in this organization under the same configuration. Once connected, configure a card with `manage_vault_cards`: ```json @@ -216,8 +290,16 @@ A reusable card remaining `ready` does not establish that the last payment succe No vault request is automatically retried. After a failed, timed-out, rejected, or indeterminate payment, inspect state/events; do not replay checkout, invoke again, or reconfigure a card to retry it. -- Card `update` replaces the **entire spec**; omitted optional fields are removed. - The API decides when a card can be reconfigured. +- Requested-card `update` replaces the spec. Pending issuance updates preserve + omitted optional fields and clear explicit empty lists; only provider-supported + changes are allowed. Provider/wallet bindings cannot change after authorization + starts. The tool forwards omissions and empty values without normalization. + The API decides which edits are allowed; an uncertain update enters + `recovery_required` and must not be retried. +- `recovery_required` is preserved in responses and ends the API's bounded wait. + It is neither decline nor expiry. Stop payment attempts and reconcile with the + provider or support. There is no reset or caller-asserted reconciliation tool. + Unresolved cards can also block deletion of their wallet and vault. - Browser attachments accept at most 20 references, each containing exactly one `id` or `name`. They are creation-only and unavailable for browser pools. You cannot add vaults to an existing browser. Vault-bound browser creation also diff --git a/docs/vault-sdk-preview.md b/docs/vault-sdk-preview.md new file mode 100644 index 00000000..ed193f72 --- /dev/null +++ b/docs/vault-sdk-preview.md @@ -0,0 +1,92 @@ +# Vault SDK validation and release gate + +**Do not merge or deploy this change until a stable Node SDK includes the new vault +provider configuration APIs.** `package.json` and `bun.lock` deliberately retain the +released `@onkernel/sdk` dependency. No staging dependency or generated SDK source +is shipped in this repository. + +The locked SDK (`0.100.0`) cannot typecheck this change: it lacks +`vaultProviderConfigs`, its request types, and the imported Link wallet request +variant. Its version number is also used by the preview; the preview version +string is **not** evidence that the feature has been released. + +## Validated contracts + +- API source: `kernel/kernel` PR #3816, head + `0b96b27cfe03b4c26e8a6c92b8ae7baeca54e77d`. +- Generated SDK: `kernel/kernel-node-sdk-staging`, commit + `ae29b778cecc8aadbf1922c29cd1fcb0464f9ea6`, from `stlc/preview/pr-3816`. +- Reviewed the complete SDK diff: five new config endpoints, customer config + references, imported Link grants, recovery states, and updated card lifecycle + semantics. The latest API main merge changes no vault paths or schemas; the + preview's vault exports and signatures match that head. +- Tests use the actual generated SDK with mocked HTTP transports. No provider + enrollment, OAuth, payment, deployment, or production mutation is exercised. + +## Reproduce against the exact preview + +Use a disposable checkout of this MCP branch. The following local tarball override +changes `node_modules` only; do not deploy it or commit an SDK override. + +1. Clone and verify the generated SDK (next to the MCP checkout): + + ```sh + gh repo clone kernel/kernel-node-sdk-staging -- --depth=1 --branch stlc/preview/pr-3816 + cd kernel-node-sdk-staging + git fetch origin ae29b778cecc8aadbf1922c29cd1fcb0464f9ea6 --depth=1 + git checkout --detach ae29b778cecc8aadbf1922c29cd1fcb0464f9ea6 + test "$(git rev-parse HEAD)" = ae29b778cecc8aadbf1922c29cd1fcb0464f9ea6 + ``` + +2. Build and pack the SDK using its own build script: + + ```sh + bun install + bun run build + cd dist + bun pm pack --filename /tmp/kernel-sdk-ae29b77.tgz + ``` + +3. Install the tarball without saving dependency changes: + + ```sh + cd ../../kernel-mcp-server + bun install --frozen-lockfile + bun add --no-save /tmp/kernel-sdk-ae29b77.tgz + git diff --exit-code -- package.json bun.lock + ``` + + A tarball is used instead of `bun link`: Turbopack cannot resolve a linked SDK + outside its project root with this repository's current configuration. + +4. Validate locally: + + ```sh + bun test + bunx tsc --noEmit + KERNEL_CLI_PROD_CLIENT_ID=build-test-prod \ + KERNEL_CLI_STAGING_CLIENT_ID=build-test-staging \ + KERNEL_CLI_DEV_CLIENT_ID=build-test-dev \ + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_Y2xlcmsuZXhhbXBsZS5jb20k \ + NEXT_TELEMETRY_DISABLED=1 bun run build + ``` + + These dummy values satisfy build-time configuration only; they cannot be used + for authentication. Without configuration, page-data collection fails on the + pre-existing required OAuth client IDs. `bun run lint` is also pre-existingly + unsupported because Next.js 16 removed `next lint`; check changed-file Prettier + formatting instead. + +5. Discard the disposable checkout after validation, or reinstall the locked SDK + with `bun install --force --frozen-lockfile`. Verify its contents before using + the checkout for anything else; the preview shares the stable version string. + +## Before merging + +Publish the stable SDK through its normal release process, then update this +repository's `@onkernel/sdk` dependency and lockfile to that **actual released +version**. Repeat tests, typecheck, and build with a clean frozen-lockfile install, +not the preview tarball. Recheck the generated signatures if the upstream API +changes. Coordinate availability of the new API routes before deploying the MCP +server. Until those steps are complete, the default dependency build is blocked; +the preview-backed build is for review and validation only. diff --git a/src/lib/mcp/kernel-client.test.ts b/src/lib/mcp/kernel-client.test.ts index e1e1fb50..5eb13772 100644 --- a/src/lib/mcp/kernel-client.test.ts +++ b/src/lib/mcp/kernel-client.test.ts @@ -2,6 +2,16 @@ import { describe, expect, test } from "bun:test"; import { createKernelClient } from "@/lib/mcp/kernel-client"; describe("createKernelClient", () => { + test("disables SDK payload logging even when KERNEL_LOG requests debug", () => { + const previous = process.env.KERNEL_LOG; + process.env.KERNEL_LOG = "debug"; + try { + expect(createKernelClient("test-key").logLevel).toBe("off"); + } finally { + if (previous === undefined) delete process.env.KERNEL_LOG; + else process.env.KERNEL_LOG = previous; + } + }); test("uses an explicit project before the server default", () => { const previous = process.env.KERNEL_PROJECT; process.env.KERNEL_PROJECT = "proj_default"; diff --git a/src/lib/mcp/kernel-client.ts b/src/lib/mcp/kernel-client.ts index d6d7deef..417d8ca5 100644 --- a/src/lib/mcp/kernel-client.ts +++ b/src/lib/mcp/kernel-client.ts @@ -3,6 +3,8 @@ import { Kernel } from "@onkernel/sdk"; export function createKernelClient(apiKey: string, project?: string) { return new Kernel({ apiKey, + // SDK debug logging includes request bodies, which can contain vault secrets. + logLevel: "off", project: project ?? process.env.KERNEL_PROJECT, baseURL: process.env.API_BASE_URL, defaultHeaders: { diff --git a/src/lib/mcp/register.test.ts b/src/lib/mcp/register.test.ts index e5d2c6f8..85cb3ca6 100644 --- a/src/lib/mcp/register.test.ts +++ b/src/lib/mcp/register.test.ts @@ -99,6 +99,7 @@ describe("MCP toolset allowlist", () => { ]); expect(captureRegistration(mcpApps, true).legacyTools).toEqual([ "get_connection_context", + "manage_vault_provider_configs", "manage_vault_wallets", "manage_vault_cards", "manage_vault_items", diff --git a/src/lib/mcp/tools/vault-cards.ts b/src/lib/mcp/tools/vault-cards.ts index 7a29c129..e40018d9 100644 --- a/src/lib/mcp/tools/vault-cards.ts +++ b/src/lib/mcp/tools/vault-cards.ts @@ -17,7 +17,7 @@ export function registerVaultCardTools( ) { server.tool( "manage_vault_cards", - 'Configure requests for live payment cards, not merchant payments. Test-mode creation is unsupported. "create" creates or retrieves an identical card request by immutable key. "update" replaces the ENTIRE spec, removing omitted optional fields, only when the API permits it. Neither implicitly authorizes Link: inspect available_operations with manage_vault_items and obtain explicit user approval before invoking. AgentCard authorizes at checkout. Amounts are integer minor currency units. No card data, OAuth tokens, provider secrets, or domain configuration. Never reconfigure a card to retry a failed, timed-out, rejected, or indeterminate payment. Requests are not automatically retried.', + 'Configure payment card requests, not merchant payments. Mode is determined by the wallet credentials, not a per-item test flag; never assume a test transaction. "create" creates or retrieves an identical card request by immutable key. "update" replaces requested-card specs. Pending issuance updates preserve omitted optional fields and clear explicit empty lists, only for provider-supported edits allowed by the API. Wallet/provider binding cannot change after authorization starts. Uncertain updates enter recovery_required; do not retry. Neither implicitly authorizes Link: inspect available_operations with manage_vault_items and obtain explicit user approval before invoking. AgentCard authorizes at checkout. Amounts are integer minor currency units. No card data, OAuth tokens, provider secrets, or domain configuration. Never reconfigure a card to retry a failed, timed-out, rejected, or indeterminate payment. Requests are not automatically retried.', { ...vaultItemSchema, key: vaultKeySchema(), diff --git a/src/lib/mcp/tools/vault-items.ts b/src/lib/mcp/tools/vault-items.ts index ea3973c9..a46837d3 100644 --- a/src/lib/mcp/tools/vault-items.ts +++ b/src/lib/mcp/tools/vault-items.ts @@ -25,7 +25,7 @@ export function registerVaultItemTools( ) { server.tool( "manage_vault_items", - 'Inspect payment vault items and immutable audit events. "list" reads items; "get" reads state, public aliases, required user actions, available_operations, and available_expansions. "invoke" fetches the item again and submits only an advertised operation; read its description and obtain explicit user approval first. Provider actions (OAuth, enrollment, MFA, approval) must be completed by the user, not invoked as operations. "events" observes outcomes; use the last event ID as after. "delete" invalidates an item credential; confirm with the user first. Ready does not mean paid. Requests are never automatically retried. Do not retry failed, timed-out, rejected, or indeterminate payments; inspect state/events instead.', + 'Inspect payment vault items and immutable audit events. "list" reads items; "get" reads state, public aliases, required user actions, available_operations, and available_expansions. "invoke" fetches the item again and submits only an advertised operation; read its description and obtain explicit user approval first. Provider actions (OAuth, enrollment, MFA, approval) must be completed by the user, not invoked as operations. "events" observes outcomes; use the last event ID as after. "delete" invalidates an item credential; confirm with the user first. Unresolved payments block item and parent deletion. recovery_required is not decline or expiry: stop payment attempts and reconcile with the provider or support; no reset exists. Ready does not mean paid. Requests are never automatically retried. Do not retry failed, timed-out, rejected, or indeterminate payments; inspect state/events instead.', { ...vaultItemSchema, action: z.enum(["list", "get", "invoke", "events", "delete"]), diff --git a/src/lib/mcp/tools/vault-provider-configs.test.ts b/src/lib/mcp/tools/vault-provider-configs.test.ts new file mode 100644 index 00000000..bf5581e6 --- /dev/null +++ b/src/lib/mcp/tools/vault-provider-configs.test.ts @@ -0,0 +1,579 @@ +import { describe, expect, test } from "bun:test"; +import { + organizationWideAuthInfo, + projectScopedAuthInfo, +} from "@/lib/mcp/auth-context.test-fixtures"; +import { toolResultJSON } from "@/lib/mcp/mcp-test-fixtures"; +import { connectVaultTest, item, linkSpec } from "./vaults.test-fixtures"; + +const tool = "manage_vault_provider_configs"; +const secret = "secret-sentinel+/configuration"; +const access = "access-sentinel+/grant"; +const refresh = "refresh-sentinel+/grant"; +const config = { + id: "vpc_example", + name: "checkout-client", + provider: "agentcard", + client_id: "application-id", + test_mode: false, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; +const create = { + action: "create", + name: config.name, + provider: config.provider, + credentials: { client_id: config.client_id, client_secret: secret }, +}; +const importedSpec = { + authorization: { + method: "oauth", + client: { + type: "customer_managed", + provider_config: { name: config.name }, + }, + tokens: { access_token: access, refresh_token: refresh }, + }, +}; +const importedCreate = { + action: "create", + vault: "checkout", + key: "imported-wallet", + provider: "link", + spec: importedSpec, +}; + +function expectSecretFree(result: unknown) { + const text = JSON.stringify(result); + for (const value of [secret, access, refresh]) { + expect(text).not.toContain(value); + expect(text).not.toContain(encodeURIComponent(value)); + } +} + +describe("vault provider config SDK routing", () => { + test.each(["link", "agentcard"])( + "creates %s configs and projects only public metadata", + async (provider) => { + const fixture = await connectVaultTest( + [ + Response.json({ + ...config, + provider, + credentials: create.credentials, + client_secret: secret, + tokens: importedSpec.authorization.tokens, + }), + ], + organizationWideAuthInfo(), + ); + try { + const result = await fixture.call(tool, { ...create, provider }); + expect(result.isError).not.toBe(true); + expectSecretFree(result); + expect(toolResultJSON(result)).toEqual({ ...config, provider }); + expect(fixture.requests).toHaveLength(1); + expect(fixture.requests[0]).toMatchObject({ + method: "POST", + path: "/vault-provider-configs", + }); + expect(fixture.requests[0].body).toEqual({ + name: config.name, + provider, + credentials: create.credentials, + }); + expect(fixture.requests[0].headers.has("X-Kernel-Project")).toBe(false); + expect(fixture.requests[0].headers.has("X-Kernel-Project-Id")).toBe( + false, + ); + } finally { + await fixture.close(); + } + }, + ); + + test("gets, paginates a single page, renames, and rotates without defaulting omitted fields", async () => { + const fixture = await connectVaultTest( + [ + Response.json(config), + Response.json([config], { + headers: { "X-Has-More": "true", "X-Next-Offset": "40" }, + }), + Response.json([], { headers: { "X-Has-More": "false" } }), + Response.json({ ...config, name: "renamed" }), + Response.json(config), + ], + organizationWideAuthInfo(), + ); + try { + expect( + toolResultJSON( + await fixture.call(tool, { action: "get", config: config.id }), + ), + ).toEqual(config); + expect( + toolResultJSON( + await fixture.call(tool, { action: "list", limit: 20, offset: 20 }), + ), + ).toEqual({ items: [config], has_more: true, next_offset: 40 }); + expect( + toolResultJSON( + await fixture.call(tool, { action: "list", offset: 40 }), + ), + ).toMatchObject({ items: [], has_more: false }); + await fixture.call(tool, { + action: "update", + config: config.name, + name: "renamed", + }); + const rotated = await fixture.call(tool, { + action: "update", + config: config.id, + credentials: { client_secret: secret }, + }); + expectSecretFree(rotated); + expect( + fixture.requests.map(({ method, path, body }) => ({ + method, + path, + body, + })), + ).toEqual([ + { + method: "GET", + path: `/vault-provider-configs/${config.id}`, + body: undefined, + }, + { + method: "GET", + path: "/vault-provider-configs?limit=20&offset=20", + body: undefined, + }, + { + method: "GET", + path: "/vault-provider-configs?offset=40", + body: undefined, + }, + { + method: "PATCH", + path: `/vault-provider-configs/${config.name}`, + body: { name: "renamed" }, + }, + { + method: "PATCH", + path: `/vault-provider-configs/${config.id}`, + body: { credentials: { client_secret: secret } }, + }, + ]); + } finally { + await fixture.close(); + } + }); + + test.each([204, 404, 409])( + "delete preserves HTTP %s semantics", + async (status) => { + const fixture = await connectVaultTest( + [ + status === 204 + ? new Response(null, { status }) + : Response.json( + { + code: status === 409 ? "conflict" : "not_found", + message: secret, + }, + { status }, + ), + ], + organizationWideAuthInfo(), + ); + try { + const result = await fixture.call(tool, { + action: "delete", + config: config.id, + }); + expect(result.isError === true).toBe(status === 409); + expectSecretFree(result); + if (status !== 409) + expect(toolResultJSON(result)).toEqual({ + status: "deleted_or_not_found", + config: config.id, + }); + expect(fixture.requests).toHaveLength(1); + expect(fixture.requests[0].method).toBe("DELETE"); + } finally { + await fixture.close(); + } + }, + ); + + test.each([400, 403, 409, 429, 500])( + "redacts HTTP %s errors and never retries credential writes", + async (status) => { + const fixture = await connectVaultTest( + [ + Response.json( + { + code: secret, + message: secret, + credentials: create.credentials, + tokens: importedSpec.authorization.tokens, + }, + { status, headers: { "Retry-After": "0", "x-request-id": secret } }, + ), + ], + organizationWideAuthInfo(), + ); + try { + const result = await fixture.call(tool, create); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toContain(`${status} `); + expectSecretFree(result); + expect(fixture.requests).toHaveLength(1); + } finally { + await fixture.close(); + } + }, + ); + + test("redacts a secret echoed inside a public response field", async () => { + const fixture = await connectVaultTest( + [ + Response.json({ + ...config, + name: `echo ${secret} ${encodeURIComponent(secret)}`, + }), + ], + organizationWideAuthInfo(), + ); + try { + expectSecretFree(await fixture.call(tool, create)); + } finally { + await fixture.close(); + } + }); +}); + +describe("config scope and validation", () => { + test.each([ + create, + { action: "update", config: config.id, name: "renamed" }, + { action: "delete", config: config.id }, + ])("rejects project-scoped writes locally", async (args) => { + const fixture = await connectVaultTest([], projectScopedAuthInfo()); + try { + expect((await fixture.call(tool, args)).isError).toBe(true); + expect(fixture.requests).toHaveLength(0); + } finally { + await fixture.close(); + } + }); + + test("allows project credentials to read organization configs", async () => { + const fixture = await connectVaultTest( + [Response.json(config), Response.json([])], + projectScopedAuthInfo(), + ); + try { + expect( + (await fixture.call(tool, { action: "get", config: config.id })) + .isError, + ).not.toBe(true); + expect((await fixture.call(tool, { action: "list" })).isError).not.toBe( + true, + ); + expect(fixture.requests).toHaveLength(2); + } finally { + await fixture.close(); + } + }); + + const invalidConfigInputs: Record[] = [ + { action: "create" }, + { ...create, credentials: { client_secret: secret } }, + { ...create, credentials: { ...create.credentials, [access]: refresh } }, + { + ...create, + credentials: { client_id: config.client_id, client_secret: "" }, + }, + { action: "update", config: config.id, credentials: create.credentials }, + { action: "update", config: config.id, provider: "link", name: "renamed" }, + { action: "update", config: config.id }, + { action: "update", name: "renamed" }, + { action: "get" }, + { action: "get", config: "../bad" }, + { action: "delete" }, + { action: "list", limit: 101 }, + { action: "list", offset: -1 }, + { action: "list", credentials: { client_secret: secret } }, + ]; + test.each(invalidConfigInputs)( + "rejects malformed or immutable config changes without disclosure", + async (args) => { + const fixture = await connectVaultTest([], organizationWideAuthInfo()); + try { + const result = await fixture.call(tool, args); + expect(result.isError).toBe(true); + expectSecretFree(result); + expect(fixture.requests).toHaveLength(0); + } finally { + await fixture.close(); + } + }, + ); +}); + +describe("configured wallets and recovery", () => { + test.each([400, 409, 429, 500])( + "does not expose or retry a rejected imported grant (HTTP %s)", + async (status) => { + const fixture = await connectVaultTest([ + Response.json( + { + code: "provider_error", + message: access, + tokens: importedSpec.authorization.tokens, + }, + { status }, + ), + ]); + try { + const result = await fixture.call( + "manage_vault_wallets", + importedCreate, + ); + expect(result.isError).toBe(true); + expectSecretFree(result); + expect(fixture.requests).toHaveLength(1); + } finally { + await fixture.close(); + } + }, + ); + + test("an identical card PUT returns recovery unchanged without authorizing", async () => { + const recovery = { + ...item, + state: { provider: "link", status: "recovery_required" }, + available_operations: [], + }; + const fixture = await connectVaultTest([Response.json(recovery)]); + try { + const result = await fixture.call("manage_vault_cards", { + action: "create", + vault: "checkout", + key: "order-1", + provider: "link", + spec: linkSpec, + }); + expect(toolResultJSON(result).item.state).toEqual(recovery.state); + expect(toolResultJSON(result).hints.invocation).toEqual([]); + expect(fixture.requests).toHaveLength(1); + expect(fixture.requests[0].method).toBe("PUT"); + } finally { + await fixture.close(); + } + }); + test.each([{ id: config.id }, { name: config.name }])( + "imports Link tokens separately from config credentials", + async (reference) => { + const spec = { + authorization: { + ...importedSpec.authorization, + client: { type: "customer_managed", provider_config: reference }, + }, + }; + const publicSpec = { + provider: "link", + authorization: { + method: "oauth", + client: { + type: "customer_managed", + provider_config: { id: config.id }, + }, + }, + }; + const fixture = await connectVaultTest([ + Response.json({ + ...item, + type: "wallet", + spec: { + ...publicSpec, + authorization: { + ...publicSpec.authorization, + tokens: importedSpec.authorization.tokens, + }, + }, + state: { + provider: "link", + status: "connected", + status_reason: `${access} ${encodeURIComponent(refresh)}`, + }, + available_operations: [], + }), + ]); + try { + const result = await fixture.call("manage_vault_wallets", { + ...importedCreate, + spec, + }); + expect(result.isError).not.toBe(true); + expectSecretFree(result); + expect(toolResultJSON(result).item.spec).toEqual(publicSpec); + expect(fixture.requests).toHaveLength(1); + expect(fixture.requests[0]).toMatchObject({ + method: "PUT", + path: "/vaults/checkout/items/imported-wallet", + body: { type: "wallet", spec: { ...spec, provider: "link" } }, + }); + } finally { + await fixture.close(); + } + }, + ); + + test.each([{ id: config.id }, { name: config.name }])( + "selects AgentCard configuration without user grants", + async (reference) => { + const spec = { provider_config: reference, user_id: "usr_enrolled" }; + const fixture = await connectVaultTest([ + Response.json({ + ...item, + type: "wallet", + spec: { ...spec, provider: "agentcard" }, + available_operations: [], + }), + ]); + try { + const result = await fixture.call("manage_vault_wallets", { + action: "create", + vault: "checkout", + key: "wallet", + provider: "agentcard", + spec, + }); + expect(result.isError).not.toBe(true); + expect(toolResultJSON(result).item.spec.provider_config).toEqual( + reference, + ); + expect(fixture.requests[0].body).toEqual({ + type: "wallet", + spec: { ...spec, provider: "agentcard" }, + }); + } finally { + await fixture.close(); + } + }, + ); + + test.each([ + { authorization: { ...importedSpec.authorization, tokens: undefined } }, + { + authorization: { + ...importedSpec.authorization, + tokens: { access_token: access }, + }, + }, + { + authorization: { + ...importedSpec.authorization, + tokens: { ...importedSpec.authorization.tokens, [secret]: secret }, + }, + }, + { + authorization: { + ...importedSpec.authorization, + client: { type: "kernel_managed" }, + }, + }, + ...[{}, { id: config.id, name: config.name }, { name: "../bad" }].map( + (provider_config) => ({ + authorization: { + ...importedSpec.authorization, + client: { type: "customer_managed", provider_config }, + }, + }), + ), + ])( + "rejects incomplete/mismatched grants and selectors without leaking tokens", + async (spec) => { + const fixture = await connectVaultTest([]); + try { + const result = await fixture.call("manage_vault_wallets", { + ...importedCreate, + spec, + }); + expect(result.isError).toBe(true); + expectSecretFree(result); + expect(fixture.requests).toHaveLength(0); + } finally { + await fixture.close(); + } + }, + ); + + test.each(["link", "agentcard"])( + "preserves %s recovery_required without polling or invocation hints", + async (provider) => { + const recovery = { + ...item, + state: { provider, status: "recovery_required" }, + available_operations: [], + }; + const fixture = await connectVaultTest([Response.json(recovery)]); + try { + const result = await fixture.call("manage_vault_items", { + action: "get", + vault: "checkout", + key: "order-1", + wait: 60, + }); + expect(toolResultJSON(result).item.state.status).toBe( + "recovery_required", + ); + expect(toolResultJSON(result).hints.invocation).toEqual([]); + expect(toolResultJSON(result).guidance.join(" ")).toContain( + "reconcile", + ); + expect(fixture.requests).toHaveLength(1); + } finally { + await fixture.close(); + } + }, + ); + + test("preserves omitted vs explicit empty pending-card fields", async () => { + const fixture = await connectVaultTest([ + Response.json(item), + Response.json(item), + ]); + try { + for (const spec of [ + linkSpec, + { ...linkSpec, line_items: [], totals: [], metadata: {} }, + ]) { + await fixture.call("manage_vault_cards", { + action: "update", + vault: "checkout", + key: "order-1", + provider: "link", + spec, + }); + } + expect(fixture.requests.map(({ body }) => body)).toEqual([ + { spec: { ...linkSpec, provider: "link" } }, + { + spec: { + ...linkSpec, + provider: "link", + line_items: [], + totals: [], + metadata: {}, + }, + }, + ]); + } finally { + await fixture.close(); + } + }); +}); diff --git a/src/lib/mcp/tools/vault-provider-configs.ts b/src/lib/mcp/tools/vault-provider-configs.ts new file mode 100644 index 00000000..fff8a7d5 --- /dev/null +++ b/src/lib/mcp/tools/vault-provider-configs.ts @@ -0,0 +1,206 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { APIError } from "@onkernel/sdk"; +import type { + VaultProviderConfigCreateParams, + VaultProviderConfigUpdateParams, +} from "@onkernel/sdk/resources/vault-provider-configs"; +import { z } from "zod"; +import type { McpDependencies } from "@/lib/mcp/dependencies"; +import { connectionContextFromAuthInfo } from "@/lib/mcp/project-selection"; +import { + errorResponse, + jsonResponse, + paginatedJsonResponse, +} from "@/lib/mcp/responses"; +import { paginationParams } from "@/lib/mcp/schemas"; +import { + projectVaultOutput, + redactVaultSecrets, + throwVaultError, + vaultProviderConfigFields, +} from "@/lib/mcp/vault-responses"; +import { + providerCredentialsSchema, + vaultProviderSchema, + vaultSelectorSchema, +} from "@/lib/mcp/vault-schemas"; + +export function registerVaultProviderConfigTools( + server: McpServer, + dependencies: McpDependencies, +) { + server.tool( + "manage_vault_provider_configs", + 'Manage organization-owned Link and AgentCard application credentials, not user OAuth grants. "create" requires name, provider, and credentials (client_id/client_secret); duplicate names conflict without replacing secrets. "list" and "get" return public configuration metadata only. "update" renames or rotates client_secret across all bound wallets; omitted fields stay unchanged. Provider, client_id, mode, and wallet bindings are immutable. "delete" requires user confirmation and fails while any non-deleted item references the config; it does not revoke unrelated grants. Writes require an organization-scoped connection. Supply write-only secrets through a trusted client, never chat. No automatic retries.', + { + action: z.enum(["create", "list", "get", "update", "delete"]), + config: vaultSelectorSchema() + .describe( + "(get, update, delete) Configuration ID or name within the organization.", + ) + .optional(), + name: vaultSelectorSchema() + .describe("(create, update) Unique organization-wide name.") + .optional(), + provider: vaultProviderSchema + .describe("(create only) Immutable provider.") + .optional(), + credentials: providerCredentialsSchema + .describe( + "(create) client_id and client_secret. (update) client_secret only. Never user access/refresh tokens.", + ) + .optional(), + ...paginationParams, + }, + { + title: "Manage Kernel vault provider configurations", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + async (params, extra) => { + if (!extra.authInfo) throw new Error("Authentication required"); + const write = ["create", "update", "delete"].includes(params.action); + if ( + write && + connectionContextFromAuthInfo(extra.authInfo).scope.kind !== + "organization" + ) { + return errorResponse( + "Provider configuration writes require an organization-scoped connection.", + ); + } + if (params.action !== "create" && params.provider !== undefined) { + return errorResponse( + "provider is only accepted for create; configuration identity is immutable.", + ); + } + if ( + !["create", "update"].includes(params.action) && + (params.credentials !== undefined || params.name !== undefined) + ) { + return errorResponse( + "name and credentials are only accepted for create or update.", + ); + } + if ( + params.action === "update" && + params.credentials?.client_id !== undefined + ) { + return errorResponse( + "client_id is immutable; create a new configuration to change clients.", + ); + } + const client = dependencies + .createKernelClient(extra.authInfo.token) + .withOptions({ + project: null, + projectID: null, + logLevel: "off", + }); + const options = { maxRetries: 0, signal: extra.signal }; + const project = (value: unknown) => + redactVaultSecrets( + projectVaultOutput(value, vaultProviderConfigFields), + [params.credentials?.client_secret], + ); + try { + switch (params.action) { + case "create": { + if ( + !params.name || + !params.provider || + !params.credentials?.client_id + ) { + return errorResponse( + "name, provider, and credentials with client_id and client_secret are required for create.", + ); + } + const body: VaultProviderConfigCreateParams = { + name: params.name, + provider: params.provider, + credentials: { + client_id: params.credentials.client_id, + client_secret: params.credentials.client_secret, + }, + }; + return jsonResponse( + project(await client.vaultProviderConfigs.create(body, options)), + ); + } + case "list": { + const page = await client.vaultProviderConfigs.list( + { + ...(params.limit !== undefined && { limit: params.limit }), + ...(params.offset !== undefined && { offset: params.offset }), + }, + options, + ); + return paginatedJsonResponse(page, { + mapItem: project, + emptyText: + "No provider configurations found in the organization.", + }); + } + case "get": + if (!params.config) + return errorResponse("config is required for get."); + return jsonResponse( + project( + await client.vaultProviderConfigs.retrieve( + params.config, + options, + ), + ), + ); + case "update": { + if (!params.config) + return errorResponse("config is required for update."); + if (params.name === undefined && params.credentials === undefined) + return errorResponse( + "name or credentials is required for update.", + ); + const body: VaultProviderConfigUpdateParams = { + ...(params.name !== undefined && { name: params.name }), + ...(params.credentials !== undefined && { + credentials: { + client_secret: params.credentials.client_secret, + }, + }), + }; + return jsonResponse( + project( + await client.vaultProviderConfigs.update( + params.config, + body, + options, + ), + ), + ); + } + case "delete": + if (!params.config) + return errorResponse("config is required for delete."); + await client.vaultProviderConfigs.delete(params.config, options); + return jsonResponse({ + status: "deleted_or_not_found", + config: params.config, + }); + } + } catch (error) { + if ( + params.action === "delete" && + error instanceof APIError && + error.status === 404 + ) { + return jsonResponse({ + status: "deleted_or_not_found", + config: params.config, + }); + } + throwVaultError("manage_vault_provider_configs", params.action, error); + } + }, + ); +} diff --git a/src/lib/mcp/tools/vault-wallets.ts b/src/lib/mcp/tools/vault-wallets.ts index 09f8bb5d..34033290 100644 --- a/src/lib/mcp/tools/vault-wallets.ts +++ b/src/lib/mcp/tools/vault-wallets.ts @@ -1,5 +1,6 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; +import type { ItemUpsertParams } from "@onkernel/sdk/resources/vaults/items"; import type { McpDependencies } from "@/lib/mcp/dependencies"; import { projectForOperation } from "@/lib/mcp/project-selection"; import { longOperationOptions } from "@/lib/mcp/request-options"; @@ -19,7 +20,7 @@ export function registerVaultWalletTools( ) { server.tool( "manage_vault_wallets", - 'Connect payment wallets without exposing secrets. "create" creates or retrieves an identical wallet by immutable key and returns a provider connection/enrollment action for the user to complete. "payment_methods" requests the advertised live payment_methods expansion (unavailable expansions return an API error). Select Link payment_method_id explicitly; never automatically choose a default. AgentCard card_id may be omitted for cardholder selection at checkout approval. Capabilities are advisory; absent means unknown. Never provide card data or OAuth codes/tokens. Requests are not automatically retried.', + 'Connect payment wallets without exposing secrets. "create" creates or retrieves an identical wallet by immutable key. Hosted connection/enrollment actions are for the user; a valid imported Link grant creates a connected wallet. "payment_methods" requests the advertised live payment_methods expansion (unavailable expansions return an API error). Select Link payment_method_id explicitly; never automatically choose a default. AgentCard card_id may be omitted for cardholder selection at checkout approval. Capabilities are advisory; absent means unknown. Kernel-managed Link OAuth remains supported. Customer-managed Link requires authorization.client.provider_config and a write-only authorization.tokens pair supplied by a trusted backend, never chat; config credentials do not authorize a user. Kernel owns refresh rotation after import. Duplicate create never replaces a grant; bindings cannot change. AgentCard spec.provider_config is optional; omit for Kernel-managed credentials, and reuse user_id only within the same config. No in-place imported reauthorization: obtain a fresh grant under a new wallet key for new payments only; retain unresolved old payments for reconciliation. Never provide card data or OAuth codes. Requests are not automatically retried.', { ...vaultItemSchema, key: vaultKeySchema(), @@ -30,7 +31,7 @@ export function registerVaultWalletTools( spec: z .union([linkWalletSpecSchema, agentcardWalletSpecSchema]) .describe( - '(create) Specification object, not a {type, spec} envelope. Embedded provider must match provider. Link: {"authorization":{"method":"oauth","client":{"type":"kernel_managed"}}}. AgentCard: {} to enroll or {"user_id":"usr_..."} for an already enrolled user.', + '(create) Specification object, not a {type, spec} envelope. Embedded provider must match provider. Link: {"authorization":{"method":"oauth","client":{"type":"kernel_managed"}}}, or customer_managed with provider_config (exactly one id/name) and tokens from a trusted backend. AgentCard: {} to enroll, optionally provider_config or user_id from the same configuration.', ) .optional(), }, @@ -57,7 +58,7 @@ export function registerVaultWalletTools( return errorResponse( "provider and spec are required for create.", ); - const spec = + const spec: ItemUpsertParams.WalletVaultItemRequest["spec"] = params.provider === "link" ? { ...linkWalletSpecSchema.parse(params.spec), @@ -76,7 +77,14 @@ export function registerVaultWalletTools( }, options, ); - return vaultItemResponse(item, target); + const tokens = + spec.provider === "link" && "tokens" in spec.authorization + ? spec.authorization.tokens + : undefined; + return vaultItemResponse(item, target, [ + tokens?.access_token, + tokens?.refresh_token, + ]); } case "payment_methods": { const item = await client.vaults.items.retrieve( diff --git a/src/lib/mcp/tools/vaults.test.ts b/src/lib/mcp/tools/vaults.test.ts index 5f3a77eb..5ff6ebf2 100644 --- a/src/lib/mcp/tools/vaults.test.ts +++ b/src/lib/mcp/tools/vaults.test.ts @@ -13,18 +13,23 @@ import { } from "./vaults.test-fixtures"; describe("vault SDK request contracts", () => { - test("advertises all four project-aware tools with conservative annotations", async () => { + test("advertises vault tools with scope-appropriate inputs and conservative annotations", async () => { const fixture = await connectVaultTest([]); try { const { tools } = await fixture.client.listTools(); expect(tools.map((tool) => tool.name).sort()).toEqual([ "manage_vault_cards", "manage_vault_items", + "manage_vault_provider_configs", "manage_vault_wallets", "manage_vaults", ]); for (const tool of tools) { - expect(tool.inputSchema.properties).toHaveProperty("project"); + if (tool.name === "manage_vault_provider_configs") { + expect(tool.inputSchema.properties).not.toHaveProperty("project"); + } else { + expect(tool.inputSchema.properties).toHaveProperty("project"); + } expect(JSON.stringify(tool.inputSchema)).not.toContain('"$ref"'); expect(tool.annotations).toMatchObject({ readOnlyHint: false, @@ -33,8 +38,13 @@ describe("vault SDK request contracts", () => { }); } const cards = tools.find((tool) => tool.name === "manage_vault_cards"); - expect(cards?.description).toContain("live payment cards"); - expect(cards?.description).toContain("Test-mode creation is unsupported"); + expect(cards?.description).toContain( + "Mode is determined by the wallet credentials", + ); + expect(cards?.description).toContain( + "Pending issuance updates preserve omitted optional fields", + ); + expect(cards?.description).toContain("recovery_required"); expect(fixture.requests).toHaveLength(0); } finally { await fixture.close(); diff --git a/src/lib/mcp/tools/vaults.ts b/src/lib/mcp/tools/vaults.ts index fd8985fe..0f7e99d6 100644 --- a/src/lib/mcp/tools/vaults.ts +++ b/src/lib/mcp/tools/vaults.ts @@ -24,18 +24,20 @@ import { import { registerVaultWalletTools } from "@/lib/mcp/tools/vault-wallets"; import { registerVaultCardTools } from "@/lib/mcp/tools/vault-cards"; import { registerVaultItemTools } from "@/lib/mcp/tools/vault-items"; +import { registerVaultProviderConfigTools } from "@/lib/mcp/tools/vault-provider-configs"; export function registerVaultCapabilities( server: McpServer, dependencies: McpDependencies = defaultMcpDependencies, ) { + registerVaultProviderConfigTools(server, dependencies); registerVaultWalletTools(server, dependencies); registerVaultCardTools(server, dependencies); registerVaultItemTools(server, dependencies); server.tool( "manage_vaults", - 'Manage project-owned payment vaults, not merchant payments. "create" creates or retrieves a vault by immutable name; "list" lists the effective project only; "get" reads one; "delete" invalidates the vault and every item credential. Confirm deletion with the user first. Connect a wallet with manage_vault_wallets, configure a card with manage_vault_cards, and observe actions/outcomes with manage_vault_items. Requests are not automatically retried.', + 'Manage project-owned payment vaults, not merchant payments. "create" creates or retrieves a vault by immutable name; "list" lists the effective project only; "get" reads one; "delete" invalidates the vault and every item credential. Confirm deletion with the user first; unresolved payment operations block deletion and require provider/support reconciliation. Connect a wallet with manage_vault_wallets, configure a card with manage_vault_cards, and observe actions/outcomes with manage_vault_items. Requests are not automatically retried.', { ...vaultProjectSchema, action: z.enum(["create", "list", "get", "delete"]), diff --git a/src/lib/mcp/vault-responses.test.ts b/src/lib/mcp/vault-responses.test.ts index 73bed208..02a03d0c 100644 --- a/src/lib/mcp/vault-responses.test.ts +++ b/src/lib/mcp/vault-responses.test.ts @@ -220,7 +220,11 @@ describe("vault public responses", () => { test.each([ [400, "invalid_request", "Invalid vault request."], - [404, "not_found", "Vault, item, or project not found or unavailable."], + [ + 404, + "not_found", + "Vault, item, provider configuration, or project not found or unavailable.", + ], [409, "conflict", "conflicts with the current configuration or state"], [500, "project_error", "Unable to resolve the vault's project."], [500, "db_error", "The vault storage request could not be completed."], diff --git a/src/lib/mcp/vault-responses.ts b/src/lib/mcp/vault-responses.ts index 16137682..81888d0e 100644 --- a/src/lib/mcp/vault-responses.ts +++ b/src/lib/mcp/vault-responses.ts @@ -14,6 +14,9 @@ function fields(names: string): OutputFields { } export const vaultFields = fields("id name created_at updated_at"); +export const vaultProviderConfigFields = fields( + "id name provider client_id test_mode created_at updated_at", +); const operationFields = fields("type description"); const totalFields = fields("type display_text amount"); const paymentMethodFields = { @@ -34,7 +37,11 @@ export const vaultItemFields: OutputFields = { ...fields( "provider wallet user_id payment_method_id card_id amount currency merchant merchant_name merchant_url context expires_at", ), - authorization: { method: null, client: fields("type") }, + provider_config: fields("id name"), + authorization: { + method: null, + client: { type: null, provider_config: fields("id name") }, + }, totals: totalFields, line_items: { ...fields( @@ -128,6 +135,24 @@ export function projectVaultOutput( return result; } +// Also remove supplied secrets if an upstream response echoes them in public fields. +export function redactVaultSecrets( + value: unknown, + secrets: (string | undefined)[], +): unknown { + return JSON.parse( + JSON.stringify(value, (_key, field) => { + if (typeof field !== "string") return field; + for (const secret of secrets) { + if (!secret) continue; + field = field.split(secret).join("[redacted]"); + field = field.split(encodeURIComponent(secret)).join("[redacted]"); + } + return field; + }), + ); +} + type VaultItemTarget = { project?: string; vault: string; @@ -163,8 +188,15 @@ export function vaultObservationHints(target: VaultItemTarget, after?: string) { ]; } -export function vaultItemResponse(item: unknown, target: VaultItemTarget) { - const projected = projectVaultOutput(item, vaultItemFields); +export function vaultItemResponse( + item: unknown, + target: VaultItemTarget, + secrets: (string | undefined)[] = [], +) { + const projected = redactVaultSecrets( + projectVaultOutput(item, vaultItemFields), + secrets, + ); const advertised = advertisedOperationsSchema.safeParse(projected); return jsonResponse({ item: projected, @@ -179,10 +211,11 @@ export function vaultItemResponse(item: unknown, target: VaultItemTarget) { : [], }, guidance: [ - "Ask the user to complete returned provider actions; never send card data or OAuth codes/tokens to MCP. Read operation descriptions and obtain explicit user approval before invoking.", + "Ask the user to complete returned provider actions. Never request card data or OAuth codes/tokens in chat; imported grants must come from a trusted backend. Read operation descriptions and obtain explicit user approval before invoking.", "Use returned aliases only in a new browser created with this vault attached, respecting returned permitted domains. Ready does not mean paid.", "Observe get/events for outcomes. Do not retry failed, timed-out, rejected, or indeterminate payments or reconfigure a card to retry them.", "Invocation hints are not approval to execute. Availability may change; invoke rechecks the advertised operations.", + "recovery_required is an unresolved original outcome, not decline or expiry. Stop payment attempts; reconcile with the provider or support. No reset exists, and deletion may be blocked for this item and its parents.", ], }); } @@ -192,7 +225,14 @@ const vaultErrorMessages = new Map([ "invalid_request", "Invalid vault request. Check the tool's documented inputs.", ], - ["not_found", "Vault, item, or project not found or unavailable."], + [ + "not_found", + "Vault, item, provider configuration, or project not found or unavailable.", + ], + [ + "forbidden", + "This credential cannot perform the vault operation. Provider configuration writes require organization scope.", + ], [ "conflict", "The vault request conflicts with the current configuration or state. Inspect the item and its advertised operations and expansions.", diff --git a/src/lib/mcp/vault-schemas.ts b/src/lib/mcp/vault-schemas.ts index fdcc6e7e..c56209ba 100644 --- a/src/lib/mcp/vault-schemas.ts +++ b/src/lib/mcp/vault-schemas.ts @@ -41,29 +41,91 @@ export const vaultWaitSchema = z const integer = () => z.number().int().safe(); const currency = () => z.string().regex(/^[A-Za-z]{3}$/); +export function providerConfigReferenceSchema() { + return z + .object({ + id: vaultSelectorSchema().optional(), + name: vaultSelectorSchema().optional(), + }) + .strict("Unexpected provider config reference fields.") + .refine( + (value) => (value.id !== undefined) !== (value.name !== undefined), + "Provide exactly one provider config id or name.", + ); +} + +// Strict-object issues include unknown keys in MCP errors. A misplaced secret +// can be a key, so reject extras without copying those keys into the issue. +function secretInputObject(shape: T) { + return z + .object(shape) + .passthrough() + .refine( + (value) => + Object.keys(value).every((key) => + Object.prototype.hasOwnProperty.call(shape, key), + ), + "Unexpected credential fields.", + ); +} + +export const providerCredentialsSchema = secretInputObject({ + client_id: z.string().min(1).optional(), + client_secret: z + .string() + .min(1) + .describe( + "Write-only secret; supply through a trusted client, never chat.", + ), +}); + // Keep provider specifications in sync with https://api.onkernel.com/spec.yaml. export const linkWalletSpecSchema = z .object({ provider: z.literal("link").optional(), - authorization: z - .object({ - method: z.literal("oauth"), - client: z.object({ type: z.literal("kernel_managed") }).strict(), - }) - .strict(), + authorization: z.union([ + z + .object({ + method: z.literal("oauth"), + client: z + .object({ type: z.literal("kernel_managed") }) + .strict("Unexpected OAuth client fields."), + }) + .strict("Kernel-managed authorization does not accept tokens."), + z + .object({ + method: z.literal("oauth"), + client: z + .object({ + type: z.literal("customer_managed"), + provider_config: providerConfigReferenceSchema(), + }) + .strict("Unexpected OAuth client fields."), + tokens: secretInputObject({ + access_token: z.string().min(1), + refresh_token: z.string().min(1), + }).describe( + "Write-only token pair from the same grant. Supply through a trusted backend, never chat. Kernel owns subsequent refresh rotation.", + ), + }) + .strict("Unexpected imported authorization fields."), + ]), }) - .strict(); + .strict("Unexpected Link wallet fields."); export const agentcardWalletSpecSchema = z .object({ provider: z.literal("agentcard").optional(), + provider_config: providerConfigReferenceSchema().optional(), user_id: z .string() .regex(/^usr_[A-Za-z0-9_]+$/) - .describe("An AgentCard user already enrolled in this organization.") + .describe( + "An AgentCard user already enrolled in this organization under the same provider configuration.", + ) .optional(), }) - .strict(); + .strict("Unexpected AgentCard wallet fields."); function linkTotalSchema() { return z From d603ec95445aa60983ad8485e2e9bde55989ee32 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:54:59 +0000 Subject: [PATCH 2/5] Update to released Node SDK 0.101.0 --- README.md | 2 +- bun.lock | 4 +- docs/vault-payments.md | 4 +- docs/vault-sdk-preview.md | 92 ------------------------------------ docs/vault-sdk-validation.md | 40 ++++++++++++++++ package.json | 2 +- 6 files changed, 46 insertions(+), 98 deletions(-) delete mode 100644 docs/vault-sdk-preview.md create mode 100644 docs/vault-sdk-validation.md diff --git a/README.md b/README.md index 7beccc88..2991e03c 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,7 @@ Call `get_connection_context` before deciding whether to create or select a proj - `manage_vault_cards` - Create or update card requests according to the API's lifecycle rules; does not implicitly authorize Link cards. - `manage_vault_items` - List, get, invoke advertised operations, observe events, and delete vault items. Provider approvals remain user actions; ready does not mean paid. -See [Vault payments](docs/vault-payments.md) for both provider flows, safety rules, and response shapes. `manage_browsers` accepts creation-only `vaults` references (max 20); existing sessions and pools cannot gain vault bindings. The five vault tools share the `vaults` toolset and prepare/observe credentials rather than submitting merchant payments. They are exposed only when `GET /org/entitlements` reports `features.vaults.enabled: true` for the current credential; missing or unavailable entitlements hide them. Toolset configuration cannot override this access check. Provider configuration support is awaiting a stable SDK release; see the [SDK validation and release gate](docs/vault-sdk-preview.md) before building or merging. +See [Vault payments](docs/vault-payments.md) for both provider flows, safety rules, and response shapes. `manage_browsers` accepts creation-only `vaults` references (max 20); existing sessions and pools cannot gain vault bindings. The five vault tools share the `vaults` toolset and prepare/observe credentials rather than submitting merchant payments. They are exposed only when `GET /org/entitlements` reports `features.vaults.enabled: true` for the current credential; missing or unavailable entitlements hide them. Toolset configuration cannot override this access check. Provider configuration support uses the released `@onkernel/sdk` 0.101.0; see [SDK validation](docs/vault-sdk-validation.md) for reproducible build and test commands. ### Standalone tools diff --git a/bun.lock b/bun.lock index ced2fac2..a6b90ab4 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ "@clerk/themes": "^2.4.19", "@modelcontextprotocol/sdk": "1.26.0", "@onkernel/managed-auth-react": "0.5.1", - "@onkernel/sdk": "^0.100.0", + "@onkernel/sdk": "^0.101.0", "@posthog/mcp": "0.10.1", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", @@ -149,7 +149,7 @@ "@onkernel/managed-auth-react": ["@onkernel/managed-auth-react@0.5.1", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-tRnx91QTqlop2otlXyOxmI+jHodAMCW0dytWyk4hvu26cjACqH0387S0nP4IfgBVkZW6rwdNoBYKOheQfIxMgA=="], - "@onkernel/sdk": ["@onkernel/sdk@0.100.0", "", {}, "sha512-kyWMSHAZUIONcqsFpSExGVzKvml8iSFGec48PLRccum1DPZi/e1YE4o8v1SMkxVB5stecyCQFTs1hrkfdcVl8A=="], + "@onkernel/sdk": ["@onkernel/sdk@0.101.0", "", {}, "sha512-XxUbhbLziPOVvfIbsSRAFDmtnBHwSAXJ6/ENUJ5UvfFtnc1p5WjmYpiftJIM/B5Z0AXjTm2TYkgN+pVs+1jR8w=="], "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eJopQrUk0WR7jViYDC29+Rp50xGvs4GtWOXBeqCoFMzutkkO3CZvHehA4JqnjfWMTSS8toqvRhCSOpOz62Wf9w=="], diff --git a/docs/vault-payments.md b/docs/vault-payments.md index 34f5d129..58e34c53 100644 --- a/docs/vault-payments.md +++ b/docs/vault-payments.md @@ -9,8 +9,8 @@ there is no per-item test flag. AgentCard configuration responses report the introspected `test_mode`. A development or staging MCP endpoint does not make a card request a test transaction. -For the unreleased provider configuration APIs, see the -[SDK validation and release gate](vault-sdk-preview.md). +Provider configuration support uses the released Node SDK 0.101.0. See +[SDK validation](vault-sdk-validation.md) for build and test commands. ## Tools and scope diff --git a/docs/vault-sdk-preview.md b/docs/vault-sdk-preview.md deleted file mode 100644 index ed193f72..00000000 --- a/docs/vault-sdk-preview.md +++ /dev/null @@ -1,92 +0,0 @@ -# Vault SDK validation and release gate - -**Do not merge or deploy this change until a stable Node SDK includes the new vault -provider configuration APIs.** `package.json` and `bun.lock` deliberately retain the -released `@onkernel/sdk` dependency. No staging dependency or generated SDK source -is shipped in this repository. - -The locked SDK (`0.100.0`) cannot typecheck this change: it lacks -`vaultProviderConfigs`, its request types, and the imported Link wallet request -variant. Its version number is also used by the preview; the preview version -string is **not** evidence that the feature has been released. - -## Validated contracts - -- API source: `kernel/kernel` PR #3816, head - `0b96b27cfe03b4c26e8a6c92b8ae7baeca54e77d`. -- Generated SDK: `kernel/kernel-node-sdk-staging`, commit - `ae29b778cecc8aadbf1922c29cd1fcb0464f9ea6`, from `stlc/preview/pr-3816`. -- Reviewed the complete SDK diff: five new config endpoints, customer config - references, imported Link grants, recovery states, and updated card lifecycle - semantics. The latest API main merge changes no vault paths or schemas; the - preview's vault exports and signatures match that head. -- Tests use the actual generated SDK with mocked HTTP transports. No provider - enrollment, OAuth, payment, deployment, or production mutation is exercised. - -## Reproduce against the exact preview - -Use a disposable checkout of this MCP branch. The following local tarball override -changes `node_modules` only; do not deploy it or commit an SDK override. - -1. Clone and verify the generated SDK (next to the MCP checkout): - - ```sh - gh repo clone kernel/kernel-node-sdk-staging -- --depth=1 --branch stlc/preview/pr-3816 - cd kernel-node-sdk-staging - git fetch origin ae29b778cecc8aadbf1922c29cd1fcb0464f9ea6 --depth=1 - git checkout --detach ae29b778cecc8aadbf1922c29cd1fcb0464f9ea6 - test "$(git rev-parse HEAD)" = ae29b778cecc8aadbf1922c29cd1fcb0464f9ea6 - ``` - -2. Build and pack the SDK using its own build script: - - ```sh - bun install - bun run build - cd dist - bun pm pack --filename /tmp/kernel-sdk-ae29b77.tgz - ``` - -3. Install the tarball without saving dependency changes: - - ```sh - cd ../../kernel-mcp-server - bun install --frozen-lockfile - bun add --no-save /tmp/kernel-sdk-ae29b77.tgz - git diff --exit-code -- package.json bun.lock - ``` - - A tarball is used instead of `bun link`: Turbopack cannot resolve a linked SDK - outside its project root with this repository's current configuration. - -4. Validate locally: - - ```sh - bun test - bunx tsc --noEmit - KERNEL_CLI_PROD_CLIENT_ID=build-test-prod \ - KERNEL_CLI_STAGING_CLIENT_ID=build-test-staging \ - KERNEL_CLI_DEV_CLIENT_ID=build-test-dev \ - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_Y2xlcmsuZXhhbXBsZS5jb20k \ - NEXT_TELEMETRY_DISABLED=1 bun run build - ``` - - These dummy values satisfy build-time configuration only; they cannot be used - for authentication. Without configuration, page-data collection fails on the - pre-existing required OAuth client IDs. `bun run lint` is also pre-existingly - unsupported because Next.js 16 removed `next lint`; check changed-file Prettier - formatting instead. - -5. Discard the disposable checkout after validation, or reinstall the locked SDK - with `bun install --force --frozen-lockfile`. Verify its contents before using - the checkout for anything else; the preview shares the stable version string. - -## Before merging - -Publish the stable SDK through its normal release process, then update this -repository's `@onkernel/sdk` dependency and lockfile to that **actual released -version**. Repeat tests, typecheck, and build with a clean frozen-lockfile install, -not the preview tarball. Recheck the generated signatures if the upstream API -changes. Coordinate availability of the new API routes before deploying the MCP -server. Until those steps are complete, the default dependency build is blocked; -the preview-backed build is for review and validation only. diff --git a/docs/vault-sdk-validation.md b/docs/vault-sdk-validation.md new file mode 100644 index 00000000..f59c08af --- /dev/null +++ b/docs/vault-sdk-validation.md @@ -0,0 +1,40 @@ +# Vault SDK validation + +Vault provider configuration support uses the published `@onkernel/sdk` **0.101.0**. +`package.json` requires `^0.101.0`, and `bun.lock` pins the npm release. No staging +package, local tarball override, or generated SDK source is required. + +## Validated contracts + +The [v0.101.0 release](https://github.com/kernel/kernel-node-sdk/releases/tag/v0.101.0) +was published from commit `88e5ccf02ff0b653f8951d38a508a684fe12231b`. +Its generated vault resources match the previously validated preview at +`ae29b778cecc8aadbf1922c29cd1fcb0464f9ea6`: provider configuration CRUD, config +references, imported Link grants, recovery states, and card lifecycle semantics. + +Tests use the installed release with mocked HTTP transports. No provider +enrollment, OAuth, payment, deployment, or production mutation is exercised. + +## Reproduce validation + +From a clean checkout: + +```sh +bun install --frozen-lockfile +bun test +bunx tsc --noEmit +KERNEL_CLI_PROD_CLIENT_ID=build-test-prod \ +KERNEL_CLI_STAGING_CLIENT_ID=build-test-staging \ +KERNEL_CLI_DEV_CLIENT_ID=build-test-dev \ +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_Y2xlcmsuZXhhbXBsZS5jb20k \ +NEXT_TELEMETRY_DISABLED=1 bun run build +``` + +The dummy values satisfy build-time configuration only; they cannot be used for +authentication. Without configuration, page-data collection fails on the existing +required OAuth client IDs. The existing `next lint` script is unsupported on +Next.js 16; check changed-file Prettier formatting instead. + +The stable-SDK merge gate is resolved. Deployment still requires the new vault API +routes to be available and the caller's vault entitlement to be enabled; local SDK +validation does not verify a deployed API's availability. diff --git a/package.json b/package.json index 222b0f40..a61846cc 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "@clerk/themes": "^2.4.19", "@modelcontextprotocol/sdk": "1.26.0", "@onkernel/managed-auth-react": "0.5.1", - "@onkernel/sdk": "^0.100.0", + "@onkernel/sdk": "^0.101.0", "@posthog/mcp": "0.10.1", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", From 59da88307718ec3da763798008808f65efb4c378 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:09:30 +0000 Subject: [PATCH 3/5] Remove vault SDK validation document --- README.md | 2 +- docs/vault-payments.md | 3 +-- docs/vault-sdk-validation.md | 40 ------------------------------------ 3 files changed, 2 insertions(+), 43 deletions(-) delete mode 100644 docs/vault-sdk-validation.md diff --git a/README.md b/README.md index 2991e03c..d162d8d6 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,7 @@ Call `get_connection_context` before deciding whether to create or select a proj - `manage_vault_cards` - Create or update card requests according to the API's lifecycle rules; does not implicitly authorize Link cards. - `manage_vault_items` - List, get, invoke advertised operations, observe events, and delete vault items. Provider approvals remain user actions; ready does not mean paid. -See [Vault payments](docs/vault-payments.md) for both provider flows, safety rules, and response shapes. `manage_browsers` accepts creation-only `vaults` references (max 20); existing sessions and pools cannot gain vault bindings. The five vault tools share the `vaults` toolset and prepare/observe credentials rather than submitting merchant payments. They are exposed only when `GET /org/entitlements` reports `features.vaults.enabled: true` for the current credential; missing or unavailable entitlements hide them. Toolset configuration cannot override this access check. Provider configuration support uses the released `@onkernel/sdk` 0.101.0; see [SDK validation](docs/vault-sdk-validation.md) for reproducible build and test commands. +See [Vault payments](docs/vault-payments.md) for both provider flows, safety rules, and response shapes. `manage_browsers` accepts creation-only `vaults` references (max 20); existing sessions and pools cannot gain vault bindings. The five vault tools share the `vaults` toolset and prepare/observe credentials rather than submitting merchant payments. They are exposed only when `GET /org/entitlements` reports `features.vaults.enabled: true` for the current credential; missing or unavailable entitlements hide them. Toolset configuration cannot override this access check. Provider configuration support uses the released `@onkernel/sdk` 0.101.0. ### Standalone tools diff --git a/docs/vault-payments.md b/docs/vault-payments.md index 58e34c53..179fde36 100644 --- a/docs/vault-payments.md +++ b/docs/vault-payments.md @@ -9,8 +9,7 @@ there is no per-item test flag. AgentCard configuration responses report the introspected `test_mode`. A development or staging MCP endpoint does not make a card request a test transaction. -Provider configuration support uses the released Node SDK 0.101.0. See -[SDK validation](vault-sdk-validation.md) for build and test commands. +Provider configuration support uses the released Node SDK 0.101.0. ## Tools and scope diff --git a/docs/vault-sdk-validation.md b/docs/vault-sdk-validation.md deleted file mode 100644 index f59c08af..00000000 --- a/docs/vault-sdk-validation.md +++ /dev/null @@ -1,40 +0,0 @@ -# Vault SDK validation - -Vault provider configuration support uses the published `@onkernel/sdk` **0.101.0**. -`package.json` requires `^0.101.0`, and `bun.lock` pins the npm release. No staging -package, local tarball override, or generated SDK source is required. - -## Validated contracts - -The [v0.101.0 release](https://github.com/kernel/kernel-node-sdk/releases/tag/v0.101.0) -was published from commit `88e5ccf02ff0b653f8951d38a508a684fe12231b`. -Its generated vault resources match the previously validated preview at -`ae29b778cecc8aadbf1922c29cd1fcb0464f9ea6`: provider configuration CRUD, config -references, imported Link grants, recovery states, and card lifecycle semantics. - -Tests use the installed release with mocked HTTP transports. No provider -enrollment, OAuth, payment, deployment, or production mutation is exercised. - -## Reproduce validation - -From a clean checkout: - -```sh -bun install --frozen-lockfile -bun test -bunx tsc --noEmit -KERNEL_CLI_PROD_CLIENT_ID=build-test-prod \ -KERNEL_CLI_STAGING_CLIENT_ID=build-test-staging \ -KERNEL_CLI_DEV_CLIENT_ID=build-test-dev \ -NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_Y2xlcmsuZXhhbXBsZS5jb20k \ -NEXT_TELEMETRY_DISABLED=1 bun run build -``` - -The dummy values satisfy build-time configuration only; they cannot be used for -authentication. Without configuration, page-data collection fails on the existing -required OAuth client IDs. The existing `next lint` script is unsupported on -Next.js 16; check changed-file Prettier formatting instead. - -The stable-SDK merge gate is resolved. Deployment still requires the new vault API -routes to be available and the caller's vault entitlement to be enabled; local SDK -validation does not verify a deployed API's availability. From cbe23a33cfa00fb8aa27fcc36b23d479cdc03f0d Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:18:07 +0000 Subject: [PATCH 4/5] Address vault tool review feedback --- .../mcp/tools/vault-provider-configs.test.ts | 57 ++++++++++++++++++- src/lib/mcp/tools/vault-provider-configs.ts | 13 ----- src/lib/mcp/vault-responses.test.ts | 2 + src/lib/mcp/vault-responses.ts | 2 +- 4 files changed, 58 insertions(+), 16 deletions(-) diff --git a/src/lib/mcp/tools/vault-provider-configs.test.ts b/src/lib/mcp/tools/vault-provider-configs.test.ts index bf5581e6..4acb380b 100644 --- a/src/lib/mcp/tools/vault-provider-configs.test.ts +++ b/src/lib/mcp/tools/vault-provider-configs.test.ts @@ -288,6 +288,61 @@ describe("config scope and validation", () => { } }); + test.each(["list", "get", "delete"])( + "%s ignores unused write fields without sending credentials", + async (action) => { + const response = + action === "list" + ? Response.json([config]) + : action === "get" + ? Response.json(config) + : new Response(null, { status: 204 }); + const fixture = await connectVaultTest( + [response], + organizationWideAuthInfo(), + ); + try { + const result = await fixture.call(tool, { + ...create, + action, + config: config.id, + }); + expect(result.isError).not.toBe(true); + expectSecretFree(result); + expect(fixture.requests).toHaveLength(1); + expect(fixture.requests[0].body).toBeUndefined(); + expect(fixture.requests[0].path).toBe( + action === "list" + ? "/vault-provider-configs" + : `/vault-provider-configs/${config.id}`, + ); + } finally { + await fixture.close(); + } + }, + ); + + test("update ignores the create-only provider without changing identity", async () => { + const fixture = await connectVaultTest( + [Response.json({ ...config, name: "renamed" })], + organizationWideAuthInfo(), + ); + try { + const result = await fixture.call(tool, { + action: "update", + config: config.id, + provider: "link", + name: "renamed", + }); + expect(result.isError).not.toBe(true); + expect(toolResultJSON(result).provider).toBe(config.provider); + expect(fixture.requests).toHaveLength(1); + expect(fixture.requests[0].body).toEqual({ name: "renamed" }); + } finally { + await fixture.close(); + } + }); + const invalidConfigInputs: Record[] = [ { action: "create" }, { ...create, credentials: { client_secret: secret } }, @@ -297,7 +352,6 @@ describe("config scope and validation", () => { credentials: { client_id: config.client_id, client_secret: "" }, }, { action: "update", config: config.id, credentials: create.credentials }, - { action: "update", config: config.id, provider: "link", name: "renamed" }, { action: "update", config: config.id }, { action: "update", name: "renamed" }, { action: "get" }, @@ -305,7 +359,6 @@ describe("config scope and validation", () => { { action: "delete" }, { action: "list", limit: 101 }, { action: "list", offset: -1 }, - { action: "list", credentials: { client_secret: secret } }, ]; test.each(invalidConfigInputs)( "rejects malformed or immutable config changes without disclosure", diff --git a/src/lib/mcp/tools/vault-provider-configs.ts b/src/lib/mcp/tools/vault-provider-configs.ts index fff8a7d5..a8b53d94 100644 --- a/src/lib/mcp/tools/vault-provider-configs.ts +++ b/src/lib/mcp/tools/vault-provider-configs.ts @@ -71,19 +71,6 @@ export function registerVaultProviderConfigTools( "Provider configuration writes require an organization-scoped connection.", ); } - if (params.action !== "create" && params.provider !== undefined) { - return errorResponse( - "provider is only accepted for create; configuration identity is immutable.", - ); - } - if ( - !["create", "update"].includes(params.action) && - (params.credentials !== undefined || params.name !== undefined) - ) { - return errorResponse( - "name and credentials are only accepted for create or update.", - ); - } if ( params.action === "update" && params.credentials?.client_id !== undefined diff --git a/src/lib/mcp/vault-responses.test.ts b/src/lib/mcp/vault-responses.test.ts index 02a03d0c..a61eff5d 100644 --- a/src/lib/mcp/vault-responses.test.ts +++ b/src/lib/mcp/vault-responses.test.ts @@ -220,6 +220,7 @@ describe("vault public responses", () => { test.each([ [400, "invalid_request", "Invalid vault request."], + [403, "forbidden", "Check connection scope and permissions."], [ 404, "not_found", @@ -261,6 +262,7 @@ describe("vault public responses", () => { expect(text).toContain(`[code: ${code}]`); expect(text).toContain("Do not replay a payment."); expect(text).not.toContain("hidden"); + expect(text).not.toContain("Provider configuration writes"); } finally { await fixture.close(); } diff --git a/src/lib/mcp/vault-responses.ts b/src/lib/mcp/vault-responses.ts index 81888d0e..3db46455 100644 --- a/src/lib/mcp/vault-responses.ts +++ b/src/lib/mcp/vault-responses.ts @@ -231,7 +231,7 @@ const vaultErrorMessages = new Map([ ], [ "forbidden", - "This credential cannot perform the vault operation. Provider configuration writes require organization scope.", + "This credential cannot perform the vault operation. Check connection scope and permissions.", ], [ "conflict", From 6e788335aa959072157059089ff992109c620e91 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:56:02 +0000 Subject: [PATCH 5/5] Sanitize vault validation and complete responses --- src/lib/mcp/tools/vault-cards.ts | 5 +- src/lib/mcp/tools/vault-items.ts | 5 +- src/lib/mcp/tools/vault-provider-configs.ts | 42 ++-- .../mcp/tools/vault-secret-boundaries.test.ts | 227 ++++++++++++++++++ src/lib/mcp/tools/vault-wallets.ts | 5 +- src/lib/mcp/tools/vaults.ts | 5 +- src/lib/mcp/vault-responses.ts | 105 +++++--- src/lib/mcp/vault-schemas.ts | 90 ++++--- 8 files changed, 380 insertions(+), 104 deletions(-) create mode 100644 src/lib/mcp/tools/vault-secret-boundaries.test.ts diff --git a/src/lib/mcp/tools/vault-cards.ts b/src/lib/mcp/tools/vault-cards.ts index e40018d9..bd023435 100644 --- a/src/lib/mcp/tools/vault-cards.ts +++ b/src/lib/mcp/tools/vault-cards.ts @@ -9,6 +9,7 @@ import { vaultItemSchema, vaultKeySchema, vaultProviderSchema, + vaultToolInput, } from "@/lib/mcp/vault-schemas"; export function registerVaultCardTools( @@ -18,7 +19,7 @@ export function registerVaultCardTools( server.tool( "manage_vault_cards", 'Configure payment card requests, not merchant payments. Mode is determined by the wallet credentials, not a per-item test flag; never assume a test transaction. "create" creates or retrieves an identical card request by immutable key. "update" replaces requested-card specs. Pending issuance updates preserve omitted optional fields and clear explicit empty lists, only for provider-supported edits allowed by the API. Wallet/provider binding cannot change after authorization starts. Uncertain updates enter recovery_required; do not retry. Neither implicitly authorizes Link: inspect available_operations with manage_vault_items and obtain explicit user approval before invoking. AgentCard authorizes at checkout. Amounts are integer minor currency units. No card data, OAuth tokens, provider secrets, or domain configuration. Never reconfigure a card to retry a failed, timed-out, rejected, or indeterminate payment. Requests are not automatically retried.', - { + vaultToolInput({ ...vaultItemSchema, key: vaultKeySchema(), action: z.enum(["create", "update"]), @@ -28,7 +29,7 @@ export function registerVaultCardTools( .describe( "Full provider specification object, not a {type, spec} envelope. Embedded provider must match provider. No defaults or normalization are applied. Integers must be within JavaScript's safe range, including expires_at.", ), - }, + }), { title: "Configure Kernel vault cards", readOnlyHint: false, diff --git a/src/lib/mcp/tools/vault-items.ts b/src/lib/mcp/tools/vault-items.ts index a46837d3..e853dd65 100644 --- a/src/lib/mcp/tools/vault-items.ts +++ b/src/lib/mcp/tools/vault-items.ts @@ -17,6 +17,7 @@ import { vaultItemSchema, vaultKeySchema, vaultWaitSchema, + vaultToolInput, } from "@/lib/mcp/vault-schemas"; export function registerVaultItemTools( @@ -26,7 +27,7 @@ export function registerVaultItemTools( server.tool( "manage_vault_items", 'Inspect payment vault items and immutable audit events. "list" reads items; "get" reads state, public aliases, required user actions, available_operations, and available_expansions. "invoke" fetches the item again and submits only an advertised operation; read its description and obtain explicit user approval first. Provider actions (OAuth, enrollment, MFA, approval) must be completed by the user, not invoked as operations. "events" observes outcomes; use the last event ID as after. "delete" invalidates an item credential; confirm with the user first. Unresolved payments block item and parent deletion. recovery_required is not decline or expiry: stop payment attempts and reconcile with the provider or support; no reset exists. Ready does not mean paid. Requests are never automatically retried. Do not retry failed, timed-out, rejected, or indeterminate payments; inspect state/events instead.', - { + vaultToolInput({ ...vaultItemSchema, action: z.enum(["list", "get", "invoke", "events", "delete"]), key: vaultKeySchema() @@ -54,7 +55,7 @@ export function registerVaultItemTools( "(events) Return events after this event ID; preserve the vault and item key.", ) .optional(), - }, + }), { title: "Inspect and operate Kernel vault items", readOnlyHint: false, diff --git a/src/lib/mcp/tools/vault-provider-configs.ts b/src/lib/mcp/tools/vault-provider-configs.ts index a8b53d94..0a18a9b3 100644 --- a/src/lib/mcp/tools/vault-provider-configs.ts +++ b/src/lib/mcp/tools/vault-provider-configs.ts @@ -7,15 +7,11 @@ import type { import { z } from "zod"; import type { McpDependencies } from "@/lib/mcp/dependencies"; import { connectionContextFromAuthInfo } from "@/lib/mcp/project-selection"; -import { - errorResponse, - jsonResponse, - paginatedJsonResponse, -} from "@/lib/mcp/responses"; +import { errorResponse } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; import { projectVaultOutput, - redactVaultSecrets, + vaultResponse, throwVaultError, vaultProviderConfigFields, } from "@/lib/mcp/vault-responses"; @@ -23,6 +19,7 @@ import { providerCredentialsSchema, vaultProviderSchema, vaultSelectorSchema, + vaultToolInput, } from "@/lib/mcp/vault-schemas"; export function registerVaultProviderConfigTools( @@ -32,7 +29,7 @@ export function registerVaultProviderConfigTools( server.tool( "manage_vault_provider_configs", 'Manage organization-owned Link and AgentCard application credentials, not user OAuth grants. "create" requires name, provider, and credentials (client_id/client_secret); duplicate names conflict without replacing secrets. "list" and "get" return public configuration metadata only. "update" renames or rotates client_secret across all bound wallets; omitted fields stay unchanged. Provider, client_id, mode, and wallet bindings are immutable. "delete" requires user confirmation and fails while any non-deleted item references the config; it does not revoke unrelated grants. Writes require an organization-scoped connection. Supply write-only secrets through a trusted client, never chat. No automatic retries.', - { + vaultToolInput({ action: z.enum(["create", "list", "get", "update", "delete"]), config: vaultSelectorSchema() .describe( @@ -51,7 +48,7 @@ export function registerVaultProviderConfigTools( ) .optional(), ...paginationParams, - }, + }), { title: "Manage Kernel vault provider configurations", readOnlyHint: false, @@ -88,10 +85,9 @@ export function registerVaultProviderConfigTools( }); const options = { maxRetries: 0, signal: extra.signal }; const project = (value: unknown) => - redactVaultSecrets( - projectVaultOutput(value, vaultProviderConfigFields), - [params.credentials?.client_secret], - ); + projectVaultOutput(value, vaultProviderConfigFields); + const respond = (value: unknown) => + vaultResponse(value, [params.credentials?.client_secret]); try { switch (params.action) { case "create": { @@ -112,7 +108,7 @@ export function registerVaultProviderConfigTools( client_secret: params.credentials.client_secret, }, }; - return jsonResponse( + return respond( project(await client.vaultProviderConfigs.create(body, options)), ); } @@ -124,16 +120,20 @@ export function registerVaultProviderConfigTools( }, options, ); - return paginatedJsonResponse(page, { - mapItem: project, - emptyText: - "No provider configurations found in the organization.", + const items = page.getPaginatedItems(); + return respond({ + items: items.map(project), + has_more: page.has_more, + next_offset: page.next_offset, + ...(items.length === 0 && { + note: "No provider configurations found in the organization.", + }), }); } case "get": if (!params.config) return errorResponse("config is required for get."); - return jsonResponse( + return respond( project( await client.vaultProviderConfigs.retrieve( params.config, @@ -156,7 +156,7 @@ export function registerVaultProviderConfigTools( }, }), }; - return jsonResponse( + return respond( project( await client.vaultProviderConfigs.update( params.config, @@ -170,7 +170,7 @@ export function registerVaultProviderConfigTools( if (!params.config) return errorResponse("config is required for delete."); await client.vaultProviderConfigs.delete(params.config, options); - return jsonResponse({ + return respond({ status: "deleted_or_not_found", config: params.config, }); @@ -181,7 +181,7 @@ export function registerVaultProviderConfigTools( error instanceof APIError && error.status === 404 ) { - return jsonResponse({ + return respond({ status: "deleted_or_not_found", config: params.config, }); diff --git a/src/lib/mcp/tools/vault-secret-boundaries.test.ts b/src/lib/mcp/tools/vault-secret-boundaries.test.ts new file mode 100644 index 00000000..fa9c0df4 --- /dev/null +++ b/src/lib/mcp/tools/vault-secret-boundaries.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, test } from "bun:test"; +import { organizationWideAuthInfo } from "@/lib/mcp/auth-context.test-fixtures"; +import { toolResultJSON } from "@/lib/mcp/mcp-test-fixtures"; +import { connectVaultTest, item } from "./vaults.test-fixtures"; + +const access = "review-secret-access-sentinel"; +const refresh = "review-secret-refresh-sentinel"; +const authorization = { + method: "oauth", + client: { + type: "customer_managed", + provider_config: { name: "configured-link" }, + }, + tokens: { access_token: access, refresh_token: refresh }, +}; +const wallet = { + action: "create", + provider: "link", + vault: "checkout", + key: "imported-wallet", + spec: { authorization }, +}; + +function expectSecretFree(value: unknown) { + const text = JSON.stringify(value); + expect(text).not.toContain(access); + expect(text).not.toContain(refresh); +} + +describe("vault validation boundary", () => { + test.each([ + { ...wallet.spec, [access]: refresh }, + { authorization: { ...authorization, [access]: refresh } }, + { + authorization: { + ...authorization, + client: { ...authorization.client, [access]: refresh }, + }, + }, + { + authorization: { + ...authorization, + client: { + ...authorization.client, + provider_config: { name: "configured-link", [access]: refresh }, + }, + }, + }, + { + authorization: { + ...authorization, + tokens: { ...authorization.tokens, [access]: refresh }, + }, + }, + { authorization: { ...authorization, method: access } }, + { + authorization: { + ...authorization, + client: { ...authorization.client, type: access }, + }, + }, + ])( + "sanitizes rejected keys and values at every wallet nesting level", + async (spec) => { + const fixture = await connectVaultTest([]); + try { + const result = await fixture.call("manage_vault_wallets", { + ...wallet, + spec, + }); + expect(result.isError).toBe(true); + expectSecretFree(result); + expect(JSON.stringify(result)).toContain("Invalid vault tool input"); + expect(fixture.requests).toHaveLength(0); + } finally { + await fixture.close(); + } + }, + ); + + test.each([ + { action: access }, + { action: "create", provider: access }, + { + action: "create", + credentials: { + client_id: "client", + client_secret: refresh, + [access]: refresh, + }, + }, + ])("sanitizes config validation before the callback runs", async (args) => { + const fixture = await connectVaultTest([], organizationWideAuthInfo()); + try { + const result = await fixture.call("manage_vault_provider_configs", args); + expect(result.isError).toBe(true); + expectSecretFree(result); + expect(fixture.requests).toHaveLength(0); + } finally { + await fixture.close(); + } + }); + + test("advertises strict credential and token objects", async () => { + const fixture = await connectVaultTest([]); + try { + const { tools } = await fixture.client.listTools(); + const configs = tools.find( + ({ name }) => name === "manage_vault_provider_configs", + ); + const wallets = tools.find(({ name }) => name === "manage_vault_wallets"); + expect(configs?.inputSchema.properties?.credentials).toMatchObject({ + type: "object", + additionalProperties: false, + }); + expect(wallets?.inputSchema.properties?.spec).toMatchObject({ + anyOf: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + authorization: expect.objectContaining({ + anyOf: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + tokens: expect.objectContaining({ + type: "object", + additionalProperties: false, + }), + }), + }), + ]), + }), + }), + }), + ]), + }); + } finally { + await fixture.close(); + } + }); +}); + +describe("complete vault response boundary", () => { + test.each(["key", "vault", "project"])( + "omits executable hints when %s contains a supplied token", + async (field) => { + const fixture = await connectVaultTest( + [ + Response.json({ + ...item, + key: access, + type: "wallet", + spec: { authorization }, + state: { provider: "link", status: "connected" }, + }), + ], + organizationWideAuthInfo(), + ); + try { + const result = await fixture.call("manage_vault_wallets", { + ...wallet, + [field]: access, + }); + expect(result.isError).not.toBe(true); + expectSecretFree(result); + const data = toolResultJSON(result); + expect(data.item.key).toBe("[redacted]"); + expect(data.hints).toEqual({ observation: [], invocation: [] }); + expect(fixture.requests).toHaveLength(1); + } finally { + await fixture.close(); + } + }, + ); + + test("omits only unsafe invocation hints without modifying safe identifiers", async () => { + const fixture = await connectVaultTest([ + Response.json({ + ...item, + available_operations: [ + { type: access, description: "Unsafe echo" }, + { type: "authorize", description: "Safe operation" }, + ], + }), + ]); + try { + const result = await fixture.call("manage_vault_wallets", wallet); + expect(result.isError).not.toBe(true); + expectSecretFree(result); + const hints = toolResultJSON(result).hints; + expect(hints.observation).toHaveLength(2); + expect(hints.invocation).toHaveLength(1); + expect(hints.invocation[0].arguments).toMatchObject({ + key: wallet.key, + vault: wallet.vault, + operation: "authorize", + }); + } finally { + await fixture.close(); + } + }); + + test.each([204, 404])( + "redacts config selectors in deletion acknowledgments (HTTP %s)", + async (status) => { + const fixture = await connectVaultTest( + [new Response(null, { status })], + organizationWideAuthInfo(), + ); + try { + const result = await fixture.call("manage_vault_provider_configs", { + action: "delete", + config: access, + credentials: { client_secret: access }, + }); + expect(result.isError).not.toBe(true); + expectSecretFree(result); + expect(toolResultJSON(result)).toEqual({ + status: "deleted_or_not_found", + config: "[redacted]", + }); + expect(fixture.requests[0].body).toBeUndefined(); + } finally { + await fixture.close(); + } + }, + ); +}); diff --git a/src/lib/mcp/tools/vault-wallets.ts b/src/lib/mcp/tools/vault-wallets.ts index 34033290..ffd1f18f 100644 --- a/src/lib/mcp/tools/vault-wallets.ts +++ b/src/lib/mcp/tools/vault-wallets.ts @@ -12,6 +12,7 @@ import { vaultItemSchema, vaultKeySchema, vaultProviderSchema, + vaultToolInput, } from "@/lib/mcp/vault-schemas"; export function registerVaultWalletTools( @@ -21,7 +22,7 @@ export function registerVaultWalletTools( server.tool( "manage_vault_wallets", 'Connect payment wallets without exposing secrets. "create" creates or retrieves an identical wallet by immutable key. Hosted connection/enrollment actions are for the user; a valid imported Link grant creates a connected wallet. "payment_methods" requests the advertised live payment_methods expansion (unavailable expansions return an API error). Select Link payment_method_id explicitly; never automatically choose a default. AgentCard card_id may be omitted for cardholder selection at checkout approval. Capabilities are advisory; absent means unknown. Kernel-managed Link OAuth remains supported. Customer-managed Link requires authorization.client.provider_config and a write-only authorization.tokens pair supplied by a trusted backend, never chat; config credentials do not authorize a user. Kernel owns refresh rotation after import. Duplicate create never replaces a grant; bindings cannot change. AgentCard spec.provider_config is optional; omit for Kernel-managed credentials, and reuse user_id only within the same config. No in-place imported reauthorization: obtain a fresh grant under a new wallet key for new payments only; retain unresolved old payments for reconciliation. Never provide card data or OAuth codes. Requests are not automatically retried.', - { + vaultToolInput({ ...vaultItemSchema, key: vaultKeySchema(), action: z.enum(["create", "payment_methods"]), @@ -34,7 +35,7 @@ export function registerVaultWalletTools( '(create) Specification object, not a {type, spec} envelope. Embedded provider must match provider. Link: {"authorization":{"method":"oauth","client":{"type":"kernel_managed"}}}, or customer_managed with provider_config (exactly one id/name) and tokens from a trusted backend. AgentCard: {} to enroll, optionally provider_config or user_id from the same configuration.', ) .optional(), - }, + }), { title: "Manage Kernel vault wallets", readOnlyHint: false, diff --git a/src/lib/mcp/tools/vaults.ts b/src/lib/mcp/tools/vaults.ts index 0f7e99d6..97e3c620 100644 --- a/src/lib/mcp/tools/vaults.ts +++ b/src/lib/mcp/tools/vaults.ts @@ -20,6 +20,7 @@ import { import { vaultProjectSchema, vaultSelectorSchema, + vaultToolInput, } from "@/lib/mcp/vault-schemas"; import { registerVaultWalletTools } from "@/lib/mcp/tools/vault-wallets"; import { registerVaultCardTools } from "@/lib/mcp/tools/vault-cards"; @@ -38,7 +39,7 @@ export function registerVaultCapabilities( server.tool( "manage_vaults", 'Manage project-owned payment vaults, not merchant payments. "create" creates or retrieves a vault by immutable name; "list" lists the effective project only; "get" reads one; "delete" invalidates the vault and every item credential. Confirm deletion with the user first; unresolved payment operations block deletion and require provider/support reconciliation. Connect a wallet with manage_vault_wallets, configure a card with manage_vault_cards, and observe actions/outcomes with manage_vault_items. Requests are not automatically retried.', - { + vaultToolInput({ ...vaultProjectSchema, action: z.enum(["create", "list", "get", "delete"]), vault: vaultSelectorSchema() @@ -48,7 +49,7 @@ export function registerVaultCapabilities( .describe("(create) Immutable vault name.") .optional(), ...paginationParams, - }, + }), { title: "Manage Kernel payment vaults", readOnlyHint: false, diff --git a/src/lib/mcp/vault-responses.ts b/src/lib/mcp/vault-responses.ts index 3db46455..7ea38e70 100644 --- a/src/lib/mcp/vault-responses.ts +++ b/src/lib/mcp/vault-responses.ts @@ -135,22 +135,47 @@ export function projectVaultOutput( return result; } -// Also remove supplied secrets if an upstream response echoes them in public fields. -export function redactVaultSecrets( +function secretVariants(secrets: (string | undefined)[]) { + return secrets + .filter((secret): secret is string => !!secret) + .flatMap((secret) => [secret, encodeURIComponent(secret)]); +} + +function containsVaultSecret(value: unknown, secrets: string[]): boolean { + if (typeof value === "string") + return secrets.some((secret) => value.includes(secret)); + if (value && typeof value === "object") { + return Object.values(value).some((field) => + containsVaultSecret(field, secrets), + ); + } + return false; +} + +function redactVaultSecrets(value: unknown, secrets: string[]): unknown { + if (typeof value === "string") { + let text = value; + for (const secret of secrets) text = text.split(secret).join("[redacted]"); + return text; + } + if (Array.isArray(value)) + return value.map((field) => redactVaultSecrets(field, secrets)); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, field]) => [ + key, + redactVaultSecrets(field, secrets), + ]), + ); + } + return value; +} + +export function vaultResponse( value: unknown, - secrets: (string | undefined)[], -): unknown { - return JSON.parse( - JSON.stringify(value, (_key, field) => { - if (typeof field !== "string") return field; - for (const secret of secrets) { - if (!secret) continue; - field = field.split(secret).join("[redacted]"); - field = field.split(encodeURIComponent(secret)).join("[redacted]"); - } - return field; - }), - ); + secrets: (string | undefined)[] = [], +) { + return jsonResponse(redactVaultSecrets(value, secretVariants(secrets))); } type VaultItemTarget = { @@ -193,31 +218,35 @@ export function vaultItemResponse( target: VaultItemTarget, secrets: (string | undefined)[] = [], ) { - const projected = redactVaultSecrets( - projectVaultOutput(item, vaultItemFields), - secrets, - ); + const projected = projectVaultOutput(item, vaultItemFields); const advertised = advertisedOperationsSchema.safeParse(projected); - return jsonResponse({ - item: projected, - hints: { - observation: vaultObservationHints(target), - invocation: advertised.success - ? advertised.data.available_operations.map(({ type }) => ({ - tool: "manage_vault_items", - arguments: { ...target, action: "invoke", operation: type }, - requires_user_approval: true, - })) - : [], + const secretValues = secretVariants(secrets); + const safeHint = (hint: unknown) => !containsVaultSecret(hint, secretValues); + return vaultResponse( + { + item: projected, + hints: { + observation: vaultObservationHints(target).filter(safeHint), + invocation: advertised.success + ? advertised.data.available_operations + .map(({ type }) => ({ + tool: "manage_vault_items", + arguments: { ...target, action: "invoke", operation: type }, + requires_user_approval: true, + })) + .filter(safeHint) + : [], + }, + guidance: [ + "Ask the user to complete returned provider actions. Never request card data or OAuth codes/tokens in chat; imported grants must come from a trusted backend. Read operation descriptions and obtain explicit user approval before invoking.", + "Use returned aliases only in a new browser created with this vault attached, respecting returned permitted domains. Ready does not mean paid.", + "Observe get/events for outcomes. Do not retry failed, timed-out, rejected, or indeterminate payments or reconfigure a card to retry them.", + "Invocation hints are not approval to execute. Availability may change; invoke rechecks the advertised operations.", + "recovery_required is an unresolved original outcome, not decline or expiry. Stop payment attempts; reconcile with the provider or support. No reset exists, and deletion may be blocked for this item and its parents.", + ], }, - guidance: [ - "Ask the user to complete returned provider actions. Never request card data or OAuth codes/tokens in chat; imported grants must come from a trusted backend. Read operation descriptions and obtain explicit user approval before invoking.", - "Use returned aliases only in a new browser created with this vault attached, respecting returned permitted domains. Ready does not mean paid.", - "Observe get/events for outcomes. Do not retry failed, timed-out, rejected, or indeterminate payments or reconfigure a card to retry them.", - "Invocation hints are not approval to execute. Availability may change; invoke rechecks the advertised operations.", - "recovery_required is an unresolved original outcome, not decline or expiry. Stop payment attempts; reconcile with the provider or support. No reset exists, and deletion may be blocked for this item and its parents.", - ], - }); + secrets, + ); } const vaultErrorMessages = new Map([ diff --git a/src/lib/mcp/vault-schemas.ts b/src/lib/mcp/vault-schemas.ts index c56209ba..160a33df 100644 --- a/src/lib/mcp/vault-schemas.ts +++ b/src/lib/mcp/vault-schemas.ts @@ -47,37 +47,52 @@ export function providerConfigReferenceSchema() { id: vaultSelectorSchema().optional(), name: vaultSelectorSchema().optional(), }) - .strict("Unexpected provider config reference fields.") + .strict() .refine( (value) => (value.id !== undefined) !== (value.name !== undefined), "Provide exactly one provider config id or name.", ); } -// Strict-object issues include unknown keys in MCP errors. A misplaced secret -// can be a key, so reject extras without copying those keys into the issue. -function secretInputObject(shape: T) { - return z - .object(shape) - .passthrough() - .refine( - (value) => - Object.keys(value).every((key) => - Object.prototype.hasOwnProperty.call(shape, key), - ), - "Unexpected credential fields.", - ); +// MCP serializes Zod issues before the tool callback runs. Validate each input +// field without exposing rejected values or nested keys, retaining its schema +// for tools/list and its normal parsed output for the callback. +export function vaultToolInput(shape: Shape) { + return Object.fromEntries( + Object.entries(shape).map(([key, schema]) => [ + key, + z.preprocess((value, context) => { + if (!schema.safeParse(value).success) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "Invalid vault tool input. Check the documented schema.", + fatal: true, + }); + return z.NEVER; + } + return value; + }, schema), + ]), + ) as { + [Key in keyof Shape]: z.ZodEffects< + Shape[Key], + z.output, + unknown + >; + }; } -export const providerCredentialsSchema = secretInputObject({ - client_id: z.string().min(1).optional(), - client_secret: z - .string() - .min(1) - .describe( - "Write-only secret; supply through a trusted client, never chat.", - ), -}); +export const providerCredentialsSchema = z + .object({ + client_id: z.string().min(1).optional(), + client_secret: z + .string() + .min(1) + .describe( + "Write-only secret; supply through a trusted client, never chat.", + ), + }) + .strict(); // Keep provider specifications in sync with https://api.onkernel.com/spec.yaml. export const linkWalletSpecSchema = z @@ -87,11 +102,9 @@ export const linkWalletSpecSchema = z z .object({ method: z.literal("oauth"), - client: z - .object({ type: z.literal("kernel_managed") }) - .strict("Unexpected OAuth client fields."), + client: z.object({ type: z.literal("kernel_managed") }).strict(), }) - .strict("Kernel-managed authorization does not accept tokens."), + .strict(), z .object({ method: z.literal("oauth"), @@ -100,18 +113,21 @@ export const linkWalletSpecSchema = z type: z.literal("customer_managed"), provider_config: providerConfigReferenceSchema(), }) - .strict("Unexpected OAuth client fields."), - tokens: secretInputObject({ - access_token: z.string().min(1), - refresh_token: z.string().min(1), - }).describe( - "Write-only token pair from the same grant. Supply through a trusted backend, never chat. Kernel owns subsequent refresh rotation.", - ), + .strict(), + tokens: z + .object({ + access_token: z.string().min(1), + refresh_token: z.string().min(1), + }) + .strict() + .describe( + "Write-only token pair from the same grant. Supply through a trusted backend, never chat. Kernel owns subsequent refresh rotation.", + ), }) - .strict("Unexpected imported authorization fields."), + .strict(), ]), }) - .strict("Unexpected Link wallet fields."); + .strict(); export const agentcardWalletSpecSchema = z .object({ @@ -125,7 +141,7 @@ export const agentcardWalletSpecSchema = z ) .optional(), }) - .strict("Unexpected AgentCard wallet fields."); + .strict(); function linkTotalSchema() { return z