diff --git a/README.md b/README.md index c57a827..d162d8d 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 uses the released `@onkernel/sdk` 0.101.0. ### Standalone tools diff --git a/bun.lock b/bun.lock index ced2fac..a6b90ab 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 2340f8e..179fde3 100644 --- a/docs/vault-payments.md +++ b/docs/vault-payments.md @@ -4,12 +4,16 @@ 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. + +Provider configuration support uses the released Node SDK 0.101.0. ## 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 +21,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 +60,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 +230,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 +289,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/package.json b/package.json index 222b0f4..a61846c 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", diff --git a/src/lib/mcp/kernel-client.test.ts b/src/lib/mcp/kernel-client.test.ts index e1e1fb5..5eb1377 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 d6d7dee..417d8ca 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 e5d2c6f..85cb3ca 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 7a29c12..bd02343 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( @@ -17,8 +18,8 @@ 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.', + 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 ea3973c..e853dd6 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( @@ -25,8 +26,8 @@ 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.', + 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.test.ts b/src/lib/mcp/tools/vault-provider-configs.test.ts new file mode 100644 index 0000000..4acb380 --- /dev/null +++ b/src/lib/mcp/tools/vault-provider-configs.test.ts @@ -0,0 +1,632 @@ +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(); + } + }); + + 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 } }, + { ...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 }, + { action: "update", name: "renamed" }, + { action: "get" }, + { action: "get", config: "../bad" }, + { action: "delete" }, + { action: "list", limit: 101 }, + { action: "list", offset: -1 }, + ]; + 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 0000000..0a18a9b --- /dev/null +++ b/src/lib/mcp/tools/vault-provider-configs.ts @@ -0,0 +1,193 @@ +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 } from "@/lib/mcp/responses"; +import { paginationParams } from "@/lib/mcp/schemas"; +import { + projectVaultOutput, + vaultResponse, + throwVaultError, + vaultProviderConfigFields, +} from "@/lib/mcp/vault-responses"; +import { + providerCredentialsSchema, + vaultProviderSchema, + vaultSelectorSchema, + vaultToolInput, +} 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.', + vaultToolInput({ + 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 === "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) => + projectVaultOutput(value, vaultProviderConfigFields); + const respond = (value: unknown) => + vaultResponse(value, [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 respond( + 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, + ); + 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 respond( + 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 respond( + 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 respond({ + status: "deleted_or_not_found", + config: params.config, + }); + } + } catch (error) { + if ( + params.action === "delete" && + error instanceof APIError && + error.status === 404 + ) { + return respond({ + status: "deleted_or_not_found", + config: params.config, + }); + } + throwVaultError("manage_vault_provider_configs", params.action, error); + } + }, + ); +} 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 0000000..fa9c0df --- /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 09f8bb5..ffd1f18 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"; @@ -11,6 +12,7 @@ import { vaultItemSchema, vaultKeySchema, vaultProviderSchema, + vaultToolInput, } from "@/lib/mcp/vault-schemas"; export function registerVaultWalletTools( @@ -19,8 +21,8 @@ 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.', + vaultToolInput({ ...vaultItemSchema, key: vaultKeySchema(), action: z.enum(["create", "payment_methods"]), @@ -30,10 +32,10 @@ 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(), - }, + }), { title: "Manage Kernel vault wallets", readOnlyHint: false, @@ -57,7 +59,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 +78,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 5f3a77e..5ff6ebf 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 fd8985f..97e3c62 100644 --- a/src/lib/mcp/tools/vaults.ts +++ b/src/lib/mcp/tools/vaults.ts @@ -20,23 +20,26 @@ 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"; 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.', + vaultToolInput({ ...vaultProjectSchema, action: z.enum(["create", "list", "get", "delete"]), vault: vaultSelectorSchema() @@ -46,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.test.ts b/src/lib/mcp/vault-responses.test.ts index 73bed20..a61eff5 100644 --- a/src/lib/mcp/vault-responses.test.ts +++ b/src/lib/mcp/vault-responses.test.ts @@ -220,7 +220,12 @@ describe("vault public responses", () => { test.each([ [400, "invalid_request", "Invalid vault request."], - [404, "not_found", "Vault, item, or project not found or unavailable."], + [403, "forbidden", "Check connection scope and permissions."], + [ + 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."], @@ -257,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 1613768..7ea38e7 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,49 @@ export function projectVaultOutput( return result; } +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)[] = [], +) { + return jsonResponse(redactVaultSecrets(value, secretVariants(secrets))); +} + type VaultItemTarget = { project?: string; vault: string; @@ -163,28 +213,40 @@ export function vaultObservationHints(target: VaultItemTarget, after?: string) { ]; } -export function vaultItemResponse(item: unknown, target: VaultItemTarget) { +export function vaultItemResponse( + item: unknown, + target: VaultItemTarget, + secrets: (string | undefined)[] = [], +) { 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 send card data or OAuth codes/tokens to MCP. 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.", - ], - }); + secrets, + ); } const vaultErrorMessages = new Map([ @@ -192,7 +254,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. Check connection scope and permissions.", + ], [ "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 fdcc6e7..160a33d 100644 --- a/src/lib/mcp/vault-schemas.ts +++ b/src/lib/mcp/vault-schemas.ts @@ -41,26 +41,104 @@ 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() + .refine( + (value) => (value.id !== undefined) !== (value.name !== undefined), + "Provide exactly one provider config id or name.", + ); +} + +// 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 = 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 .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(), + }) + .strict(), + z + .object({ + method: z.literal("oauth"), + client: z + .object({ + type: z.literal("customer_managed"), + provider_config: providerConfigReferenceSchema(), + }) + .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(), + ]), }) .strict(); 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();