diff --git a/README.md b/README.md index c489f560..c57a827f 100644 --- a/README.md +++ b/README.md @@ -292,7 +292,7 @@ Many other MCP-capable tools accept: Configure these values wherever the tool expects MCP server settings. -## Tools (20 model-facing, plus 1 app-only helper) +## Tools Each Kernel feature has a single `manage_*` tool with an `action` parameter, keeping the tool set small and consistent. Standalone tools handle high-frequency and interactive workflows. @@ -316,6 +316,12 @@ 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_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_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. ### Standalone tools diff --git a/bun.lock b/bun.lock index 6e978dc0..ced2fac2 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.98.0", + "@onkernel/sdk": "^0.100.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.98.0", "", {}, "sha512-FTvTDEPj3rS6INa+JTqso1XpnGkBKP3axvcAumX8dfd5nAI157mIlaZDWQ2k61MxwKpHDEvD5iNwD+sUk4OEcw=="], + "@onkernel/sdk": ["@onkernel/sdk@0.100.0", "", {}, "sha512-kyWMSHAZUIONcqsFpSExGVzKvml8iSFGec48PLRccum1DPZi/e1YE4o8v1SMkxVB5stecyCQFTs1hrkfdcVl8A=="], "@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 new file mode 100644 index 00000000..2340f8e1 --- /dev/null +++ b/docs/vault-payments.md @@ -0,0 +1,230 @@ +# Vault payments + +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. + +## Tools and scope + +The four 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 +response, or failed lookup hides the vault tools but leaves other toolsets usable. +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` | + +Every tool accepts 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. + +`vault` accepts an ID or immutable name. `key` is an immutable item key within that +vault, not the item ID. Vault names, item keys, and project ownership cannot be renamed. + +Wallet/card writes take a `provider` (`link` or `agentcard`) and a JSON `spec` +**object**, not a string or a `{type, spec}` envelope. The tool injects `provider`; +if present in `spec`, it must match. Tool schemas describe the provider-specific +fields and reject unknown fields, including nested ones. No defaults or currency +normalization are applied. Amounts are integer minor currency units. All integer +inputs, including `expires_at`, must fit JavaScript's safe integer range; unsafe +numbers are rejected, not silently rounded. The API enforces provider/state rules. + +These capabilities use the existing MCP authentication and deployment. To expose +only payment tools on a self-hosted server, set: + +```sh +KERNEL_MCP_ENABLED_TOOLSETS=vaults +``` + +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. + +## Link flow + +1. Create or retrieve a vault with `manage_vaults`: + + ```json + { "action": "create", "name": "checkout" } + ``` + +2. Connect a wallet with `manage_vault_wallets`: + + ```json + { + "action": "create", + "vault": "checkout", + "key": "wallet-1", + "provider": "link", + "spec": { + "authorization": { + "method": "oauth", + "client": { "type": "kernel_managed" } + } + } + } + ``` + + Give the returned `item.action.url` to the user to complete with the provider. + Do not ask for card details or OAuth codes/tokens in chat. Observe the wallet + with `manage_vault_items`, `action: "get"`, the same vault/key, and `wait: 30`. + +3. Once connected, call `manage_vault_wallets` with `action: "payment_methods"` + and the same vault/key. Explicitly select a returned method ID with the user; + do not automatically choose the default. Capabilities are advisory: absent + means unknown, not ineligible. + +4. Create the purchase request with `manage_vault_cards`, replacing + `pm_selected` with the selected returned ID: + + ```json + { + "action": "create", + "vault": "checkout", + "key": "order-1", + "provider": "link", + "spec": { + "wallet": "wallet-1", + "payment_method_id": "pm_selected", + "amount": 1234, + "currency": "usd", + "merchant_name": "Example Shop", + "merchant_url": "https://shop.example", + "context": "Purchase the selected office supplies from Example Shop for the approved order, with a total spending limit of 1234 minor currency units." + } + } + ``` + + Link also supports `line_items`, `totals`, `metadata`, and `expires_at`. + Creating or updating the card does **not** implicitly authorize it. + +5. Read `available_operations` with `manage_vault_items`, `action: "get"`. + Read the operation description and obtain explicit user approval before + invoking an advertised operation: + + ```json + { + "action": "invoke", + "vault": "checkout", + "key": "order-1", + "operation": "authorize" + } + ``` + + The tool fetches the item again and submits only a currently advertised + operation. The current API accepts only `{"type":"authorize"}`; there are no + operation parameters. New parameterless operation names can be forwarded when + the API advertises them. Follow any returned provider action and observe state. + OAuth, enrollment, MFA, and approval actions are for the user, not operation names. + +6. When ready, create a new browser with `manage_browsers`: + + ```json + { + "action": "create", + "vaults": [{ "name": "checkout" }] + } + ``` + + Use only returned `item.state.aliases` through the browser tools in **that + browser**, respecting returned permitted domains. Merchant checkout submission + is a separate browser action and requires the user's authorization. + +## AgentCard flow + +Use a separate vault or different immutable item keys. Create the vault as above, +then connect a wallet with `manage_vault_wallets`: + +```json +{ + "action": "create", + "vault": "checkout", + "key": "agentcard-wallet", + "provider": "agentcard", + "spec": {} +} +``` + +Complete the returned enrollment action. Alternatively, `spec.user_id` may refer +to a user already enrolled in this organization. Once connected, configure a card +with `manage_vault_cards`: + +```json +{ + "action": "create", + "vault": "checkout", + "key": "agentcard-order", + "provider": "agentcard", + "spec": { + "wallet": "agentcard-wallet", + "merchant": "Example Shop", + "amount": 1234, + "currency": "usd" + } +} +``` + +AgentCard uses `merchant`, not Link's `merchant_name`. Optionally inspect wallet +payment methods and provide a returned `card_id`; otherwise the cardholder selects +one at approval. AgentCard currently does not advertise `authorize`: authorization +happens at checkout. Attach the vault to a new browser and use returned aliases. +Observe the card for its checkout authorization and any approval URL for the user. +A reusable card remaining `ready` does not establish that the last payment succeeded. + +## Observation, updates, and safety + +- Single-item responses are JSON text containing `{item, hints, guidance}`. They preserve + public state, non-secret aliases, masks, safe action/approval URLs, advertised + operations/expansions, and payment outcomes. Unknown provider fields, opaque + event data, free-form metadata, and URLs carrying OAuth codes/tokens are omitted. + API errors retain the HTTP status but use curated messages for recognized error + codes. Unknown codes use a generic fallback; upstream error text is never returned. + There is no raw-output or raw-card tool. +- `hints.observation` contains `{tool, arguments}` entries for non-blocking `get` + and `events` calls. `hints.invocation` contains only currently advertised + operations, each with `requires_user_approval: true`. Hints preserve the resolved + project selector (when present), vault, and item key. Pass `tool` as the MCP + call's `name` and `arguments` unchanged. Provider-hosted actions remain separate + in `item.action` and approval URLs; they are not callable operation hints. + **A hint is not user approval or a recommendation to retry a payment.** + Availability can change; `invoke` still fetches the item and rechecks it. +- Vault lists return `{items, has_more, next_offset}`. Item lists return `{items}`. + `get` with `expand: ["payment_methods"]` is equivalent to the wallet + `payment_methods` action. An unavailable expansion returns an API error. +- Only `get` and `events` accept `wait: 0..60`; other actions reject it. + `invoke` does not wait for authorization. Each observation is bounded, + not a background polling loop or readiness guarantee. The SDK timeout is the + wait plus 30 seconds; configure the MCP client's timeout accordingly, or use + shorter waits. Request cancellation is propagated to the SDK. +- `events` accepts `after` and returns `{events, next_after, hints, guidance}`. + Its observation hints include the next events cursor, preserving the input + cursor on an empty result (or omitting `after` when there is no cursor). + Event responses do not include invocation hints because they do not establish + current operation availability. +- **Ready does not mean paid.** Inspect state and immutable events for outcomes. + 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. +- 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 + disables automatic SDK retries. +- Provider-assigned permitted domains are not configurable through these tools. +- Vault/item deletion invalidates the affected credentials. Confirm with the user + first. Any HTTP 404 returns `deleted_or_not_found`, including a missing project; + other errors fail. Non-delete 404s remain errors. +- The existing analytics filter omits tool inputs, outputs, and error messages; + do not add payment payloads or action URLs to application logs. diff --git a/package.json b/package.json index 9a78182b..222b0f40 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.98.0", + "@onkernel/sdk": "^0.100.0", "@posthog/mcp": "0.10.1", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", diff --git a/src/app/[transport]/route.test.ts b/src/app/[transport]/route.test.ts index b4d31344..e902ffe7 100644 --- a/src/app/[transport]/route.test.ts +++ b/src/app/[transport]/route.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { Kernel } from "@onkernel/sdk"; import type { McpConnectionScopeFailureAnalytics } from "@/lib/mcp/analytics"; import { defaultMcpDependencies } from "@/lib/mcp/dependencies"; @@ -137,6 +138,109 @@ describe("connection scope failures through the handler", () => { }); }); +describe("vault entitlement routing", () => { + function installKernelResponses(entitlements: (token: string) => Response) { + const paths: string[] = []; + defaultMcpDependencies.createKernelClient = (token) => + new Kernel({ + apiKey: token, + baseURL: "https://api.example.test", + maxRetries: 0, + fetch: async (input) => { + const path = new URL(String(input)).pathname; + paths.push(path); + if (path === "/auth/context") + return Response.json({ + authentication: { + method: "api_key", + source: "api_key", + credential_id: "key_test", + }, + principal: { type: "api_key", id: "key_test" }, + organization: { id: token === "sk_allowed" ? "org_a" : "org_b" }, + authorization: { + credential_scope: { project_id: null }, + effective_scope: { project_id: null }, + }, + }); + if (path === "/org/entitlements") return entitlements(token); + throw new Error(`Unexpected API request: ${path}`); + }, + }); + return paths; + } + + async function call(method: string, token = "sk_allowed", params?: object) { + const response = await POST( + new nextServer.NextRequest("https://mcp.example.test/mcp", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }), + ); + expect(response.status).toBe(200); + const text = await response.text(); + const event = text.split("\n").find((line) => line.startsWith("data: ")); + return JSON.parse(event ? event.slice(6) : text); + } + + test("selects tools per credential and rechecks access after revocation", async () => { + let enabled = true; + const paths = installKernelResponses((token) => + Response.json({ + features: { vaults: { enabled: token === "sk_allowed" && enabled } }, + }), + ); + const allowed = await call("tools/list"); + expect( + allowed.result.tools.map((tool: { name: string }) => tool.name), + ).toContain("manage_vaults"); + const denied = await call("tools/list", "sk_denied"); + expect( + denied.result.tools.filter((tool: { name: string }) => + tool.name.startsWith("manage_vault"), + ), + ).toHaveLength(0); + expect( + denied.result.tools.map((tool: { name: string }) => tool.name), + ).toContain("manage_browsers"); + enabled = false; + const revoked = await call("tools/call", "sk_allowed", { + name: "manage_vaults", + arguments: { action: "list" }, + }); + expect(JSON.stringify(revoked)).toContain("not found"); + expect(paths).toEqual([ + "/auth/context", + "/org/entitlements", + "/auth/context", + "/org/entitlements", + "/auth/context", + "/org/entitlements", + ]); + }); + + test.each([200, 404, 503])( + "keeps other tools available when entitlements are absent or fail (HTTP %s)", + async (status) => { + installKernelResponses(() => Response.json({ features: {} }, { status })); + const result = await call("tools/list"); + expect( + result.result.tools.filter((tool: { name: string }) => + tool.name.startsWith("manage_vault"), + ), + ).toHaveLength(0); + expect( + result.result.tools.map((tool: { name: string }) => tool.name), + ).toContain("manage_browsers"); + }, + ); +}); + describe("connectionScopeFailureResponse", () => { test("names an inactive project instead of blaming the credential", async () => { const response = connectionScopeFailureResponse({ diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index e32f279e..5bfea6ce 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -24,6 +24,7 @@ import { verifyMcpTransportSession, } from "@/lib/mcp-transport-session"; import { registerMcpCapabilities } from "@/lib/mcp/register"; +import { resolveMcpVaultAccess } from "@/lib/mcp/entitlements"; import { name, version } from "../../../server.json"; export async function OPTIONS(_req: NextRequest): Promise { @@ -105,15 +106,20 @@ export function connectionScopeFailureResponse( // Handler variants keep per-connection capabilities out of tools/list unless // the authenticated connection can use them. const serverInfo = { serverInfo: { name, version } }; -function createHandler({ mcpApps = false }: { mcpApps?: boolean } = {}) { +function createHandler({ + mcpApps = false, + vaults = false, +}: { mcpApps?: boolean; vaults?: boolean } = {}) { return createMcpHandler((server) => { instrumentMcpAnalytics(server); - registerMcpCapabilities(server, { mcpApps }); + registerMcpCapabilities(server, { mcpApps, vaults }); }, serverInfo); } const handler = createHandler(); const mcpAppsHandler = createHandler({ mcpApps: true }); +const vaultsHandler = createHandler({ vaults: true }); +const vaultsMcpAppsHandler = createHandler({ mcpApps: true, vaults: true }); type AuthInfoExtra = { userId: string | null; @@ -165,13 +171,17 @@ async function handleMcpRequestWithIdentity({ } return connectionScopeFailureResponse(connection); } + // Recheck with the current credential on every request, including tools/call. + const vaults = await resolveMcpVaultAccess({ token, signal: req.signal }); const connectionContext = connection.context; const connectionAnalytics = observeConnection && isMcpAnalyticsEnabled() ? connectionAnalyticsFromContext(connectionContext) : null; + const baseHandler = vaults ? vaultsHandler : handler; + const appsHandler = vaults ? vaultsMcpAppsHandler : mcpAppsHandler; const authHandler = withMcpAuth( - mcpApps ? mcpAppsHandler : handler, + mcpApps ? appsHandler : baseHandler, async () => ({ token, scopes, diff --git a/src/lib/mcp/analytics.test.ts b/src/lib/mcp/analytics.test.ts index 5fabe2f8..00e5f837 100644 --- a/src/lib/mcp/analytics.test.ts +++ b/src/lib/mcp/analytics.test.ts @@ -341,6 +341,27 @@ describe("sanitizeMcpAnalyticsEvent", () => { expect(result?.properties[PostHogMCPAnalyticsProperty.IsError]).toBe(false); }); + test("drops vault specs, aliases, provider actions, and error bodies", async () => { + const event = toolCallEvent({ + [PostHogMCPAnalyticsProperty.ToolName]: "manage_vault_cards", + [PostHogMCPAnalyticsProperty.Parameters]: { + spec: { metadata: { order: "private-order" } }, + }, + [PostHogMCPAnalyticsProperty.Response]: { + state: { aliases: { number: "private-alias" } }, + action: { url: "https://provider.example/approve?code=private-code" }, + }, + [PostHogMCPAnalyticsProperty.ErrorMessage]: "private-provider-body", + }); + + const result = await sanitizeMcpAnalyticsEvent(event); + + expect(JSON.stringify(result)).not.toContain("private-"); + expect(result?.properties[PostHogMCPAnalyticsProperty.ToolName]).toBe( + "manage_vault_cards", + ); + }); + test("drops $set so no person properties can flow", async () => { const event = toolCallEvent({ $set: { email: "agent@example.com" } }); diff --git a/src/lib/mcp/entitlements.test.ts b/src/lib/mcp/entitlements.test.ts new file mode 100644 index 00000000..0dd767a2 --- /dev/null +++ b/src/lib/mcp/entitlements.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { Kernel } from "@onkernel/sdk"; +import { resolveMcpVaultAccess } from "@/lib/mcp/entitlements"; + +function fixture(body: unknown, status = 200) { + const requests: Request[] = []; + const dependencies = { + createKernelClient: (token: string) => + new Kernel({ + apiKey: token, + project: "proj_pinned", + baseURL: "https://api.example.test", + fetch: async (input, init) => { + requests.push(new Request(input, init)); + return Response.json(body, { status }); + }, + }), + }; + return { requests, dependencies }; +} + +describe("MCP vault entitlement", () => { + test.each([ + { body: { features: { vaults: { enabled: true } } }, enabled: true }, + { body: { features: { vaults: { enabled: false } } }, enabled: false }, + { body: { features: {} }, enabled: false }, + { body: {}, enabled: false }, + { body: null, enabled: false }, + { body: { features: { vaults: null } }, enabled: false }, + { body: { features: { vaults: { enabled: "true" } } }, enabled: false }, + { body: { features: { vaults: { enabled: 1 } } }, enabled: false }, + { body: { features: { vaults: { enabled: null } } }, enabled: false }, + ])("requires an explicit boolean entitlement", async ({ body, enabled }) => { + const { requests, dependencies } = fixture(body); + expect( + await resolveMcpVaultAccess({ token: "sk_project_key", dependencies }), + ).toBe(enabled); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("GET"); + expect(new URL(requests[0].url).pathname).toBe("/org/entitlements"); + expect(requests[0].headers.get("Authorization")).toBe( + "Bearer sk_project_key", + ); + expect(requests[0].headers.get("X-Kernel-Project")).toBe("proj_pinned"); + }); + + test.each([401, 403, 404, 429, 500, 503])( + "fails closed without retrying HTTP %s", + async (status) => { + const { requests, dependencies } = fixture( + { message: "hidden-provider-secret" }, + status, + ); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + expect( + await resolveMcpVaultAccess({ token: "sk_secret", dependencies }), + ).toBe(false); + expect(requests).toHaveLength(1); + expect(JSON.stringify(warn.mock.calls)).not.toContain( + "hidden-provider-secret", + ); + } finally { + warn.mockRestore(); + } + }, + ); + + test("bounds the lookup and forwards cancellation", async () => { + const { dependencies } = fixture({ + features: { vaults: { enabled: true } }, + }); + const client = dependencies.createKernelClient("sk_key"); + const retrieve = spyOn(client.organization.entitlements, "retrieve"); + const controller = new AbortController(); + try { + expect( + await resolveMcpVaultAccess({ + token: "sk_key", + signal: controller.signal, + dependencies: { createKernelClient: () => client }, + }), + ).toBe(true); + expect(retrieve).toHaveBeenCalledWith({ + signal: controller.signal, + maxRetries: 0, + timeout: 5_000, + }); + controller.abort(); + expect( + await resolveMcpVaultAccess({ + token: "sk_key", + signal: controller.signal, + dependencies: { createKernelClient: () => client }, + }), + ).toBe(false); + } finally { + retrieve.mockRestore(); + } + }); + + test("does not reuse access across credentials or after revocation", async () => { + let enabled = true; + const tokens: string[] = []; + const dependencies = { + createKernelClient: (token: string) => { + tokens.push(token); + return fixture({ + features: { vaults: { enabled: token === "org_a" && enabled } }, + }).dependencies.createKernelClient(token); + }, + }; + expect(await resolveMcpVaultAccess({ token: "org_a", dependencies })).toBe( + true, + ); + expect(await resolveMcpVaultAccess({ token: "org_b", dependencies })).toBe( + false, + ); + enabled = false; + expect(await resolveMcpVaultAccess({ token: "org_a", dependencies })).toBe( + false, + ); + expect(tokens).toEqual(["org_a", "org_b", "org_a"]); + }); +}); diff --git a/src/lib/mcp/entitlements.ts b/src/lib/mcp/entitlements.ts new file mode 100644 index 00000000..d56bebe5 --- /dev/null +++ b/src/lib/mcp/entitlements.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import { + defaultMcpDependencies, + type McpDependencies, +} from "@/lib/mcp/dependencies"; + +const vaultEntitlementSchema = z.object({ + features: z.object({ + vaults: z.object({ enabled: z.boolean() }), + }), +}); + +export async function resolveMcpVaultAccess({ + token, + signal, + dependencies = defaultMcpDependencies, +}: { + token: string; + signal?: AbortSignal; + dependencies?: Pick; +}): Promise { + try { + const entitlements = await dependencies + .createKernelClient(token) + .organization.entitlements.retrieve({ + signal, + maxRetries: 0, + timeout: 5_000, + }); + // Older APIs may not advertise vaults yet. Only explicit access enables tools. + const parsed = vaultEntitlementSchema.safeParse(entitlements); + return parsed.success && parsed.data.features.vaults.enabled; + } catch { + // Do not expose upstream error bodies or interrupt unrelated toolsets. + console.warn( + "Unable to resolve MCP vault entitlement; vault tools disabled", + ); + return false; + } +} diff --git a/src/lib/mcp/register.test.ts b/src/lib/mcp/register.test.ts index b0c628c6..e5d2c6f8 100644 --- a/src/lib/mcp/register.test.ts +++ b/src/lib/mcp/register.test.ts @@ -20,9 +20,10 @@ const NON_AUTH_TOOLSETS = [ "replays", "credentials", "credential_providers", + "vaults", ].join(","); -function captureRegistration(mcpApps: boolean) { +function captureRegistration(mcpApps: boolean, vaults = false) { const legacyTools: string[] = []; const appTools: string[] = []; const resources: string[] = []; @@ -47,7 +48,7 @@ function captureRegistration(mcpApps: boolean) { return { enable() {}, disable() {} }; }, } as unknown as McpServer; - registerMcpCapabilities(server, { mcpApps }); + registerMcpCapabilities(server, { mcpApps, vaults }); return { legacyTools, appTools, resources, schemas }; } @@ -85,6 +86,39 @@ describe("MCP Apps additive registration", () => { }); describe("MCP toolset allowlist", () => { + test.each([false, true])( + "requires vault access even with an allowlist (MCP Apps: %s)", + (mcpApps) => { + const previousEnabled = process.env.KERNEL_MCP_ENABLED_TOOLSETS; + const previousDisabled = process.env.KERNEL_MCP_DISABLED_TOOLSETS; + process.env.KERNEL_MCP_ENABLED_TOOLSETS = "vaults"; + delete process.env.KERNEL_MCP_DISABLED_TOOLSETS; + try { + expect(captureRegistration(mcpApps).legacyTools).toEqual([ + "get_connection_context", + ]); + expect(captureRegistration(mcpApps, true).legacyTools).toEqual([ + "get_connection_context", + "manage_vault_wallets", + "manage_vault_cards", + "manage_vault_items", + "manage_vaults", + ]); + process.env.KERNEL_MCP_DISABLED_TOOLSETS = "vaults"; + expect(captureRegistration(mcpApps, true).legacyTools).toEqual([ + "get_connection_context", + ]); + } finally { + if (previousEnabled === undefined) + delete process.env.KERNEL_MCP_ENABLED_TOOLSETS; + else process.env.KERNEL_MCP_ENABLED_TOOLSETS = previousEnabled; + if (previousDisabled === undefined) + delete process.env.KERNEL_MCP_DISABLED_TOOLSETS; + else process.env.KERNEL_MCP_DISABLED_TOOLSETS = previousDisabled; + } + }, + ); + test("keeps connection context and only the selected browser controls", () => { const previousEnabled = process.env.KERNEL_MCP_ENABLED_TOOLSETS; const previousDisabled = process.env.KERNEL_MCP_DISABLED_TOOLSETS; @@ -130,12 +164,16 @@ describe("project selection registration", () => { "manage_replays", "manage_auth_connections", "manage_credentials", + "manage_vaults", + "manage_vault_wallets", + "manage_vault_cards", + "manage_vault_items", "open_auth_login", "begin_auth_login", ]; test("advertises one stable project-aware tool contract", () => { - const registration = captureRegistration(true); + const registration = captureRegistration(true, true); for (const name of projectScopedTools) { expect(registration.schemas.get(name)).toHaveProperty("project"); diff --git a/src/lib/mcp/register.ts b/src/lib/mcp/register.ts index 2f25b8ca..bc967126 100644 --- a/src/lib/mcp/register.ts +++ b/src/lib/mcp/register.ts @@ -24,9 +24,11 @@ import { registerProxyTools } from "@/lib/mcp/tools/proxies"; import { registerReplayTools } from "@/lib/mcp/tools/replays"; import { registerShellTool } from "@/lib/mcp/tools/shell"; import { registerWebMcpTool } from "@/lib/mcp/tools/webmcp"; +import { registerVaultCapabilities } from "@/lib/mcp/tools/vaults"; type McpToolOptions = McpDependencies; type McpRegistrationOptions = { mcpApps?: boolean; + vaults?: boolean; dependencies?: McpDependencies; }; type RegisterMcpToolset = (server: McpServer, options: McpToolOptions) => void; @@ -54,6 +56,7 @@ const mcpToolRegistrations = [ ["auth_connections", registerManagedAuthCapabilities], ["credentials", registerCredentialTools], ["credential_providers", registerCredentialProviderTools], + ["vaults", registerVaultCapabilities], ] as const satisfies readonly (readonly [string, RegisterMcpToolset])[]; type McpToolset = (typeof mcpToolRegistrations)[number][0]; @@ -169,6 +172,7 @@ export function registerMcpCapabilities( server: McpServer, { mcpApps = false, + vaults = false, dependencies = defaultMcpDependencies, }: McpRegistrationOptions = {}, ) { @@ -181,7 +185,10 @@ export function registerMcpCapabilities( registerConnectionContextTool(server); for (const [toolset, registerToolset] of mcpToolRegistrations) { - if (toolsetEnabled(enabledToolsets, disabledToolsets, toolset)) { + if ( + (toolset !== "vaults" || vaults) && + toolsetEnabled(enabledToolsets, disabledToolsets, toolset) + ) { registerToolset(server, dependencies); } } diff --git a/src/lib/mcp/tools/browser-vaults.test.ts b/src/lib/mcp/tools/browser-vaults.test.ts new file mode 100644 index 00000000..030bad28 --- /dev/null +++ b/src/lib/mcp/tools/browser-vaults.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from "bun:test"; +import { Kernel } from "@onkernel/sdk"; +import { connectTestMcp } from "@/lib/mcp/mcp-test-fixtures"; +import { registerBrowserCapabilities } from "@/lib/mcp/tools/browsers"; + +describe("browser vault attachment", () => { + test("advertises inline vault references without JSON Schema refs", async () => { + const fixture = await connectTestMcp(registerBrowserCapabilities, {}); + try { + const { tools } = await fixture.client.listTools(); + const browser = tools.find((tool) => tool.name === "manage_browsers"); + expect(browser?.inputSchema.properties).toHaveProperty("vaults"); + expect(JSON.stringify(browser?.inputSchema)).not.toContain('"$ref"'); + } finally { + await fixture.close(); + } + }); + + test("forwards creation-only references by ID and name", async () => { + const requests: Array<{ body: unknown; options: unknown }> = []; + const fixture = await connectTestMcp(registerBrowserCapabilities, { + browsers: { + create: async (body: unknown, options: unknown) => { + requests.push({ body, options }); + return { session_id: "brr_123" }; + }, + }, + }); + const vaults = [{ id: "vlt_123" }, { name: "checkout" }]; + try { + const result = await fixture.client.callTool({ + name: "manage_browsers", + arguments: { action: "create", vaults, headless: false }, + }); + expect(result.isError).toBeUndefined(); + expect(requests).toHaveLength(1); + expect(requests[0].body).toEqual({ headless: false, vaults }); + expect(requests[0].options).toMatchObject({ maxRetries: 0 }); + } finally { + await fixture.close(); + } + }); + + test.each( + [ + [{}], + [{ id: "vlt_123", name: "checkout" }], + [{ name: "checkout" }, { name: "checkout" }], + [{ name: "../other" }], + [{ id: "" }], + [{ name: "checkout", secret: "hidden" }], + Array.from({ length: 21 }, (_, index) => ({ name: `vault-${index}` })), + ].map((vaults) => ({ vaults })), + )( + "rejects invalid vault references without creating a browser", + async ({ vaults }) => { + let creates = 0; + const fixture = await connectTestMcp(registerBrowserCapabilities, { + browsers: { + create: async () => { + creates++; + return { session_id: "brr_123" }; + }, + }, + }); + try { + const result = await fixture.client.callTool({ + name: "manage_browsers", + arguments: { action: "create", vaults }, + }); + expect(result.isError).toBe(true); + expect(creates).toBe(0); + } finally { + await fixture.close(); + } + }, + ); + + test("accepts the 20-reference boundary and rejects mutation of existing bindings", async () => { + let creates = 0; + let updates = 0; + const fixture = await connectTestMcp(registerBrowserCapabilities, { + browsers: { + create: async () => { + creates++; + return { session_id: "brr_123" }; + }, + update: async () => { + updates++; + return { session_id: "brr_123" }; + }, + }, + }); + try { + const result = await fixture.client.callTool({ + name: "manage_browsers", + arguments: { + action: "create", + vaults: Array.from({ length: 20 }, (_, index) => ({ + name: `vault-${index}`, + })), + }, + }); + expect(result.isError).toBeUndefined(); + const update = await fixture.client.callTool({ + name: "manage_browsers", + arguments: { + action: "update", + session_id: "brr_123", + vaults: [{ name: "checkout" }], + }, + }); + expect(update.isError).toBe(true); + expect(JSON.stringify(update)).toContain("creation-only"); + expect(creates).toBe(1); + expect(updates).toBe(0); + } finally { + await fixture.close(); + } + }); + + test("does not retry a failed vault-bound browser creation", async () => { + let calls = 0; + const sdk = new Kernel({ + apiKey: "test-key", + baseURL: "https://api.example", + fetch: async () => { + calls++; + return Response.json({ message: "Unavailable" }, { status: 503 }); + }, + }); + const fixture = await connectTestMcp(registerBrowserCapabilities, sdk); + try { + const result = await fixture.client.callTool({ + name: "manage_browsers", + arguments: { action: "create", vaults: [{ name: "checkout" }] }, + }); + expect(result.isError).toBe(true); + expect(calls).toBe(1); + } finally { + await fixture.close(); + } + }); +}); diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index bea79ecd..3c40a69a 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -23,6 +23,7 @@ import { throwToolError, } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; +import { browserVaultsSchema } from "@/lib/mcp/vault-schemas"; import { projectForOperation, projectSelectionInputSchema, @@ -470,6 +471,7 @@ export function registerBrowserCapabilities( "(create) URL to open when the browser is created. Navigation is best-effort.", ) .optional(), + vaults: browserVaultsSchema, chrome_policy: z .record(z.string(), z.unknown()) .describe( @@ -676,9 +678,16 @@ export function registerBrowserCapabilities( ); try { + if (params.vaults !== undefined && params.action !== "create") { + return errorResponse( + "Vault bindings are creation-only; they cannot be added to an existing browser.", + ); + } switch (params.action) { case "create": { const createParams: BrowserCreateParams = {}; + if (params.vaults !== undefined) + createParams.vaults = params.vaults; if (params.headless !== undefined) createParams.headless = params.headless; if (params.gpu !== undefined) createParams.gpu = params.gpu; @@ -707,7 +716,12 @@ export function registerBrowserCapabilities( if (telemetry.value !== undefined) createParams.telemetry = telemetry.value; - const browser = await client.browsers.create(createParams); + const browser = await client.browsers.create( + createParams, + params.vaults?.length + ? { maxRetries: 0, signal: extra.signal } + : undefined, + ); if (!browser) return errorResponse("Failed to create browser session"); diff --git a/src/lib/mcp/tools/vault-cards.ts b/src/lib/mcp/tools/vault-cards.ts new file mode 100644 index 00000000..7a29c129 --- /dev/null +++ b/src/lib/mcp/tools/vault-cards.ts @@ -0,0 +1,77 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { McpDependencies } from "@/lib/mcp/dependencies"; +import { projectForOperation } from "@/lib/mcp/project-selection"; +import { throwVaultError, vaultItemResponse } from "@/lib/mcp/vault-responses"; +import { + agentcardCardSpecSchema, + linkCardSpecSchema, + vaultItemSchema, + vaultKeySchema, + vaultProviderSchema, +} from "@/lib/mcp/vault-schemas"; + +export function registerVaultCardTools( + server: McpServer, + dependencies: McpDependencies, +) { + 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.', + { + ...vaultItemSchema, + key: vaultKeySchema(), + action: z.enum(["create", "update"]), + provider: vaultProviderSchema, + spec: z + .union([linkCardSpecSchema, agentcardCardSpecSchema]) + .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, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + async (params, extra) => { + if (!extra.authInfo) throw new Error("Authentication required"); + const project = projectForOperation(extra.authInfo, params); + const target = { project, vault: params.vault, key: params.key }; + const client = dependencies.createKernelClient( + extra.authInfo.token, + project, + ); + const options = { maxRetries: 0, signal: extra.signal }; + try { + const spec = + params.provider === "link" + ? { + ...linkCardSpecSchema.parse(params.spec), + provider: params.provider, + } + : { + ...agentcardCardSpecSchema.parse(params.spec), + provider: params.provider, + }; + const item = + params.action === "create" + ? await client.vaults.items.upsert( + params.key, + { id_or_name: params.vault, type: "card", spec }, + options, + ) + : await client.vaults.items.update( + params.key, + { id_or_name: params.vault, spec }, + options, + ); + return vaultItemResponse(item, target); + } catch (error) { + throwVaultError("manage_vault_cards", params.action, error); + } + }, + ); +} diff --git a/src/lib/mcp/tools/vault-items.test.ts b/src/lib/mcp/tools/vault-items.test.ts new file mode 100644 index 00000000..5f8e6ac4 --- /dev/null +++ b/src/lib/mcp/tools/vault-items.test.ts @@ -0,0 +1,411 @@ +import { APIConnectionTimeoutError } from "@onkernel/sdk"; +import { describe, expect, test } from "bun:test"; +import { connectTestMcp, toolResultJSON } from "@/lib/mcp/mcp-test-fixtures"; +import { registerVaultCapabilities } from "@/lib/mcp/tools/vaults"; +import { connectVaultTest, item } from "./vaults.test-fixtures"; + +describe("advertised vault operations", () => { + test.each([ + { + type: "card", + provider: "link", + status: "requested", + operation: "authorize", + }, + { + type: "wallet", + provider: "agentcard", + status: "connected", + operation: "future_operation", + }, + { + type: "card", + provider: "agentcard", + status: "ready", + operation: "authorize", + }, + ])( + "uses API-advertised availability for $provider/$type/$status", + async ({ type, provider, status, operation }) => { + const before = { + ...item, + type, + spec: { provider }, + state: { provider, status }, + available_operations: [ + { type: operation, description: "Require user approval." }, + ], + }; + const after = { + ...before, + available_operations: [], + action: { + name: "spend_approval", + url: "https://provider.example/approve", + }, + }; + const fixture = await connectVaultTest([ + Response.json(before), + Response.json(after), + ]); + try { + const result = await fixture.call("manage_vault_items", { + action: "invoke", + vault: "checkout", + key: "order-1", + operation, + }); + expect(toolResultJSON(result).item).toEqual(after); + expect( + fixture.requests.map(({ method, path, body }) => ({ + method, + path, + body, + })), + ).toEqual([ + { + method: "GET", + path: "/vaults/checkout/items/order-1", + body: undefined, + }, + { + method: "POST", + path: "/vaults/checkout/items/order-1/operations", + body: { type: operation }, + }, + ]); + } finally { + await fixture.close(); + } + }, + ); + + test("re-fetches availability rather than trusting an earlier get", async () => { + const fixture = await connectVaultTest([ + Response.json(item), + Response.json({ ...item, available_operations: [] }), + ]); + try { + await fixture.call("manage_vault_items", { + action: "get", + vault: "checkout", + key: "order-1", + }); + const result = await fixture.call("manage_vault_items", { + action: "invoke", + vault: "checkout", + key: "order-1", + operation: "authorize", + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toContain("not advertised"); + expect(fixture.requests.map((request) => request.method)).toEqual([ + "GET", + "GET", + ]); + } finally { + await fixture.close(); + } + }); + + test.each(["get", "post"])( + "does not retry an operation's failed %s", + async (stage) => { + const failure = Response.json( + { + code: "provider_error", + message: "Provider unavailable", + opaque: "hidden", + }, + { status: 503 }, + ); + const fixture = await connectVaultTest( + stage === "get" ? [failure] : [Response.json(item), failure], + ); + try { + const result = await fixture.call("manage_vault_items", { + action: "invoke", + vault: "checkout", + key: "order-1", + operation: "authorize", + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toContain("provider_error"); + expect(JSON.stringify(result)).not.toContain("hidden"); + expect(fixture.requests).toHaveLength(stage === "get" ? 1 : 2); + } finally { + await fixture.close(); + } + }, + ); +}); + +describe("vault observation and deletion", () => { + test("advertises wait as get/events-only", async () => { + const fixture = await connectVaultTest([]); + try { + const { tools } = await fixture.client.listTools(); + const tool = tools.find((tool) => tool.name === "manage_vault_items"); + expect(tool?.inputSchema.properties?.wait).toMatchObject({ + description: expect.stringContaining("(get, events)"), + minimum: 0, + maximum: 60, + }); + } finally { + await fixture.close(); + } + }); + + test.each(["list", "invoke", "delete"])( + "rejects wait on %s without making a request", + async (action) => { + const fixture = await connectVaultTest([]); + try { + for (const wait of [0, 60]) { + const result = await fixture.call("manage_vault_items", { + action, + vault: "checkout", + key: "order-1", + operation: "authorize", + wait, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toContain( + "wait is only supported for get and events", + ); + } + expect(fixture.requests).toHaveLength(0); + } finally { + await fixture.close(); + } + }, + ); + + test("returns pending state without polling, and preserves the event cursor on an empty wait", async () => { + const event = { + id: "evt_2", + name: "checkout.outcome", + created_at: "2026-01-01T00:00:00Z", + data: { outcome_reason: "indeterminate" }, + }; + const pending = { + ...item, + state: { provider: "link", status: "pending_authorization" }, + }; + const fixture = await connectVaultTest([ + Response.json(pending), + Response.json([event]), + Response.json([]), + ]); + try { + expect( + toolResultJSON( + await fixture.call("manage_vault_items", { + action: "get", + vault: "checkout", + key: "order-1", + wait: 60, + }), + ).item.state.status, + ).toBe("pending_authorization"); + const first = toolResultJSON( + await fixture.call("manage_vault_items", { + action: "events", + vault: "checkout", + key: "order-1", + after: "evt_1", + wait: 60, + }), + ); + const empty = toolResultJSON( + await fixture.call("manage_vault_items", { + action: "events", + vault: "checkout", + key: "order-1", + after: first.next_after, + wait: 60, + }), + ); + expect(first).toMatchObject({ events: [event], next_after: "evt_2" }); + expect(empty).toMatchObject({ events: [], next_after: "evt_2" }); + expect(fixture.requests).toHaveLength(3); + expect(fixture.requests[0].path).toBe( + "/vaults/checkout/items/order-1?wait=60", + ); + for (const [index, after] of [ + [1, "evt_1"], + [2, "evt_2"], + ] as const) { + const url = new URL( + fixture.requests[index].path, + "https://api.example", + ); + expect(url.searchParams.get("after")).toBe(after); + expect(url.searchParams.get("wait")).toBe("60"); + } + } finally { + await fixture.close(); + } + }); + + test("passes bounded timeout headroom, disables retries, and propagates cancellation", async () => { + const options: Array<{ + timeout: number; + maxRetries: number; + signal: AbortSignal; + }> = []; + const fixture = await connectTestMcp(registerVaultCapabilities, { + vaults: { + items: { + retrieve: async ( + _key: string, + _params: unknown, + requestOptions: (typeof options)[number], + ) => { + options.push(requestOptions); + return item; + }, + events: async ( + _key: string, + _params: unknown, + requestOptions: (typeof options)[number], + ) => { + options.push(requestOptions); + return []; + }, + }, + }, + }); + try { + for (const action of ["get", "events"]) { + await fixture.client.callTool({ + name: "manage_vault_items", + arguments: { action, vault: "checkout", key: "order-1", wait: 60 }, + }); + } + expect(options).toHaveLength(2); + for (const request of options) { + expect(request.timeout).toBe(90000); + expect(request.maxRetries).toBe(0); + expect(request.signal).toBeInstanceOf(AbortSignal); + } + } finally { + await fixture.close(); + } + }); + + test("reports a timeout once without invoking the operation", async () => { + let gets = 0; + const fixture = await connectTestMcp(registerVaultCapabilities, { + vaults: { + items: { + retrieve: async () => { + gets++; + throw new APIConnectionTimeoutError(); + }, + }, + }, + }); + try { + const result = await fixture.client.callTool({ + name: "manage_vault_items", + arguments: { + action: "invoke", + vault: "checkout", + key: "order-1", + operation: "authorize", + }, + }); + expect(result.isError).toBe(true); + expect(gets).toBe(1); + } finally { + await fixture.close(); + } + }); + + test.each([204, 404, 403, 500])( + "handles vault and item deletion HTTP %s", + async (status) => { + for (const name of ["manage_vaults", "manage_vault_items"]) { + const response = + status === 204 + ? new Response(null, { status }) + : Response.json( + { code: "fixture_error", message: "Request rejected" }, + { status }, + ); + const fixture = await connectVaultTest([response]); + try { + const result = await fixture.call(name, { + action: "delete", + vault: "checkout", + ...(name === "manage_vault_items" && { key: "order-1" }), + }); + if (status === 204 || status === 404) + expect(toolResultJSON(result).status).toBe("deleted_or_not_found"); + else expect(result.isError).toBe(true); + expect(fixture.requests).toHaveLength(1); + expect(fixture.requests[0].method).toBe("DELETE"); + } finally { + await fixture.close(); + } + } + }, + ); + + test.each([ + ["manage_vaults", { action: "create", name: "checkout" }], + ["manage_vaults", { action: "list" }], + [ + "manage_vault_items", + { action: "events", vault: "checkout", key: "order-1" }, + ], + [ + "manage_vault_wallets", + { + action: "create", + vault: "checkout", + key: "wallet-1", + provider: "agentcard", + spec: {}, + }, + ], + [ + "manage_vault_wallets", + { action: "payment_methods", vault: "checkout", key: "wallet-1" }, + ], + [ + "manage_vault_cards", + { + action: "update", + vault: "checkout", + key: "order-1", + provider: "agentcard", + spec: { + wallet: "wallet-1", + amount: 100, + merchant: "Example", + currency: "usd", + }, + }, + ], + ] as const)( + "does not retry a rate-limited %s request", + async (name, args) => { + const fixture = await connectVaultTest([ + Response.json( + { code: "spend_request_rate_limited", message: "Stop and back off" }, + { status: 429, headers: { "retry-after-ms": "1" } }, + ), + ]); + try { + const result = await fixture.call(name, args); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toContain("spend_request_rate_limited"); + expect(fixture.requests).toHaveLength(1); + } finally { + await fixture.close(); + } + }, + ); +}); diff --git a/src/lib/mcp/tools/vault-items.ts b/src/lib/mcp/tools/vault-items.ts new file mode 100644 index 00000000..ea3973c9 --- /dev/null +++ b/src/lib/mcp/tools/vault-items.ts @@ -0,0 +1,188 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { APIError } from "@onkernel/sdk"; +import { z } from "zod"; +import type { McpDependencies } from "@/lib/mcp/dependencies"; +import { projectForOperation } from "@/lib/mcp/project-selection"; +import { longOperationOptions } from "@/lib/mcp/request-options"; +import { errorResponse, jsonResponse } from "@/lib/mcp/responses"; +import { + projectVaultOutput, + throwVaultError, + vaultEventFields, + vaultItemFields, + vaultItemResponse, + vaultObservationHints, +} from "@/lib/mcp/vault-responses"; +import { + vaultItemSchema, + vaultKeySchema, + vaultWaitSchema, +} from "@/lib/mcp/vault-schemas"; + +export function registerVaultItemTools( + server: McpServer, + dependencies: McpDependencies, +) { + 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.', + { + ...vaultItemSchema, + action: z.enum(["list", "get", "invoke", "events", "delete"]), + key: vaultKeySchema() + .describe("Required except for list. Immutable item key, not ID.") + .optional(), + operation: z + .string() + .min(1) + .refine((value) => value.trim().length > 0) + .describe( + '(invoke) Type advertised in available_operations. The current API accepts "authorize", with no extra operation parameters. Availability is API-controlled, not inferred from provider or state.', + ) + .optional(), + expand: z + .array(z.enum(["payment_methods"])) + .describe( + "(get) Advertised live expansion. An unavailable expansion returns an API error, not a partial item.", + ) + .optional(), + wait: vaultWaitSchema, + after: z + .string() + .min(1) + .describe( + "(events) Return events after this event ID; preserve the vault and item key.", + ) + .optional(), + }, + { + title: "Inspect and operate Kernel vault items", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + async (params, extra) => { + if (!extra.authInfo) throw new Error("Authentication required"); + const project = projectForOperation(extra.authInfo, params); + const client = dependencies.createKernelClient( + extra.authInfo.token, + project, + ); + const options = { maxRetries: 0, signal: extra.signal }; + try { + if ( + params.wait !== undefined && + params.action !== "get" && + params.action !== "events" + ) { + return errorResponse( + "wait is only supported for get and events; invoke does not wait for authorization.", + ); + } + if (params.action === "list") { + const items = await client.vaults.items.list(params.vault, options); + return jsonResponse({ + items: projectVaultOutput(items, vaultItemFields), + }); + } + if (!params.key) + return errorResponse("key is required except for list."); + const target = { project, vault: params.vault, key: params.key }; + switch (params.action) { + case "get": { + const item = await client.vaults.items.retrieve( + params.key, + { + id_or_name: params.vault, + ...(params.wait !== undefined && { wait: params.wait }), + ...(params.expand !== undefined && { expand: params.expand }), + }, + { + ...longOperationOptions(params.wait ?? 0), + signal: extra.signal, + }, + ); + return vaultItemResponse(item, target); + } + case "invoke": { + if (!params.operation) + return errorResponse("operation is required for invoke."); + const item = await client.vaults.items.retrieve( + params.key, + { id_or_name: params.vault }, + options, + ); + const operation = item.available_operations.find( + (op) => op.type === params.operation, + ); + if (!operation) + return errorResponse( + "Operation is not advertised in available_operations. Inspect the item before taking further action.", + ); + const updated = await client.vaults.items.performOperation( + params.key, + { + id_or_name: params.vault, + type: operation.type, + }, + options, + ); + return vaultItemResponse(updated, target); + } + case "events": { + const events = await client.vaults.items.events( + params.key, + { + id_or_name: params.vault, + ...(params.wait !== undefined && { wait: params.wait }), + ...(params.after !== undefined && { after: params.after }), + }, + { + ...longOperationOptions(params.wait ?? 0), + signal: extra.signal, + }, + ); + const lastEventID = events.at(-1)?.id; + if (lastEventID !== undefined && typeof lastEventID !== "string") { + throw new Error("Invalid vault event cursor"); + } + const nextAfter = lastEventID ?? params.after; + return jsonResponse({ + events: projectVaultOutput(events, vaultEventFields), + next_after: nextAfter ?? null, + hints: { observation: vaultObservationHints(target, nextAfter) }, + guidance: + "Observing events never retries a payment. Do not retry failed, timed-out, rejected, or indeterminate payments.", + }); + } + case "delete": { + await client.vaults.items.delete( + params.key, + { id_or_name: params.vault }, + options, + ); + return jsonResponse({ + status: "deleted_or_not_found", + vault: params.vault, + key: params.key, + }); + } + } + } catch (error) { + if ( + params.action === "delete" && + error instanceof APIError && + error.status === 404 + ) { + return jsonResponse({ + status: "deleted_or_not_found", + vault: params.vault, + key: params.key, + }); + } + throwVaultError("manage_vault_items", params.action, error); + } + }, + ); +} diff --git a/src/lib/mcp/tools/vault-wallets.ts b/src/lib/mcp/tools/vault-wallets.ts new file mode 100644 index 00000000..09f8bb5d --- /dev/null +++ b/src/lib/mcp/tools/vault-wallets.ts @@ -0,0 +1,98 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { McpDependencies } from "@/lib/mcp/dependencies"; +import { projectForOperation } from "@/lib/mcp/project-selection"; +import { longOperationOptions } from "@/lib/mcp/request-options"; +import { errorResponse } from "@/lib/mcp/responses"; +import { throwVaultError, vaultItemResponse } from "@/lib/mcp/vault-responses"; +import { + agentcardWalletSpecSchema, + linkWalletSpecSchema, + vaultItemSchema, + vaultKeySchema, + vaultProviderSchema, +} from "@/lib/mcp/vault-schemas"; + +export function registerVaultWalletTools( + server: McpServer, + dependencies: McpDependencies, +) { + 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.', + { + ...vaultItemSchema, + key: vaultKeySchema(), + action: z.enum(["create", "payment_methods"]), + provider: vaultProviderSchema + .describe("(create) Payment provider.") + .optional(), + 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.', + ) + .optional(), + }, + { + title: "Manage Kernel vault wallets", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + async (params, extra) => { + if (!extra.authInfo) throw new Error("Authentication required"); + const project = projectForOperation(extra.authInfo, params); + const target = { project, vault: params.vault, key: params.key }; + const client = dependencies.createKernelClient( + extra.authInfo.token, + project, + ); + const options = { maxRetries: 0, signal: extra.signal }; + try { + switch (params.action) { + case "create": { + if (!params.provider || !params.spec) + return errorResponse( + "provider and spec are required for create.", + ); + const spec = + params.provider === "link" + ? { + ...linkWalletSpecSchema.parse(params.spec), + provider: params.provider, + } + : { + ...agentcardWalletSpecSchema.parse(params.spec), + provider: params.provider, + }; + const item = await client.vaults.items.upsert( + params.key, + { + id_or_name: params.vault, + type: "wallet", + spec, + }, + options, + ); + return vaultItemResponse(item, target); + } + case "payment_methods": { + const item = await client.vaults.items.retrieve( + params.key, + { + id_or_name: params.vault, + expand: ["payment_methods"], + }, + { ...longOperationOptions(0), signal: extra.signal }, + ); + return vaultItemResponse(item, target); + } + } + } catch (error) { + throwVaultError("manage_vault_wallets", params.action, error); + } + }, + ); +} diff --git a/src/lib/mcp/tools/vaults.test-fixtures.ts b/src/lib/mcp/tools/vaults.test-fixtures.ts new file mode 100644 index 00000000..606fc7e8 --- /dev/null +++ b/src/lib/mcp/tools/vaults.test-fixtures.ts @@ -0,0 +1,102 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Kernel } from "@onkernel/sdk"; +import { projectScopedAuthInfo } from "@/lib/mcp/auth-context.test-fixtures"; +import { registerVaultCapabilities } from "@/lib/mcp/tools/vaults"; + +export const vault = { + id: "vlt_123", + name: "checkout", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; +export const item = { + id: "vi_123", + key: "order-1", + type: "card", + spec: { provider: "link", wallet: "wallet-1" }, + state: { provider: "link", status: "requested" }, + available_operations: [ + { + type: "authorize", + description: "Obtain explicit user approval before authorizing.", + }, + ], + available_expansions: [], +}; +export const linkSpec = { + wallet: "wallet-1", + payment_method_id: "pm_example", + amount: 1234, + currency: "USD", + merchant_name: "Example Shop", + merchant_url: "https://shop.example", + context: + "Purchase the selected office supplies from Example Shop for the approved order, with a total spending limit of 1234 minor currency units.", +}; +export const agentcardSpec = { + wallet: "wallet-1", + merchant: "Example Shop", + amount: 1234, + currency: "USD", +}; + +type RequestRecord = { + method: string; + path: string; + headers: Headers; + body?: unknown; +}; + +export async function connectVaultTest( + replies: Response[], + authInfo: AuthInfo | null = projectScopedAuthInfo(), +) { + const requests: RequestRecord[] = []; + const server = new McpServer({ name: "vault-test", version: "0.0.0" }); + registerVaultCapabilities(server, { + createKernelClient: (token, project) => + new Kernel({ + apiKey: token, + project, + baseURL: "https://api.example", + fetch: async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + const text = await request.text(); + requests.push({ + method: request.method, + path: url.pathname + url.search, + headers: request.headers, + ...(text && { body: JSON.parse(text) }), + }); + return ( + replies.shift() ?? + Response.json( + { message: "Unexpected extra request" }, + { status: 500 }, + ) + ); + }, + }), + }); + const client = new Client({ name: "vault-test-client", version: "0.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const send = clientTransport.send.bind(clientTransport); + clientTransport.send = (message, options) => + send(message, { ...options, authInfo: authInfo ?? undefined }); + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport), + ]); + return { + client, + requests, + call: (name: string, args: Record) => + client.callTool({ name, arguments: args }), + close: () => Promise.all([client.close(), server.close()]), + }; +} diff --git a/src/lib/mcp/tools/vaults.test.ts b/src/lib/mcp/tools/vaults.test.ts new file mode 100644 index 00000000..5f3a77eb --- /dev/null +++ b/src/lib/mcp/tools/vaults.test.ts @@ -0,0 +1,474 @@ +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 { + agentcardSpec, + connectVaultTest, + item, + linkSpec, + vault, +} from "./vaults.test-fixtures"; + +describe("vault SDK request contracts", () => { + test("advertises all four project-aware tools with 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_wallets", + "manage_vaults", + ]); + for (const tool of tools) { + expect(tool.inputSchema.properties).toHaveProperty("project"); + expect(JSON.stringify(tool.inputSchema)).not.toContain('"$ref"'); + expect(tool.annotations).toMatchObject({ + readOnlyHint: false, + idempotentHint: false, + openWorldHint: true, + }); + } + 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(fixture.requests).toHaveLength(0); + } finally { + await fixture.close(); + } + }); + + test("creates/gets/lists vaults with pagination and a public projection", async () => { + const fixture = await connectVaultTest([ + Response.json({ ...vault, secret: "hidden" }), + Response.json(vault), + Response.json([vault], { + headers: { "x-has-more": "true", "x-next-offset": "40" }, + }), + Response.json([], { + headers: { "x-has-more": "false", "x-next-offset": "0" }, + }), + ]); + try { + expect( + toolResultJSON( + await fixture.call("manage_vaults", { + action: "create", + name: "checkout", + }), + ), + ).toEqual(vault); + expect( + toolResultJSON( + await fixture.call("manage_vaults", { + action: "get", + vault: "vlt_123", + }), + ), + ).toEqual(vault); + expect( + toolResultJSON( + await fixture.call("manage_vaults", { + action: "list", + limit: 20, + offset: 20, + }), + ), + ).toEqual({ items: [vault], has_more: true, next_offset: 40 }); + expect( + toolResultJSON( + await fixture.call("manage_vaults", { action: "list", offset: 40 }), + ), + ).toMatchObject({ items: [], has_more: false, next_offset: 0 }); + expect( + fixture.requests.map(({ method, path, body }) => ({ + method, + path, + body, + })), + ).toEqual([ + { method: "POST", path: "/vaults", body: { name: "checkout" } }, + { method: "GET", path: "/vaults/vlt_123", body: undefined }, + { method: "GET", path: "/vaults?limit=20&offset=20", body: undefined }, + { method: "GET", path: "/vaults?offset=40", body: undefined }, + ]); + for (const request of fixture.requests) { + expect(request.headers.get("authorization")).toBe("Bearer test-token"); + expect(request.headers.get("x-kernel-project")).toBe("proj_test"); + } + } finally { + await fixture.close(); + } + }); + + test.each([ + { + provider: "link", + spec: { + authorization: { method: "oauth", client: { type: "kernel_managed" } }, + }, + }, + { provider: "agentcard", spec: {} }, + { provider: "agentcard", spec: { user_id: "usr_enrolled" } }, + ])( + "creates a $provider wallet without opening or completing its action", + async ({ provider, spec }) => { + const response = { + ...item, + type: "wallet", + action: { + name: "card_enrollment", + url: "https://provider.example/enroll", + }, + }; + const fixture = await connectVaultTest([Response.json(response)]); + try { + const result = await fixture.call("manage_vault_wallets", { + action: "create", + vault: "checkout", + key: "wallet-1", + provider, + spec, + }); + expect(result.isError).toBeUndefined(); + expect(toolResultJSON(result).item.action).toEqual(response.action); + expect(fixture.requests).toHaveLength(1); + expect(fixture.requests[0]).toMatchObject({ + method: "PUT", + path: "/vaults/checkout/items/wallet-1", + body: { type: "wallet", spec: { ...spec, provider } }, + }); + } finally { + await fixture.close(); + } + }, + ); + + test.each(["link", "agentcard"])( + "creates and fully replaces a %s card without authorizing it", + async (provider) => { + const original = + provider === "link" + ? { + ...linkSpec, + provider, + metadata: { order: "001" }, + expires_at: Number.MAX_SAFE_INTEGER, + line_items: [ + { + name: "Supplies", + quantity: 1, + unit_amount: 1234, + description: "", + sku: "001", + url: "https://shop.example/item", + image_url: "https://shop.example/image", + product_url: "https://shop.example/product", + totals: [ + { + type: "discount", + display_text: "Discount", + amount: -100, + }, + ], + }, + ], + totals: [ + { type: "total", display_text: "Order total", amount: 1234 }, + ], + } + : { + ...agentcardSpec, + provider, + card_id: "vc_chosen", + amount: Number.MAX_SAFE_INTEGER, + }; + const replacement = provider === "link" ? linkSpec : agentcardSpec; + const fixture = await connectVaultTest([ + Response.json(item), + Response.json(item), + ]); + try { + for (const [action, spec] of [ + ["create", original], + ["update", replacement], + ]) { + const result = await fixture.call("manage_vault_cards", { + action, + vault: "checkout", + key: "order-1", + provider, + spec, + }); + expect(result.isError).toBeUndefined(); + } + expect(fixture.requests).toHaveLength(2); + expect(fixture.requests[0]).toMatchObject({ + method: "PUT", + body: { type: "card", spec: original }, + }); + expect(fixture.requests[1]).toMatchObject({ + method: "PATCH", + body: { spec: { ...replacement, provider } }, + }); + expect(fixture.requests[1].body).not.toHaveProperty("type"); + } finally { + await fixture.close(); + } + }, + ); + + test("lists items and expands live payment methods through either tool", async () => { + const expanded = { + ...item, + expanded: { + payment_methods: [ + { + id: "pm_choice", + provider: "link", + type: "card", + is_default: true, + display: { brand: "visa", last4: "4242" }, + capabilities: { + single_use_card: { eligible: false, reasons: ["unsupported"] }, + }, + }, + ], + }, + }; + const fixture = await connectVaultTest([ + Response.json([]), + Response.json(expanded), + Response.json(expanded), + ]); + try { + expect( + toolResultJSON( + await fixture.call("manage_vault_items", { + action: "list", + vault: "checkout", + }), + ), + ).toEqual({ items: [] }); + const wallet = await fixture.call("manage_vault_wallets", { + action: "payment_methods", + vault: "checkout", + key: "wallet-1", + }); + const get = await fixture.call("manage_vault_items", { + action: "get", + vault: "checkout", + key: "wallet-1", + expand: ["payment_methods"], + wait: 0, + }); + expect(toolResultJSON(wallet).item).toEqual(expanded); + expect(toolResultJSON(get).item).toEqual(expanded); + for (const request of fixture.requests.slice(1)) { + const url = new URL(request.path, "https://api.example"); + expect(url.pathname).toBe("/vaults/checkout/items/wallet-1"); + expect(url.searchParams.getAll("expand")).toEqual(["payment_methods"]); + expect(request.method).toBe("GET"); + } + } finally { + await fixture.close(); + } + }); +}); + +describe("vault scopes and input validation", () => { + test.each([ + { auth: organizationWideAuthInfo(), project: undefined, expected: null }, + { + auth: organizationWideAuthInfo(), + project: "checkout-project", + expected: "checkout-project", + }, + { + auth: projectScopedAuthInfo(), + project: "proj_test", + expected: "proj_test", + }, + ])( + "resolves the effective project without listing projects", + async ({ auth, project, expected }) => { + const fixture = await connectVaultTest([Response.json([])], auth); + try { + expect( + ( + await fixture.call("manage_vaults", { + action: "list", + ...(project && { project }), + }) + ).isError, + ).toBeUndefined(); + expect(fixture.requests).toHaveLength(1); + expect(fixture.requests[0].headers.get("x-kernel-project")).toBe( + expected, + ); + } finally { + await fixture.close(); + } + }, + ); + + test.each([ + ["manage_vaults", { action: "get", vault: "checkout" }], + [ + "manage_vault_wallets", + { action: "payment_methods", vault: "checkout", key: "wallet-1" }, + ], + [ + "manage_vault_cards", + { + action: "create", + vault: "checkout", + key: "order-1", + provider: "agentcard", + spec: agentcardSpec, + }, + ], + [ + "manage_vault_items", + { + action: "invoke", + vault: "checkout", + key: "order-1", + operation: "authorize", + }, + ], + ] as const)( + "%s rejects cross-project calls and unauthenticated calls", + async (name, args) => { + for (const auth of [projectScopedAuthInfo(), null]) { + const fixture = await connectVaultTest([], auth); + try { + expect( + (await fixture.call(name, { ...args, project: "another-project" })) + .isError, + ).toBe(true); + expect(fixture.requests).toHaveLength(0); + } finally { + await fixture.close(); + } + } + }, + ); + + test.each([ + ["manage_vaults", { action: "create" }], + ["manage_vaults", { action: "get" }], + ["manage_vaults", { action: "delete" }], + ["manage_vaults", { action: "create", name: ".." }], + ["manage_vaults", { action: "get", vault: "../other" }], + ["manage_vaults", { action: "list", limit: 101 }], + ["manage_vaults", { action: "list", offset: -1 }], + [ + "manage_vault_wallets", + { + action: "create", + vault: "checkout", + key: "wallet-1", + provider: "link", + }, + ], + [ + "manage_vault_wallets", + { + action: "create", + vault: "checkout", + key: "wallet-1", + provider: "link", + spec: {}, + }, + ], + [ + "manage_vault_wallets", + { + action: "create", + vault: "checkout", + key: "wallet-1", + provider: "link", + spec: { provider: "agentcard" }, + }, + ], + [ + "manage_vault_wallets", + { + action: "create", + vault: "checkout", + key: "wallet-1", + provider: "agentcard", + spec: { access_token: "hidden" }, + }, + ], + ["manage_vault_items", { action: "get", vault: "checkout" }], + [ + "manage_vault_items", + { action: "invoke", vault: "checkout", key: "order-1" }, + ], + [ + "manage_vault_items", + { action: "get", vault: "checkout", key: "order-1", expand: ["secrets"] }, + ], + [ + "manage_vault_items", + { action: "get", vault: "checkout", key: "order-1", wait: 61 }, + ], + [ + "manage_vault_items", + { action: "events", vault: "checkout", key: "order-1", wait: -1 }, + ], + ] as const)( + "rejects invalid %s inputs without a request", + async (name, args) => { + const fixture = await connectVaultTest([]); + try { + expect((await fixture.call(name, args)).isError).toBe(true); + expect(fixture.requests).toHaveLength(0); + } finally { + await fixture.close(); + } + }, + ); + + test.each([ + { ...linkSpec, provider: "agentcard" }, + { ...linkSpec, amount: 1.1 }, + { ...linkSpec, amount: 500001 }, + { ...linkSpec, expires_at: Number.MAX_SAFE_INTEGER + 1 }, + { + ...linkSpec, + line_items: [ + { name: "Supplies", unit_amount: Number.MAX_SAFE_INTEGER + 1 }, + ], + }, + { ...linkSpec, number: "hidden", cvc: "hidden" }, + { ...linkSpec, domains: ["shop.example"] }, + { ...linkSpec, authorization: { access_token: "hidden" } }, + { type: "card", spec: linkSpec }, + JSON.stringify(linkSpec), + null, + ])("rejects invalid or secret-bearing card specs", async (spec) => { + const fixture = await connectVaultTest([]); + try { + expect( + ( + await fixture.call("manage_vault_cards", { + action: "create", + vault: "checkout", + key: "order-1", + provider: "link", + spec, + }) + ).isError, + ).toBe(true); + 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 new file mode 100644 index 00000000..fd8985fe --- /dev/null +++ b/src/lib/mcp/tools/vaults.ts @@ -0,0 +1,119 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { APIError } from "@onkernel/sdk"; +import { z } from "zod"; +import { + defaultMcpDependencies, + type McpDependencies, +} from "@/lib/mcp/dependencies"; +import { projectForOperation } from "@/lib/mcp/project-selection"; +import { + errorResponse, + jsonResponse, + paginatedJsonResponse, +} from "@/lib/mcp/responses"; +import { paginationParams } from "@/lib/mcp/schemas"; +import { + projectVaultOutput, + throwVaultError, + vaultFields, +} from "@/lib/mcp/vault-responses"; +import { + vaultProjectSchema, + vaultSelectorSchema, +} 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"; + +export function registerVaultCapabilities( + server: McpServer, + dependencies: McpDependencies = defaultMcpDependencies, +) { + 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.', + { + ...vaultProjectSchema, + action: z.enum(["create", "list", "get", "delete"]), + vault: vaultSelectorSchema() + .describe("(get, delete) Vault ID or immutable name.") + .optional(), + name: vaultSelectorSchema() + .describe("(create) Immutable vault name.") + .optional(), + ...paginationParams, + }, + { + title: "Manage Kernel payment vaults", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + async (params, extra) => { + if (!extra.authInfo) throw new Error("Authentication required"); + const client = dependencies.createKernelClient( + extra.authInfo.token, + projectForOperation(extra.authInfo, params), + ); + const options = { maxRetries: 0, signal: extra.signal }; + try { + switch (params.action) { + case "create": { + if (!params.name) + return errorResponse("name is required for create."); + const vault = await client.vaults.upsert( + { name: params.name }, + options, + ); + return jsonResponse(projectVaultOutput(vault, vaultFields)); + } + case "list": { + const page = await client.vaults.list( + { + ...(params.limit !== undefined && { limit: params.limit }), + ...(params.offset !== undefined && { offset: params.offset }), + }, + options, + ); + return paginatedJsonResponse(page, { + mapItem: (vault) => projectVaultOutput(vault, vaultFields), + emptyText: "No vaults found in the effective project.", + }); + } + case "get": { + if (!params.vault) + return errorResponse("vault is required for get."); + const vault = await client.vaults.retrieve(params.vault, options); + return jsonResponse(projectVaultOutput(vault, vaultFields)); + } + case "delete": { + if (!params.vault) + return errorResponse("vault is required for delete."); + await client.vaults.delete(params.vault, options); + return jsonResponse({ + status: "deleted_or_not_found", + vault: params.vault, + }); + } + } + } catch (error) { + if ( + params.action === "delete" && + error instanceof APIError && + error.status === 404 + ) { + return jsonResponse({ + status: "deleted_or_not_found", + vault: params.vault, + }); + } + throwVaultError("manage_vaults", params.action, error); + } + }, + ); +} diff --git a/src/lib/mcp/vault-hints.test.ts b/src/lib/mcp/vault-hints.test.ts new file mode 100644 index 00000000..3ce298cf --- /dev/null +++ b/src/lib/mcp/vault-hints.test.ts @@ -0,0 +1,276 @@ +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 { vaultItemResponse } from "@/lib/mcp/vault-responses"; +import { + connectVaultTest, + item, + linkSpec, +} from "@/lib/mcp/tools/vaults.test-fixtures"; + +const target = { project: "proj_test", vault: "checkout", key: "order-1" }; + +describe("vault next-step hints", () => { + test("separates observation, advertised invocation, and provider-hosted approval", () => { + const result = toolResultJSON( + vaultItemResponse( + { + ...item, + action: { + name: "spend_approval", + url: "https://provider.example/approve", + secret: "hidden", + }, + available_operations: [ + { + type: "future_operation", + description: "Require approval.", + payload: "hidden", + }, + ], + }, + target, + ), + ); + expect(result.hints).toEqual({ + observation: [ + { + tool: "manage_vault_items", + arguments: { ...target, action: "get", wait: 0 }, + }, + { + tool: "manage_vault_items", + arguments: { ...target, action: "events", wait: 0 }, + }, + ], + invocation: [ + { + tool: "manage_vault_items", + arguments: { + ...target, + action: "invoke", + operation: "future_operation", + }, + requires_user_approval: true, + }, + ], + }); + expect(result.item.action).toEqual({ + name: "spend_approval", + url: "https://provider.example/approve", + }); + expect(JSON.stringify(result.hints)).not.toContain("provider.example"); + expect(JSON.stringify(result)).not.toContain("hidden"); + expect(result.guidance.join(" ")).toContain( + "Invocation hints are not approval", + ); + }); + + test.each( + [ + [], + undefined, + null, + [{ type: "" }], + [{ type: " " }], + [{ type: { secret: "hidden" } }], + ].map((operations) => ({ operations })), + )( + "never infers operations from ready state or a provider action", + ({ operations }) => { + const result = toolResultJSON( + vaultItemResponse( + { + key: item.key, + type: item.type, + state: { provider: "agentcard", status: "ready" }, + action: { + name: "authorize", + url: "https://provider.example/approve", + }, + ...(operations !== undefined && { + available_operations: operations, + }), + }, + target, + ), + ); + expect(result.hints.invocation).toEqual([]); + expect(result.hints.observation).toHaveLength(2); + }, + ); + + test.each([ + { + name: "manage_vault_wallets", + args: { action: "create", provider: "agentcard", spec: {} }, + }, + { name: "manage_vault_wallets", args: { action: "payment_methods" } }, + { + name: "manage_vault_cards", + args: { action: "create", provider: "link", spec: linkSpec }, + }, + { + name: "manage_vault_cards", + args: { action: "update", provider: "link", spec: linkSpec }, + }, + { name: "manage_vault_items", args: { action: "get" } }, + { + name: "manage_vault_items", + args: { action: "invoke", operation: "authorize" }, + }, + ])( + "attaches hints to $name/$args.action responses", + async ({ name, args }) => { + const updated = { ...item, available_operations: [] }; + const fixture = await connectVaultTest( + args.action === "invoke" + ? [Response.json(item), Response.json(updated)] + : [Response.json(item)], + ); + try { + const result = toolResultJSON( + await fixture.call(name, { + ...args, + vault: target.vault, + key: target.key, + }), + ); + expect(result.hints.observation[0].arguments).toEqual({ + ...target, + action: "get", + wait: 0, + }); + expect(result.hints.invocation).toHaveLength( + args.action === "invoke" ? 0 : 1, + ); + expect(fixture.requests).toHaveLength(args.action === "invoke" ? 2 : 1); + } finally { + await fixture.close(); + } + }, + ); + + test.each([ + { + auth: projectScopedAuthInfo("proj_fixed"), + selection: {}, + project: "proj_fixed", + }, + { + auth: organizationWideAuthInfo(), + selection: { project: "chosen-project" }, + project: "chosen-project", + }, + { + auth: organizationWideAuthInfo(), + selection: { project_id: "proj_chosen" }, + project: "proj_chosen", + }, + { auth: organizationWideAuthInfo(), selection: {}, project: undefined }, + ])( + "preserves the resolved project without inventing a default", + async ({ auth, selection, project }) => { + const fixture = await connectVaultTest([Response.json(item)], auth); + try { + const result = toolResultJSON( + await fixture.call("manage_vault_items", { + action: "get", + vault: "selected-vault", + key: "selected-key", + ...selection, + }), + ); + for (const hint of [ + ...result.hints.observation, + ...result.hints.invocation, + ]) { + expect(hint.arguments.vault).toBe("selected-vault"); + expect(hint.arguments.key).toBe("selected-key"); + expect(hint.arguments.project).toBe(project); + expect(hint.arguments).not.toHaveProperty("project_id"); + if (project === undefined) + expect(hint.arguments).not.toHaveProperty("project"); + } + expect(fixture.requests[0].headers.get("X-Kernel-Project")).toBe( + project ?? null, + ); + } finally { + await fixture.close(); + } + }, + ); + + test.each([ + { + events: [{ id: "evt_next", name: "state_changed" }], + after: "evt_before", + next: "evt_next", + }, + { events: [], after: "evt_before", next: "evt_before" }, + { events: [], after: undefined, next: undefined }, + ])( + "includes a resumable observation hint in event responses", + async ({ events, after, next }) => { + const fixture = await connectVaultTest([Response.json(events)]); + try { + const result = toolResultJSON( + await fixture.call("manage_vault_items", { + ...target, + action: "events", + ...(after !== undefined && { after }), + }), + ); + expect(result.next_after).toBe(next ?? null); + expect(result.hints.observation[1]).toEqual({ + tool: "manage_vault_items", + arguments: { + ...target, + action: "events", + wait: 0, + ...(next !== undefined && { after: next }), + }, + }); + expect(result.hints.observation[0].arguments).not.toHaveProperty( + "after", + ); + expect(result.hints).not.toHaveProperty("invocation"); + } finally { + await fixture.close(); + } + }, + ); + + test("observation hints can be submitted unchanged without a payment operation", async () => { + const fixture = await connectVaultTest([ + Response.json(item), + Response.json(item), + Response.json([]), + ]); + try { + const result = toolResultJSON( + await fixture.call("manage_vault_items", { ...target, action: "get" }), + ); + for (const hint of result.hints.observation) { + const observed = await fixture.call(hint.tool, hint.arguments); + expect(observed.isError).not.toBe(true); + } + expect(fixture.requests.map((request) => request.method)).toEqual([ + "GET", + "GET", + "GET", + ]); + expect( + fixture.requests.every( + (request) => + request.headers.get("X-Kernel-Project") === target.project, + ), + ).toBe(true); + } finally { + await fixture.close(); + } + }); +}); diff --git a/src/lib/mcp/vault-responses.test.ts b/src/lib/mcp/vault-responses.test.ts new file mode 100644 index 00000000..73bed208 --- /dev/null +++ b/src/lib/mcp/vault-responses.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, test } from "bun:test"; +import { + isDisplaySafeVaultURL, + projectVaultOutput, + vaultItemFields, +} from "@/lib/mcp/vault-responses"; +import { toolResultJSON } from "@/lib/mcp/mcp-test-fixtures"; +import { connectVaultTest, item } from "@/lib/mcp/tools/vaults.test-fixtures"; + +const aliases = { + number: "4111111111111111", + cvc: "123", + exp_month: "01", + exp_year: "2030", +}; +const unsafeItem = { + ...item, + secret: "hidden-top-level", + spec: { + ...item.spec, + metadata: { order: "hidden-metadata" }, + authorization: { + method: "oauth", + client: { type: "kernel_managed", client_secret: "hidden-client" }, + access_token: "hidden-token", + }, + }, + action: { + name: "spend_approval", + url: "https://provider.example/approval", + ciphertext: "hidden-action", + }, + state: { + provider: "agentcard", + status: "ready", + aliases: { ...aliases, secret: "hidden-alias" }, + masks: { brand: "visa", last4: "4242", pan: "hidden-pan" }, + authorization: { + id: "auth_1", + status: "approved", + charged_kind: "captured", + charged_amount_cents: 1234, + charged_currency: "usd", + amount_verified: false, + replay_delivered: false, + approval_url: + "https://provider.example/approve?access_token=hidden-approval", + raw: { secret: "hidden-provider" }, + }, + }, + expanded: { + payment_methods: [ + { + id: "pm_1", + is_default: false, + display: { brand: "visa", last4: "4242", number: "hidden-number" }, + capabilities: { single_use_card: { eligible: false, reasons: [] } }, + raw: "hidden-method", + }, + ], + }, +}; + +describe("vault public responses", () => { + test("projects nested public fields while preserving aliases, false, empty arrays, and absence", () => { + const projected = projectVaultOutput(unsafeItem, vaultItemFields); + expect(JSON.stringify(projected)).not.toContain("hidden"); + expect(projected).toMatchObject({ + state: { + aliases, + authorization: { + amount_verified: false, + replay_delivered: false, + charged_kind: "captured", + charged_amount_cents: 1234, + }, + }, + action: { + name: "spend_approval", + url: "https://provider.example/approval", + }, + expanded: { + payment_methods: [ + { + id: "pm_1", + is_default: false, + capabilities: { single_use_card: { eligible: false, reasons: [] } }, + }, + ], + }, + }); + expect(projected).not.toHaveProperty("expires_at"); + expect(projected).not.toHaveProperty("state.authorization.approval_url"); + }); + + test("does not pass opaque objects through scalar leaves", () => { + const projected = projectVaultOutput( + { + state: { + status_reason: { secret: "hidden" }, + domains: [{ secret: "hidden" }], + }, + action: null, + }, + vaultItemFields, + ); + expect(projected).toEqual({ + state: { status_reason: null, domains: [null] }, + action: null, + }); + }); + + test.each([ + "https://provider.example/?code=hidden", + "https://provider.example/?ACCESS_TOKEN=hidden", + "https://provider.example/#refresh_token=hidden", + "https://provider.example/?%63lient_secret=hidden", + "https://provider.example/#id_token=hidden", + "https://user:hidden@provider.example/", + "javascript:alert(1)", + "not-a-url", + ])("omits unsafe URLs: %s", (url) => { + expect(isDisplaySafeVaultURL(url)).toBe(false); + expect( + projectVaultOutput( + { action: { name: "spend_approval", url } }, + vaultItemFields, + ), + ).toEqual({ action: { name: "spend_approval" } }); + }); + + test("keeps full safe action URLs and operation descriptions", () => { + const url = "https://provider.example/approve?request=" + "x".repeat(300); + expect( + projectVaultOutput( + { ...item, action: { name: "spend_approval", url } }, + vaultItemFields, + ), + ).toMatchObject({ + action: { url }, + available_operations: item.available_operations, + }); + }); + + test("applies the same projection to get, list, and audit events", async () => { + const event = { + id: "evt_1", + name: "checkout.outcome", + browser_id: "brr_1", + data: { + outcome_reason: "indeterminate", + charged_amount_cents: 0, + replay_delivered: false, + raw: { secret: "hidden-event" }, + credential: "hidden-credential", + }, + raw: "hidden", + }; + const fixture = await connectVaultTest([ + Response.json(unsafeItem), + Response.json([unsafeItem]), + Response.json([event]), + ]); + try { + const get = await fixture.call("manage_vault_items", { + action: "get", + vault: "checkout", + key: "order-1", + }); + const list = await fixture.call("manage_vault_items", { + action: "list", + vault: "checkout", + }); + const events = await fixture.call("manage_vault_items", { + action: "events", + vault: "checkout", + key: "order-1", + }); + for (const result of [get, list, events]) { + expect(result.isError).toBeUndefined(); + expect(JSON.stringify(result)).not.toContain("hidden"); + } + expect(toolResultJSON(get).item).toEqual(toolResultJSON(list).items[0]); + expect(toolResultJSON(events).events).toEqual([ + { + id: "evt_1", + name: "checkout.outcome", + browser_id: "brr_1", + data: { + outcome_reason: "indeterminate", + charged_amount_cents: 0, + replay_delivered: false, + }, + }, + ]); + } finally { + await fixture.close(); + } + }); + + test("does not return opaque provider data through the event cursor", async () => { + const fixture = await connectVaultTest([ + Response.json([ + { id: { secret: "hidden-cursor" }, name: "checkout.outcome" }, + ]), + ]); + try { + const result = await fixture.call("manage_vault_items", { + action: "events", + vault: "checkout", + key: "order-1", + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).not.toContain("hidden"); + expect(fixture.requests).toHaveLength(1); + } finally { + await fixture.close(); + } + }); + + test.each([ + [400, "invalid_request", "Invalid vault request."], + [404, "not_found", "Vault, item, 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."], + [ + 500, + "provider_error", + "The payment provider could not complete the vault request.", + ], + [500, "provider_rate_limited", "rate limited requests"], + [429, "spend_request_rate_limited", "rate limited spend requests"], + ] as const)( + "returns curated text for HTTP %s / %s", + async (status, code, message) => { + const fixture = await connectVaultTest([ + Response.json( + { + code, + message: "access_token=hidden-free-text", + details: "hidden-details", + }, + { status }, + ), + ]); + try { + const result = await fixture.call("manage_vault_items", { + action: "get", + vault: "checkout", + key: "order-1", + }); + const text = JSON.stringify(result); + expect(result.isError).toBe(true); + expect(text).toContain(`${status} `); + expect(text).toContain(message); + expect(text).toContain(`[code: ${code}]`); + expect(text).toContain("Do not replay a payment."); + expect(text).not.toContain("hidden"); + } finally { + await fixture.close(); + } + }, + ); + + test.each( + [ + undefined, + null, + 123, + {}, + [], + "", + "unknown_error", + "__proto__", + "constructor", + "invalid_request hidden-suffix", + ].map((code) => ({ code })), + )( + "uses a generic fallback for unrecognized error codes", + async ({ code }) => { + const fixture = await connectVaultTest([ + Response.json( + { code, message: "password=hidden-password" }, + { status: 400 }, + ), + ]); + try { + const result = await fixture.call("manage_vault_items", { + action: "get", + vault: "checkout", + key: "order-1", + }); + const text = JSON.stringify(result); + expect(result.isError).toBe(true); + expect(text).toContain("400 Vault request failed."); + expect(text).not.toContain("[code:"); + expect(text).not.toContain("hidden"); + } finally { + await fixture.close(); + } + }, + ); + + test.each([ + { + message: "Expansion unavailable", + code: "expansion_unavailable", + opaque: "hidden-opaque", + headers: { authorization: "hidden-auth" }, + }, + { raw_provider: { secret: "hidden-without-message" } }, + { + code: "invalid_request", + message: "access_token=hidden-plaintext-secret", + }, + { code: "access_token=hidden-code-secret", message: "Invalid request" }, + { code: "conflict", message: "password=hidden-password-secret" }, + { + message: "Follow https://provider.example/?code=hidden-code", + code: "action_required", + }, + { message: { secret: "hidden-object-message" }, code: "provider_error" }, + ])( + "does not dump provider bodies or credential-bearing URLs in errors", + async (body) => { + const fixture = await connectVaultTest([ + Response.json(body, { status: 409 }), + ]); + try { + const result = await fixture.call("manage_vault_items", { + action: "get", + vault: "checkout", + key: "order-1", + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).not.toContain("hidden"); + if ( + ["provider_error", "invalid_request", "conflict"].includes( + body.code ?? "", + ) + ) { + expect(JSON.stringify(result)).toContain(`[code: ${body.code}]`); + } else { + expect(JSON.stringify(result)).not.toContain("[code:"); + } + if (body.message === "Expansion unavailable") + expect(JSON.stringify(result)).not.toContain(body.message); + } finally { + await fixture.close(); + } + }, + ); +}); diff --git a/src/lib/mcp/vault-responses.ts b/src/lib/mcp/vault-responses.ts new file mode 100644 index 00000000..16137682 --- /dev/null +++ b/src/lib/mcp/vault-responses.ts @@ -0,0 +1,282 @@ +import { + APIConnectionError, + APIConnectionTimeoutError, + APIError, + APIUserAbortError, +} from "@onkernel/sdk"; +import { z } from "zod"; +import { jsonResponse, throwToolError } from "@/lib/mcp/responses"; + +type OutputFields = { [key: string]: OutputFields | null }; + +function fields(names: string): OutputFields { + return Object.fromEntries(names.split(" ").map((name) => [name, null])); +} + +export const vaultFields = fields("id name created_at updated_at"); +const operationFields = fields("type description"); +const totalFields = fields("type display_text amount"); +const paymentMethodFields = { + ...fields("id provider type is_default"), + display: fields("label brand last4"), + capabilities: { single_use_card: fields("eligible reasons") }, +}; + +// Match the CLI's public projection, including future operation names but never +// unknown provider fields, free-form metadata, or opaque event data. +export const vaultItemFields: OutputFields = { + ...fields("id key type created_at updated_at expires_at"), + available_operations: operationFields, + available_expansions: operationFields, + action: fields("name url"), + expanded: { payment_methods: paymentMethodFields }, + spec: { + ...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") }, + totals: totalFields, + line_items: { + ...fields( + "name quantity unit_amount description sku url image_url product_url", + ), + totals: totalFields, + }, + }, + state: { + ...fields("provider status status_reason user_id domains"), + masks: fields("brand last4"), + aliases: fields("number cvc exp_month exp_year"), + authorization: fields( + "id status psp merchant amount amount_cents currency created_at expires_at approval_url browser_id reason psp_error_code expected_cents actual_cents amount_authority amount_verified charged_amount_cents charged_currency charged_kind replay_attempted replay_status replay_delivered", + ), + }, +}; + +export const vaultEventFields: OutputFields = { + ...fields("id name created_at browser_id"), + data: fields( + "reason status authorization_id vault_session_id request_kind outcome_reason provider_status provider_code provider_request_id provider_payment_status provider_error_type provider_error_code provider_decline_code provider_error_param provider_http_status provider_response_bytes provider_latency_ms payment_intent_id payment_method_id checkout_session_id replay_attempted replay_delivered charged_amount_cents charged_currency charged_kind expected_cents actual_cents currency actual_currency intent_status amount_verified psp_error_code", + ), +}; + +const urlFields = new Set([ + "url", + "approval_url", + "merchant_url", + "image_url", + "product_url", +]); +const secretURLKeys = new Set([ + "code", + "access_token", + "refresh_token", + "id_token", + "client_secret", + "password", +]); + +export function isDisplaySafeVaultURL(value: string): boolean { + try { + const url = new URL(value); + if ( + !["https:", "http:"].includes(url.protocol) || + url.username || + url.password + ) + return false; + for (const params of [ + url.searchParams, + new URLSearchParams(url.hash.slice(1)), + ]) { + for (const key of params.keys()) { + if (secretURLKeys.has(key.toLowerCase())) return false; + } + } + return true; + } catch { + return false; + } +} + +export function projectVaultOutput( + value: unknown, + allowed: OutputFields | null, +): unknown { + if (value === null) return null; + if (Array.isArray(value)) { + return value.map((item) => projectVaultOutput(item, allowed)); + } + if (allowed === null) { + return typeof value === "object" ? null : value; + } + if (typeof value !== "object") { + throw new Error("Invalid vault response shape"); + } + const result: Record = {}; + for (const [key, children] of Object.entries(allowed)) { + if (!Object.prototype.hasOwnProperty.call(value, key)) continue; + const field = Reflect.get(value, key); + if ( + urlFields.has(key) && + (typeof field !== "string" || !isDisplaySafeVaultURL(field)) + ) { + continue; + } + result[key] = projectVaultOutput(field, children); + } + return result; +} + +type VaultItemTarget = { + project?: string; + vault: string; + key: string; +}; + +const advertisedOperationsSchema = z.object({ + available_operations: z.array( + z.object({ + type: z + .string() + .min(1) + .refine((value) => value.trim().length > 0), + }), + ), +}); + +export function vaultObservationHints(target: VaultItemTarget, after?: string) { + return [ + { + tool: "manage_vault_items", + arguments: { ...target, action: "get", wait: 0 }, + }, + { + tool: "manage_vault_items", + arguments: { + ...target, + action: "events", + wait: 0, + ...(after !== undefined && { after }), + }, + }, + ]; +} + +export function vaultItemResponse(item: unknown, target: VaultItemTarget) { + 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, + })) + : [], + }, + 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.", + ], + }); +} + +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."], + [ + "conflict", + "The vault request conflicts with the current configuration or state. Inspect the item and its advertised operations and expansions.", + ], + [ + "project_error", + "Unable to resolve the vault's project. Check connection scope and project selection.", + ], + ["db_error", "The vault storage request could not be completed."], + [ + "provider_error", + "The payment provider could not complete the vault request.", + ], + [ + "provider_rate_limited", + "The payment provider has rate limited requests. Stop and wait before taking further action.", + ], + [ + "spend_request_rate_limited", + "The payment provider has rate limited spend requests. Stop and wait before taking further action.", + ], +]); +const vaultErrorGuidance = + "Inspect item state/events before taking further action. Do not replay a payment."; + +export function throwVaultError( + tool: string, + action: string, + error: unknown, +): never { + if (error instanceof z.ZodError) { + throwToolError( + tool, + action, + new Error("spec must match the selected provider's documented schema"), + ); + } + if (error instanceof APIError && typeof error.status === "number") { + // Neither provider messages nor unknown codes are safe to return, even as strings. + const body = error.error; + const code = + body && + typeof body === "object" && + "code" in body && + typeof body.code === "string" + ? body.code + : undefined; + const message = + code === undefined ? undefined : vaultErrorMessages.get(code); + throwToolError( + tool, + action, + APIError.generate( + error.status, + { + message: `${message ?? "Vault request failed."} ${vaultErrorGuidance}`, + ...(message !== undefined && { code }), + }, + undefined, + new Headers(), + ), + ); + } + if (error instanceof APIConnectionTimeoutError) { + throwToolError(tool, action, new APIConnectionTimeoutError()); + } + if (error instanceof APIUserAbortError) { + throwToolError(tool, action, new APIUserAbortError()); + } + if (error instanceof APIConnectionError) { + throwToolError( + tool, + action, + new APIConnectionError({ + message: + "Vault connection failed; inspect item state/events before taking further action. Do not replay a payment.", + }), + ); + } + throwToolError( + tool, + action, + new Error( + "Vault request failed; inspect item state/events before taking further action. Do not replay a payment.", + ), + ); +} diff --git a/src/lib/mcp/vault-schemas.ts b/src/lib/mcp/vault-schemas.ts new file mode 100644 index 00000000..fdcc6e7e --- /dev/null +++ b/src/lib/mcp/vault-schemas.ts @@ -0,0 +1,157 @@ +import { z } from "zod"; +import { projectSelectionInputSchema } from "@/lib/mcp/project-selection"; + +// Fresh schemas per property keep tools/list contracts inline instead of emitting $refs. +export function vaultSelectorSchema() { + return z + .string() + .regex(/^[a-zA-Z0-9._-]{1,255}$/) + .refine( + (value) => value !== "." && value !== "..", + "Invalid vault selector.", + ); +} + +export const vaultProjectSchema = projectSelectionInputSchema({ + project: + "Optional project name or ID. Vaults are project-owned: omit to use the API's effective default project, not all projects. A project-scoped connection cannot select a different project.", +}); + +export const vaultItemSchema = { + ...vaultProjectSchema, + vault: vaultSelectorSchema().describe("Vault ID or immutable name."), +}; + +export function vaultKeySchema() { + return vaultSelectorSchema().describe( + "Immutable item key within the vault, not the item ID.", + ); +} +export const vaultProviderSchema = z.enum(["link", "agentcard"]); +export const vaultWaitSchema = z + .number() + .int() + .min(0) + .max(60) + .describe( + "(get, events) One bounded server-side observation, in seconds (0-60). Not supported for invoke, list, or delete. Pending state is returned as-is; this never retries a payment or guarantees readiness.", + ) + .optional(); + +const integer = () => z.number().int().safe(); +const currency = () => z.string().regex(/^[A-Za-z]{3}$/); + +// 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(), + }) + .strict(); + +export const agentcardWalletSpecSchema = z + .object({ + provider: z.literal("agentcard").optional(), + user_id: z + .string() + .regex(/^usr_[A-Za-z0-9_]+$/) + .describe("An AgentCard user already enrolled in this organization.") + .optional(), + }) + .strict(); + +function linkTotalSchema() { + return z + .object({ + type: z.string(), + display_text: z.string(), + amount: integer().describe("Integer minor currency units."), + }) + .strict(); +} + +const linkLineItemSchema = z + .object({ + name: z.string(), + quantity: integer().min(1).optional(), + unit_amount: integer().optional(), + description: z.string().optional(), + sku: z.string().optional(), + url: z.string().optional(), + image_url: z.string().optional(), + product_url: z.string().optional(), + totals: z.array(linkTotalSchema()).optional(), + }) + .strict(); + +export const linkCardSpecSchema = z + .object({ + provider: z.literal("link").optional(), + wallet: vaultKeySchema(), + payment_method_id: z + .string() + .min(1) + .describe( + "Explicitly selected ID from the wallet's payment_methods expansion.", + ), + amount: integer() + .min(1) + .max(500000) + .describe("Integer minor currency units."), + currency: currency(), + merchant_name: z.string().min(1).max(255), + merchant_url: z.string().url(), + context: z.string().min(100), + line_items: z.array(linkLineItemSchema).optional(), + totals: z.array(linkTotalSchema()).optional(), + metadata: z.record(z.string()).optional(), + expires_at: integer().optional(), + }) + .strict(); + +export const agentcardCardSpecSchema = z + .object({ + provider: z.literal("agentcard").optional(), + wallet: vaultKeySchema(), + merchant: z.string().min(1).max(120), + amount: integer().min(1).describe("Integer minor currency units."), + currency: currency(), + card_id: z + .string() + .regex(/^vc_[A-Za-z0-9_]+$/) + .describe( + "Optional funding card. Omit for cardholder selection at approval.", + ) + .optional(), + }) + .strict(); + +export const browserVaultsSchema = z + .array( + z + .object({ + id: vaultSelectorSchema().optional(), + name: vaultSelectorSchema().optional(), + }) + .strict() + .refine( + (value) => (value.id !== undefined) !== (value.name !== undefined), + "Provide exactly one of id or name for each vault.", + ), + ) + .max(20) + .refine( + (values) => + new Set(values.map((value) => value.id ?? value.name)).size === + values.length, + "Duplicate vault references are not allowed.", + ) + .describe( + "(create only) Project-owned vaults to attach, each with exactly one id or name; max 20. Bindings are immutable and unavailable for pooled browsers. Use only returned non-secret payment aliases in this browser.", + ) + .optional();