From a078fb64a75253d2a2fb5e75df17d7493c197e2b Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:53:08 +0000 Subject: [PATCH 1/5] Align vault steering and safe metadata with the API --- README.md | 6 +- docs/vault-payments.md | 46 ++++++- src/lib/mcp/tools/vault-cards.ts | 2 +- src/lib/mcp/tools/vault-items.ts | 6 +- src/lib/mcp/tools/vaults.ts | 8 +- src/lib/mcp/vault-responses.ts | 50 +++++-- src/lib/mcp/vault-schemas.ts | 4 +- src/lib/mcp/vault-steering.test.ts | 214 +++++++++++++++++++++++++++++ 8 files changed, 308 insertions(+), 28 deletions(-) create mode 100644 src/lib/mcp/vault-steering.test.ts diff --git a/README.md b/README.md index 4c350dd..8bbca3f 100644 --- a/README.md +++ b/README.md @@ -317,12 +317,12 @@ Call `get_connection_context` before deciding whether to create or select a proj - `manage_credentials` - Create, list, get, update, and delete stored credentials; fetch a current TOTP code for credentials with a configured totp_secret. - `manage_credential_providers` - Create, list, get, update, and delete external credential providers (e.g. 1Password); list available items and test the provider connection. - `manage_vault_provider_configs` - Create, list, get, rename, rotate secrets, and delete organization-owned Link and AgentCard configurations. Writes require organization scope. -- `manage_vaults` - Create, list, get, and delete project-owned payment vaults. +- `manage_vaults` - Create, list, get, and delete project-owned vaults; use one per end user. - `manage_vault_wallets` - Connect Kernel-managed or configured Link/AgentCard wallets, import Link grants from a trusted backend, and inspect live payment methods. - `manage_vault_cards` - Create or update card requests according to the API's lifecycle rules; does not implicitly authorize Link cards. -- `manage_vault_items` - List, get, invoke advertised operations, observe events, and delete vault items. Provider approvals remain user actions; ready does not mean paid. +- `manage_vault_items` - List, get, invoke advertised operations, observe events, and delete vault items. Read credential definitions, presence, version, and collection links without stored values. `collect` reopens the full form; provider approvals remain user actions. Ready is not login or payment success. -See [Vault payments](docs/vault-payments.md) for both provider flows, safety rules, and response shapes. `manage_browsers` accepts creation-only `vaults` references (max 20); existing sessions and pools cannot gain vault bindings. The five vault tools share the `vaults` toolset and prepare/observe credentials rather than submitting merchant payments. They are exposed only when `GET /org/entitlements` reports `features.vaults.enabled: true` for the current credential; missing or unavailable entitlements hide them. Toolset configuration cannot override this access check. Provider configuration support uses the released `@onkernel/sdk` 0.101.0. +See [Vault payments](docs/vault-payments.md) for both provider flows, safety rules, and response shapes. `manage_browsers` accepts creation-only `vaults` references (max 20); existing sessions and pools cannot gain vault bindings. The five vault tools share the `vaults` toolset and prepare/observe credentials rather than submitting merchant payments. They are exposed only when `GET /org/entitlements` reports `features.vaults.enabled: true` for the current credential; missing or unavailable entitlements hide them. Toolset configuration cannot override this access check. Credential creation/updates and `fill`/`prepare_checkout` remain API/CLI-only; these MCP tools do not accept their write inputs. The SDK dependency is pinned in `bun.lock`. ### Standalone tools diff --git a/docs/vault-payments.md b/docs/vault-payments.md index 179fde3..cb3ce4d 100644 --- a/docs/vault-payments.md +++ b/docs/vault-payments.md @@ -1,15 +1,43 @@ # Vault payments -The vault tools prepare and observe payment credentials. They do **not** submit +The vault tools prepare and observe payment credentials and read existing non-payment credential items. 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. +They use the same vault API as the Kernel CLI. When advertised, API fill is the +preferred browser-checkout path. The alias recipes below are for explicitly chosen +egress-substitution integrations, not fallback after a failed or uncertain fill. **Assume real payment effects.** Mode comes from the selected provider credentials; there is no per-item test flag. AgentCard configuration responses report the introspected `test_mode`. A development or staging MCP endpoint does not make a card request a test transaction. -Provider configuration support uses the released Node SDK 0.101.0. +The released Node SDK dependency is pinned in `bun.lock`. + +## Credential collection and observation + +Use one vault per end user, such as `user-123`. Create credential definitions through +the Kernel API or CLI: use only the recognizable site name for `description`, and +set `sensitive: false` explicitly for ordinary usernames/emails. Passwords and TOTP +seeds must be sensitive. Payment-card data belongs in wallet/card items, not credentials. + +`manage_vault_items` can read existing credential items and invoke advertised `collect`. +It returns field definitions, `has_value`, version, and collection-link expiry, but +omits all stored values, even non-sensitive ones. Share the bearer collection link +only with the intended user, outside the agent-controlled browser. Never request a +password or TOTP seed in chat; hosted collection cannot accept TOTP seeds. + +Listing does not renew links; use single-item `get` or advertised `collect`. +Collection reopens the full form without clearing values or changing version. +`wait` observes readiness, not edits to ready items. Compare versions using `get` +without `wait`; API updates can also change the version. + +Credential creation/updates, `fill`, and `prepare_checkout` remain API/CLI-only; +MCP does not accept their write inputs. For API updates, use the current version +and `expected_item_id` when bound to an earlier read. Clearing supported required +values returns pending collection; hosted forms still require populated inputs. +Fill writes real values into the browser without submitting the form. It does not +isolate them from an agent with browser access. Never retry an uncertain fill or +fall back to payment aliases. ## Tools and scope @@ -251,8 +279,16 @@ with `manage_vault_cards`: 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. +happens at checkout. Eligible unused cards may instead advertise `prepare_checkout`; +invoke it through the API or CLI with the advertised checkout context. Keep the approval +page open, poll until `ready_to_submit`, and submit native Pay before +`state.preparation.expires_at` (at most 30 seconds after readiness). Polling does not +extend the deadline. Each preparation is single-use even after failure or expiry. +MCP preserves preparation metadata but does not expose an invocation hint for it. + +For an explicitly chosen alias-based integration, attach the vault to a new browser +and use returned aliases. Observe checkout authorization and approval URLs. Never +switch to aliases after an uncertain fill or preparation. A reusable card remaining `ready` does not establish that the last payment succeeded. ## Observation, updates, and safety diff --git a/src/lib/mcp/tools/vault-cards.ts b/src/lib/mcp/tools/vault-cards.ts index bd02343..ee32215 100644 --- a/src/lib/mcp/tools/vault-cards.ts +++ b/src/lib/mcp/tools/vault-cards.ts @@ -18,7 +18,7 @@ export function registerVaultCardTools( ) { server.tool( "manage_vault_cards", - 'Configure payment card requests, not merchant payments. Mode is determined by the wallet credentials, not a per-item test flag; never assume a test transaction. "create" creates or retrieves an identical card request by immutable key. "update" replaces requested-card specs. Pending issuance updates preserve omitted optional fields and clear explicit empty lists, only for provider-supported edits allowed by the API. Wallet/provider binding cannot change after authorization starts. Uncertain updates enter recovery_required; do not retry. Neither implicitly authorizes Link: inspect available_operations with manage_vault_items and obtain explicit user approval before invoking. AgentCard authorizes at checkout. Amounts are integer minor currency units. No card data, OAuth tokens, provider secrets, or domain configuration. Never reconfigure a card to retry a failed, timed-out, rejected, or indeterminate payment. Requests are not automatically retried.', + 'Configure payment card requests in a per-end-user vault, not merchant payments. Use wallet/card items for credit card numbers, security codes, and expiration dates; never store that data in credential items. Mode is determined by the wallet credentials, not a per-item test flag; never assume a test transaction. "create" creates or retrieves an identical card request by immutable key. "update" replaces requested-card specs. Pending issuance updates preserve omitted optional fields and clear explicit empty lists, only for provider-supported edits allowed by the API. Wallet/provider binding cannot change after authorization starts. Uncertain updates enter recovery_required; do not retry. Neither implicitly authorizes Link: inspect available_operations with manage_vault_items and obtain explicit user approval before invoking. Eligible unused AgentCard cards advertise prepare_checkout for supported tokenization checkout; this operation requires the Kernel API, not this MCP tool. Keep the returned approval page open, poll until ready_to_submit, and submit native Pay before preparation.expires_at. Preparations are single-use, even after failure or expiry. Amounts are integer minor currency units. No card data, OAuth tokens, provider secrets, or domain configuration. Never reconfigure a card to retry a failed, timed-out, rejected, or indeterminate payment. Requests are not automatically retried.', vaultToolInput({ ...vaultItemSchema, key: vaultKeySchema(), diff --git a/src/lib/mcp/tools/vault-items.ts b/src/lib/mcp/tools/vault-items.ts index 6aeb1b5..312895d 100644 --- a/src/lib/mcp/tools/vault-items.ts +++ b/src/lib/mcp/tools/vault-items.ts @@ -27,7 +27,7 @@ export function registerVaultItemTools( ) { server.tool( "manage_vault_items", - 'Inspect payment vault items and immutable audit events. "list" reads items; "get" reads state, public aliases, required user actions, available_operations, and available_expansions. "invoke" fetches the item again and submits only an advertised operation; read its description and obtain explicit user approval first. Provider actions (OAuth, enrollment, MFA, approval) must be completed by the user, not invoked as operations. "events" observes outcomes; use the last event ID as after. "delete" invalidates an item credential; confirm with the user first. Unresolved payments block item and parent deletion. recovery_required is not decline or expiry: stop payment attempts and reconcile with the provider or support; no reset exists. Ready does not mean paid. Requests are never automatically retried. Do not retry failed, timed-out, rejected, or indeterminate payments; inspect state/events instead.', + 'Inspect credential and payment vault items and immutable audit events. "list" reads items without renewing collection links; "get" reads state, safe field metadata, version, required user actions, available_operations, and available_expansions. MCP omits all stored credential values, even non-sensitive ones. For credentials, present the collection URL only to the intended user, outside the agent-controlled browser; never ask for passwords or TOTP seeds in chat. "collect" reopens the full form without clearing values or changing version; TOTP has no hosted input. wait observes readiness, not edits to ready credentials: compare versions using get without wait. Credential creation and updates require the Kernel API or CLI; use a per-user vault, site-name-only description, and sensitive:false for ordinary usernames/emails. Never store credit card data in credential items. "invoke" fetches the item again and submits only an advertised operation; read its description and obtain explicit user approval first. Provider actions (OAuth, enrollment, MFA, approval) must be completed by the user, not invoked as operations. "events" observes outcomes; use the last event ID as after. "delete" invalidates an item credential; confirm with the user first. Unresolved payments can block item and parent deletion; the API decides whether explicit abandonment is allowed, and deletion never proves a payment did not occur. recovery_required is not decline or expiry: stop payment attempts and reconcile with the provider or support; no reset exists. Credential ready means required values exist, not that login succeeded; payment ready does not mean paid. fill and prepare_checkout require additional inputs and are API-only here; never substitute another operation or retry an uncertain attempt. Requests are never automatically retried. Do not retry failed, timed-out, rejected, or indeterminate payments; inspect state/events instead.', vaultToolInput({ ...vaultItemSchema, action: z.enum(["list", "get", "invoke", "events", "delete"]), @@ -79,7 +79,7 @@ export function registerVaultItemTools( params.action !== "events" ) { return errorResponse( - "wait is only supported for get and events; invoke does not wait for authorization.", + "wait is only supported for get and events; invoke does not wait for collection or authorization.", ); } if (params.action === "list") { @@ -160,7 +160,7 @@ export function registerVaultItemTools( 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.", + "Observing events never retries an operation. For edits to ready credentials, compare item versions without wait; a version change does not identify a specific form submission. Do not replay an uncertain fill or payment.", }); } case "delete": { diff --git a/src/lib/mcp/tools/vaults.ts b/src/lib/mcp/tools/vaults.ts index 97e3c62..b7444d9 100644 --- a/src/lib/mcp/tools/vaults.ts +++ b/src/lib/mcp/tools/vaults.ts @@ -38,7 +38,7 @@ export function registerVaultCapabilities( server.tool( "manage_vaults", - 'Manage project-owned payment vaults, not merchant payments. "create" creates or retrieves a vault by immutable name; "list" lists the effective project only; "get" reads one; "delete" invalidates the vault and every item credential. Confirm deletion with the user first; unresolved payment operations block deletion and require provider/support reconciliation. Connect a wallet with manage_vault_wallets, configure a card with manage_vault_cards, and observe actions/outcomes with manage_vault_items. Requests are not automatically retried.', + 'Manage project-owned vaults for end-user credentials and payment items. Use a separate vault per end user, with an immutable name such as user-123; do not mix unrelated users. Vaults store credentials, not authenticated browser sessions, and do not submit website forms or merchant payments. "create" creates or retrieves a vault by immutable name; "list" lists the effective project only; "get" reads one; "delete" invalidates the vault and every item credential. Confirm deletion with the user first; unresolved payment operations block deletion and require provider/support reconciliation. Connect a payment wallet with manage_vault_wallets, configure a card with manage_vault_cards, and inspect credentials or payment items with manage_vault_items. Credential creation and updates use the Kernel API or CLI, not the wallet/card tools. For credentials, use only the recognizable site name as description and set sensitive:false explicitly for ordinary usernames/emails; passwords and TOTP seeds must be sensitive. Never put credit card data in credential items. Attach vaults when creating a browser; bindings cannot change later. Requests are not automatically retried.', vaultToolInput({ ...vaultProjectSchema, action: z.enum(["create", "list", "get", "delete"]), @@ -46,12 +46,14 @@ export function registerVaultCapabilities( .describe("(get, delete) Vault ID or immutable name.") .optional(), name: vaultSelectorSchema() - .describe("(create) Immutable vault name.") + .describe( + "(create) Immutable per-end-user vault name, e.g. user-123. Reuse that user's vault; do not mix unrelated users.", + ) .optional(), ...paginationParams, }), { - title: "Manage Kernel payment vaults", + title: "Manage Kernel vaults", readOnlyHint: false, destructiveHint: true, idempotentHint: false, diff --git a/src/lib/mcp/vault-responses.ts b/src/lib/mcp/vault-responses.ts index c71e895..fdfc9f9 100644 --- a/src/lib/mcp/vault-responses.ts +++ b/src/lib/mcp/vault-responses.ts @@ -29,15 +29,16 @@ const paymentMethodFields = { // 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"), + ...fields("id key type version created_at updated_at expires_at"), available_operations: operationFields, available_expansions: operationFields, - action: fields("name url"), + action: fields("name url expires_at"), 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", + "provider wallet user_id payment_method_id card_id amount currency merchant merchant_name merchant_url context expires_at description", ), + fields: { "*": fields("type required sensitive") }, provider_config: fields("id name"), authorization: { method: null, @@ -53,6 +54,10 @@ export const vaultItemFields: OutputFields = { }, state: { ...fields("provider status status_reason user_id domains"), + fields: { "*": fields("has_value") }, + preparation: fields( + "id status browser_id merchant_origin environment created_at expires_at approval_url", + ), masks: fields("brand last4"), aliases: fields("number cvc exp_month exp_year"), authorization: fields( @@ -64,7 +69,7 @@ export const vaultItemFields: OutputFields = { 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", + "reason status authorization_id preparation_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", ), }; @@ -72,6 +77,7 @@ const urlFields = new Set([ "url", "approval_url", "merchant_url", + "merchant_origin", "image_url", "product_url", ]); @@ -121,6 +127,14 @@ export function projectVaultOutput( if (typeof value !== "object") { throw new Error("Invalid vault response shape"); } + if (Object.prototype.hasOwnProperty.call(allowed, "*")) { + return Object.fromEntries( + Object.entries(value).map(([key, field]) => [ + key, + projectVaultOutput(field, allowed["*"]), + ]), + ); + } const result: Record = {}; for (const [key, children] of Object.entries(allowed)) { if (!Object.prototype.hasOwnProperty.call(value, key)) continue; @@ -220,6 +234,11 @@ export function vaultItemResponse( secrets: (string | undefined)[] = [], ) { const projected = projectVaultOutput(item, vaultItemFields); + const credential = + projected !== null && + typeof projected === "object" && + "type" in projected && + projected.type === "credential"; const advertised = advertisedOperationsSchema.safeParse(projected); const secretValues = secretVariants(secrets); const safeHint = (hint: unknown) => !containsVaultSecret(hint, secretValues); @@ -239,13 +258,22 @@ export function vaultItemResponse( .filter(safeHint) : [], }, - guidance: [ - "Ask the user to complete returned provider actions. Never request card data or OAuth codes/tokens in chat; imported grants must come from a trusted backend. Read operation descriptions and obtain explicit user approval before invoking.", - "Use returned aliases only in a new browser created with this vault attached, respecting returned permitted domains. Ready does not mean paid.", - "Observe get/events for outcomes. Do not retry failed, timed-out, rejected, or indeterminate payments or reconfigure a card to retry them.", - "Invocation hints are not approval to execute. Availability may change; invoke rechecks the advertised operations. API-advertised fill and prepare_checkout operations require additional inputs and must use the Kernel API, not this tool.", - "recovery_required is an unresolved original outcome, not decline or expiry. Stop payment attempts; reconcile with the provider or support. No reset exists, and deletion may be blocked for this item and its parents.", - ], + guidance: credential + ? [ + "Present the collection URL only to the intended user in a private surface, outside the agent-controlled browser. It is a bearer credential. Never ask for passwords or TOTP seeds in chat; TOTP seeds require trusted backend provisioning, not hosted collection.", + "MCP returns field definitions, has_value, version, and collection expiry, never stored field values. Ready means required values exist, not that login succeeded. Listing does not renew collection links; use get or the advertised collect operation.", + "collect reopens the full form without clearing values or changing readiness or version. wait observes readiness, not edits to ready items. Compare versions with get without wait; a change can also come from an API update, so it does not identify a specific form submission.", + "Create or update credentials through the Kernel API or CLI. Use a per-user vault, a recognizable site-name-only description, and sensitive:false for usernames/emails. Passwords and TOTP must be sensitive. Updates require the current version; supply expected_item_id when bound to an earlier read. Omitted values remain; null or empty strings clear supported fields, including required text/email/password fields. Hosted forms still require populated required inputs. Do not store payment-card data in credential items.", + "Invocation hints are not approval to execute. fill is API-only in this MCP server: bind the vault at browser creation, authorize the destination, and follow the advertised description. Fill does not submit or navigate; real values enter the browser and may be read by an agent with browser access. Never retry an uncertain fill or fall back to aliases.", + ] + : [ + "Ask the user to complete returned provider actions. Never request card data or OAuth codes/tokens in chat; imported grants must come from a trusted backend. Read operation descriptions and obtain explicit user approval before invoking.", + "Fill is the preferred browser-checkout path when advertised, but requires the Kernel API because this tool does not accept fill inputs. Aliases are an alternative only for explicitly chosen egress-substitution integrations in a browser created with this vault attached, respecting returned permitted domains. Never fall back to aliases after an uncertain fill. 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. API-advertised fill and prepare_checkout operations require additional inputs and must use the Kernel API, not this tool.", + "For API-only prepare_checkout, deliver the returned approval URL and keep the approval page open. Poll the item until ready_to_submit, then submit native Pay before state.preparation.expires_at. Readiness lasts at most 30 seconds; polling does not extend it. Preparations are single-use even after failure or expiry. Preparation consumed means claimed, not payment success.", + "recovery_required is an unresolved original outcome, not decline or expiry. Stop payment attempts; reconcile with the provider or support. No reset exists, and deletion may be blocked for this item and its parents.", + ], }, secrets, ); diff --git a/src/lib/mcp/vault-schemas.ts b/src/lib/mcp/vault-schemas.ts index b5a39d5..e0e75ef 100644 --- a/src/lib/mcp/vault-schemas.ts +++ b/src/lib/mcp/vault-schemas.ts @@ -40,7 +40,7 @@ export const vaultWaitSchema = z .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.", + "(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 an operation or guarantees readiness. For credentials, wait observes required-value readiness, not edits to an already-ready item; compare version using get without wait.", ) .optional(); @@ -236,6 +236,6 @@ export const browserVaultsSchema = z "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.", + "(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 a separate vault per end user. Attaching grants access to all items, including items added later. Credential fill writes real values into the page; it does not isolate them from an agent with browser access. Payment aliases are a separate, explicitly chosen egress path; never fall back to aliases after an uncertain fill.", ) .optional(); diff --git a/src/lib/mcp/vault-steering.test.ts b/src/lib/mcp/vault-steering.test.ts new file mode 100644 index 0000000..c7a0e46 --- /dev/null +++ b/src/lib/mcp/vault-steering.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, test } from "bun:test"; +import { toolResultJSON } from "@/lib/mcp/mcp-test-fixtures"; +import { vaultItemResponse } from "@/lib/mcp/vault-responses"; +import { connectVaultTest, item } from "@/lib/mcp/tools/vaults.test-fixtures"; + +const target = { vault: "user-123", key: "login" }; +const credential = { + id: "credential-1", + key: "login", + type: "credential", + version: 7, + spec: { + description: "Hacker News", + fields: { + username: { + type: "text", + required: true, + sensitive: false, + value: "private-user", + }, + password: { + type: "password", + required: true, + sensitive: true, + value: "private-password", + }, + otp: { + type: "totp", + required: false, + sensitive: true, + value: "private-seed", + }, + }, + }, + state: { + status: "ready", + fields: { + username: { has_value: true, value: "private-user" }, + password: { has_value: true, value: "private-password" }, + otp: { has_value: true, value: "private-seed" }, + }, + }, + action: { + name: "collect", + url: "https://vault.example/collect#token=collection-token", + expires_at: "2026-09-16T00:00:00Z", + }, + available_operations: [ + { type: "collect", description: "Open the full form" }, + { type: "fill", description: "Fill selected fields" }, + ], + available_expansions: [], +}; + +describe("vault OpenAPI steering", () => { + test("tool discovery steers per-user credential collection without advertising unsupported writes", async () => { + const fixture = await connectVaultTest([]); + try { + const { tools } = await fixture.client.listTools(); + const vaults = tools.find(({ name }) => name === "manage_vaults"); + const items = tools.find(({ name }) => name === "manage_vault_items"); + expect(vaults?.description).toContain("separate vault per end user"); + expect(vaults?.description).toContain("sensitive:false"); + expect(items?.description).toContain("without renewing collection links"); + expect(items?.description).toContain("API-only"); + expect(tools.map(({ name }) => name)).not.toContain( + "manage_vault_credentials", + ); + expect(fixture.requests).toEqual([]); + } finally { + await fixture.close(); + } + }); + test.each(["ready", "pending_collection"])( + "preserves %s credential metadata, not values", + (status) => { + const result = toolResultJSON( + vaultItemResponse( + { ...credential, state: { ...credential.state, status } }, + target, + ), + ); + expect(result.item.version).toBe(7); + expect(result.item.spec.description).toBe("Hacker News"); + expect(result.item.spec.fields.username).toEqual({ + type: "text", + required: true, + sensitive: false, + }); + expect(result.item.state.fields.otp).toEqual({ has_value: true }); + expect(result.item.action.expires_at).toBe(credential.action.expires_at); + expect(result.item.action.url).toBe(credential.action.url); + expect(JSON.stringify(result)).not.toContain("private-"); + expect( + result.hints.invocation.map( + (hint: { arguments: { operation: string } }) => + hint.arguments.operation, + ), + ).toEqual(["collect"]); + const guidance = result.guidance.join(" "); + for (const text of [ + "bearer credential", + "sensitive:false", + "expected_item_id", + "site-name-only", + "wait observes readiness", + "API-only", + "Never retry an uncertain fill", + ]) + expect(guidance).toContain(text); + expect(guidance).not.toContain("Ready does not mean paid"); + }, + ); + + test("collect remains parameterless and returns the safe credential projection", async () => { + const fixture = await connectVaultTest([ + Response.json(credential), + Response.json(credential), + ]); + try { + const result = toolResultJSON( + await fixture.call("manage_vault_items", { + ...target, + action: "invoke", + operation: "collect", + }), + ); + expect(result.item.version).toBe(7); + expect(result.item.spec.fields.password.sensitive).toBe(true); + expect(JSON.stringify(result)).not.toContain("private-"); + expect( + fixture.requests.map(({ method, body }) => ({ method, body })), + ).toEqual([ + { method: "GET", body: undefined }, + { method: "POST", body: { type: "collect" } }, + ]); + } finally { + await fixture.close(); + } + }); + + test("list preserves presence metadata without renewing collection", async () => { + const fixture = await connectVaultTest([Response.json([credential])]); + try { + const result = toolResultJSON( + await fixture.call("manage_vault_items", { + vault: target.vault, + action: "list", + }), + ); + expect(result.items[0].state.fields.password).toEqual({ + has_value: true, + }); + expect(JSON.stringify(result)).not.toContain("private-"); + expect(fixture.requests).toHaveLength(1); + expect(fixture.requests[0].method).toBe("GET"); + } finally { + await fixture.close(); + } + }); + + test("preserves preparation deadlines while withholding unsupported invocation hints", () => { + const preparation = { + id: "prep-1", + browser_id: "browser-1", + merchant_origin: "https://shop.example", + environment: "production", + status: "ready", + expires_at: "2026-09-16T00:00:30Z", + approval_url: "https://approve.example/prepare", + }; + const result = toolResultJSON( + vaultItemResponse( + { + ...item, + state: { + provider: "agentcard", + status: "ready_to_submit", + preparation: { ...preparation, token: "private-token" }, + }, + available_operations: [ + { type: "prepare_checkout", description: "Prepare checkout" }, + ], + }, + target, + ), + ); + expect(result.item.state.preparation).toEqual(preparation); + expect(result.hints.invocation).toEqual([]); + expect(result.guidance.join(" ")).toContain("Preparations are single-use"); + expect(result.guidance.join(" ")).toContain("Never fall back to aliases"); + expect(JSON.stringify(result)).not.toContain("private-token"); + }); + + test("field names cannot mutate projection prototypes", () => { + const fields = JSON.parse( + '{"__proto__":{"type":"text","value":"private-value"}}', + ); + const result = toolResultJSON( + vaultItemResponse( + { ...credential, spec: { ...credential.spec, fields } }, + target, + ), + ); + expect( + Object.prototype.hasOwnProperty.call( + result.item.spec.fields, + "__proto__", + ), + ).toBe(true); + expect(result.item.spec.fields["__proto__"]).toEqual({ type: "text" }); + expect(JSON.stringify(result)).not.toContain("private-value"); + }); +}); From 9bfe61cfc5677056fbdd8255acd955716ed72a7f Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:31:35 +0000 Subject: [PATCH 2/5] Support credential creation and browser fill through MCP --- README.md | 5 +- docs/vault-payments.md | 75 ++- src/lib/mcp/register.test.ts | 2 + src/lib/mcp/tool-names.ts | 1 + .../mcp/tools/vault-credential-flow.test.ts | 480 ++++++++++++++++++ src/lib/mcp/tools/vault-credentials.ts | 150 ++++++ src/lib/mcp/tools/vault-items.test.ts | 11 +- src/lib/mcp/tools/vault-items.ts | 56 +- src/lib/mcp/tools/vaults.test-fixtures.ts | 15 +- src/lib/mcp/tools/vaults.test.ts | 1 + src/lib/mcp/tools/vaults.ts | 4 +- src/lib/mcp/vault-fill.ts | 95 ++++ src/lib/mcp/vault-responses.ts | 10 +- src/lib/mcp/vault-steering.test.ts | 6 +- 14 files changed, 881 insertions(+), 30 deletions(-) create mode 100644 src/lib/mcp/tools/vault-credential-flow.test.ts create mode 100644 src/lib/mcp/tools/vault-credentials.ts create mode 100644 src/lib/mcp/vault-fill.ts diff --git a/README.md b/README.md index 8bbca3f..4b2ee0f 100644 --- a/README.md +++ b/README.md @@ -320,9 +320,10 @@ Call `get_connection_context` before deciding whether to create or select a proj - `manage_vaults` - Create, list, get, and delete project-owned vaults; use one per end user. - `manage_vault_wallets` - Connect Kernel-managed or configured Link/AgentCard wallets, import Link grants from a trusted backend, and inspect live payment methods. - `manage_vault_cards` - Create or update card requests according to the API's lifecycle rules; does not implicitly authorize Link cards. -- `manage_vault_items` - List, get, invoke advertised operations, observe events, and delete vault items. Read credential definitions, presence, version, and collection links without stored values. `collect` reopens the full form; provider approvals remain user actions. Ready is not login or payment success. +- `manage_vault_credentials` - Create credential definitions for private human collection; update values or description with version and optional immutable item identity preconditions. +- `manage_vault_items` - List, get, invoke advertised operations (including fill with value-free bindings), observe events, and delete vault items. Read credential definitions, presence, version, and collection links without stored values. `collect` reopens the full form; provider approvals remain user actions. Ready is not login or payment success. -See [Vault payments](docs/vault-payments.md) for both provider flows, safety rules, and response shapes. `manage_browsers` accepts creation-only `vaults` references (max 20); existing sessions and pools cannot gain vault bindings. The five vault tools share the `vaults` toolset and prepare/observe credentials rather than submitting merchant payments. They are exposed only when `GET /org/entitlements` reports `features.vaults.enabled: true` for the current credential; missing or unavailable entitlements hide them. Toolset configuration cannot override this access check. Credential creation/updates and `fill`/`prepare_checkout` remain API/CLI-only; these MCP tools do not accept their write inputs. The SDK dependency is pinned in `bun.lock`. +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 six 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. Credential create → collect → readiness → fill is supported entirely through MCP tools. `prepare_checkout` remains API/CLI-only. The SDK dependency is pinned in `bun.lock`. ### Standalone tools diff --git a/docs/vault-payments.md b/docs/vault-payments.md index cb3ce4d..dc66da1 100644 --- a/docs/vault-payments.md +++ b/docs/vault-payments.md @@ -1,8 +1,8 @@ # Vault payments -The vault tools prepare and observe payment credentials and read existing non-payment credential items. They do **not** submit +The vault tools prepare and observe payment credentials and manage non-payment credential items. 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. When advertised, API fill is the +They use the same vault API as the Kernel CLI. When advertised, fill is the preferred browser-checkout path. The alias recipes below are for explicitly chosen egress-substitution integrations, not fallback after a failed or uncertain fill. @@ -15,8 +15,8 @@ The released Node SDK dependency is pinned in `bun.lock`. ## Credential collection and observation -Use one vault per end user, such as `user-123`. Create credential definitions through -the Kernel API or CLI: use only the recognizable site name for `description`, and +Use one vault per end user, such as `user-123`. Create credential definitions with +`manage_vault_credentials`: use only the recognizable site name for `description`, and set `sensitive: false` explicitly for ordinary usernames/emails. Passwords and TOTP seeds must be sensitive. Payment-card data belongs in wallet/card items, not credentials. @@ -31,17 +31,75 @@ Collection reopens the full form without clearing values or changing version. `wait` observes readiness, not edits to ready items. Compare versions using `get` without `wait`; API updates can also change the version. -Credential creation/updates, `fill`, and `prepare_checkout` remain API/CLI-only; -MCP does not accept their write inputs. For API updates, use the current version +For `manage_vault_credentials` updates, use the current `version` and `expected_item_id` when bound to an earlier read. Clearing supported required values returns pending collection; hosted forms still require populated inputs. Fill writes real values into the browser without submitting the form. It does not isolate them from an agent with browser access. Never retry an uncertain fill or fall back to payment aliases. +### MCP credential flow + +1. Create the user's vault with `manage_vaults` (`action: "create"`, `name: "user-123"`). + Create a browser with `manage_browsers` and `vaults: [{"name":"user-123"}]`. + Vault bindings cannot be changed later. Navigate to the intended login page and inspect its inputs. +2. Call `manage_vault_credentials` with: + + ```json + { + "action": "create", + "vault": "user-123", + "key": "login", + "spec": { + "description": "Example", + "fields": { + "username": { "type": "text", "required": true, "sensitive": false }, + "password": { "type": "password", "required": true, "sensitive": true } + } + } + } + ``` + + Give `item.action.url` only to the intended user. To reopen the full form later, + use `manage_vault_items` with `action: "invoke"` and `operation: "collect"`. + +3. Observe readiness with `manage_vault_items` using `action: "get"`, the same vault/key, + and `wait: 60`. A pending response is not permission to fill; stop until ready. +4. Invoke `manage_vault_items` with the actual browser session ID and selectors + verified on that page: + + ```json + { + "action": "invoke", + "vault": "user-123", + "key": "login", + "operation": "fill", + "fill": { + "browser_id": "browser-session-id", + "page_url": "https://example.com/login", + "fields": [ + { "field": "username", "selector": "#username" }, + { "field": "password", "selector": "#password" } + ] + } + } + ``` + + The response has a value-free `result` with ordered field outcomes. `failed` and + `unknown` are tool errors, not invitations to retry; fields may already be written. + Fill does not navigate or submit. Submit separately only after confirming the fill + completed and submission is authorized. TOTP bindings send only the field name; + the API generates each current code immediately before writing, never exposing seeds. + +Updates use `action: "update"`, `version`, optional `expected_item_id`, and a `spec` +containing `description` and/or `fields: {"username":{"value":"new-name"}}`. +Definitions cannot be changed. Never solicit secret replacement values in chat; +prefer `collect` for human edits. Requests are not automatically retried. +`prepare_checkout` remains API/CLI-only. + ## Tools and scope -The five vault tools are exposed only when the current credential's +The six 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 @@ -55,13 +113,14 @@ The `vaults` toolset configuration can further restrict access, never grant it. | `manage_vaults` | `create`, `list`, `get`, `delete` | | `manage_vault_wallets` | `create`, `payment_methods` | | `manage_vault_cards` | `create`, `update` | +| `manage_vault_credentials` | `create`, `update` | | `manage_vault_items` | `list`, `get`, `invoke`, `events`, `delete` | Provider configurations are organization-owned and do not accept a project selector. Reads are available to project-scoped credentials; writes require an organization-scoped connection. The API remains the authorization authority. -The other four tools accept an optional `project` name or ID. Vaults are project-owned; +The other five tools accept an optional `project` name or ID. Vaults are project-owned; omitting `project` uses the API's effective default project, **not** all projects. Project-scoped connections cannot switch projects. Use `get_connection_context` to inspect the connection's scope. diff --git a/src/lib/mcp/register.test.ts b/src/lib/mcp/register.test.ts index 7cd97bc..41e49d5 100644 --- a/src/lib/mcp/register.test.ts +++ b/src/lib/mcp/register.test.ts @@ -127,6 +127,7 @@ describe("MCP toolset allowlist", () => { "manage_vault_provider_configs", "manage_vault_wallets", "manage_vault_cards", + "manage_vault_credentials", "manage_vault_items", "manage_vaults", ]); @@ -193,6 +194,7 @@ describe("project selection registration", () => { "manage_vaults", "manage_vault_wallets", "manage_vault_cards", + "manage_vault_credentials", "manage_vault_items", "open_auth_login", "begin_auth_login", diff --git a/src/lib/mcp/tool-names.ts b/src/lib/mcp/tool-names.ts index 72bb5c1..6d24c0d 100644 --- a/src/lib/mcp/tool-names.ts +++ b/src/lib/mcp/tool-names.ts @@ -19,6 +19,7 @@ export const KERNEL_MCP_TOOL_NAMES = [ "manage_proxies", "manage_replays", "manage_vault_cards", + "manage_vault_credentials", "manage_vault_items", "manage_vault_provider_configs", "manage_vault_wallets", diff --git a/src/lib/mcp/tools/vault-credential-flow.test.ts b/src/lib/mcp/tools/vault-credential-flow.test.ts new file mode 100644 index 0000000..4204352 --- /dev/null +++ b/src/lib/mcp/tools/vault-credential-flow.test.ts @@ -0,0 +1,480 @@ +import { describe, expect, test } from "bun:test"; +import { toolResultJSON } from "@/lib/mcp/mcp-test-fixtures"; +import { connectVaultTest, vault } from "./vaults.test-fixtures"; + +const target = { vault: "user-123", key: "login" }; +const spec = { + description: "Example", + fields: { + username: { type: "text", required: true, sensitive: false }, + password: { type: "password", required: true, sensitive: true }, + }, +}; +const pending = { + id: "item-1", + key: "login", + type: "credential", + version: 1, + spec, + state: { + status: "pending_collection", + fields: { username: { has_value: false }, password: { has_value: false } }, + }, + action: { + name: "collect", + url: "https://vault.example/collect#token=capability", + }, + available_operations: [{ type: "collect", description: "Reopen form" }], + available_expansions: [], +}; +const ready = { + ...pending, + version: 2, + state: { + status: "ready", + fields: { + username: { has_value: true, value: "secret-username" }, + password: { has_value: true }, + }, + }, + available_operations: [ + { type: "collect", description: "Reopen form" }, + { type: "fill", description: "Fill browser" }, + ], +}; +const fill = { + browser_id: "browser-1", + page_url: "https://example.com/login", + fields: [ + { field: "username", selector: "#username" }, + { field: "password", selector: "#password" }, + ], +}; +const completed = { + type: "fill", + status: "completed", + fields: [ + { index: 0, status: "filled" }, + { index: 1, status: "filled" }, + ], +}; +const invoke = { ...target, action: "invoke", operation: "fill", fill }; + +describe("MCP credential flow", () => { + test("advertises inline credential and fill schemas", async () => { + const fixture = await connectVaultTest([]); + try { + const { tools } = await fixture.client.listTools(); + const credentials = tools.find( + (tool) => tool.name === "manage_vault_credentials", + ); + const items = tools.find((tool) => tool.name === "manage_vault_items"); + expect(credentials?.inputSchema.properties).toHaveProperty("spec"); + expect(credentials?.inputSchema.properties).toHaveProperty( + "expected_item_id", + ); + expect(items?.inputSchema.properties).toHaveProperty("fill"); + expect( + JSON.stringify([credentials?.inputSchema, items?.inputSchema]), + ).not.toContain('"$ref"'); + } finally { + await fixture.close(); + } + }); + + test("fills TOTP by field name without a value or explicit page URL", async () => { + const fixture = await connectVaultTest([ + Response.json({ + ...ready, + spec: { + fields: { otp: { type: "totp", required: true, sensitive: true } }, + }, + state: { status: "ready", fields: { otp: { has_value: true } } }, + }), + Response.json({ + type: "fill", + status: "completed", + fields: [{ index: 0, status: "filled" }], + }), + ]); + try { + const parameters = { + browser_id: "browser-1", + fields: [{ field: "otp", selector: "#otp" }], + }; + const result = await fixture.call("manage_vault_items", { + ...invoke, + fill: parameters, + }); + expect(result.isError).toBe(false); + expect(fixture.requests[1].body).toEqual({ type: "fill", ...parameters }); + } finally { + await fixture.close(); + } + }); + + test.each([ + { provider: "link", page_url: "https://example.com", allowed: true }, + { provider: "link", page_url: "http://example.com", allowed: false }, + { + provider: "link", + page_url: "https://user:password@example.com", + allowed: false, + }, + { provider: "link", page_url: undefined, allowed: false }, + { provider: "agentcard", page_url: "https://example.com", allowed: false }, + ])( + "enforces card fill provider and page URL boundaries", + async ({ provider, page_url, allowed }) => { + const fixture = await connectVaultTest([ + Response.json({ ...ready, type: "card", spec: { provider } }), + Response.json(completed), + ]); + try { + const result = await fixture.call("manage_vault_items", { + ...invoke, + fill: { + ...fill, + page_url, + fields: [ + { field: "number", selector: "#number" }, + { field: "cvc", selector: "#cvc" }, + ], + }, + }); + expect(result.isError).toBe(!allowed); + expect( + fixture.requests.filter((request) => request.method === "POST"), + ).toHaveLength(allowed ? 1 : 0); + } finally { + await fixture.close(); + } + }, + ); + + test("rejects credential format before any operation write", async () => { + const fixture = await connectVaultTest([Response.json(ready)]); + try { + const result = await fixture.call("manage_vault_items", { + ...invoke, + fill: { + ...fill, + fields: [ + { field: "password", selector: "#password", format: "MM/YY" }, + ], + }, + }); + expect(result.isError).toBe(true); + expect(fixture.requests.map((request) => request.method)).toEqual([ + "GET", + ]); + } finally { + await fixture.close(); + } + }); + test("creates a vault and credential, collects, waits for readiness, and fills without exposing values", async () => { + const fixture = await connectVaultTest( + [ + Response.json(vault), + Response.json({ session_id: "browser-1" }), + Response.json(pending), + Response.json(pending), + Response.json(pending), + Response.json(ready), + Response.json(ready), + Response.json({ + ...completed, + value: "secret-password", + fields: completed.fields.map((field) => ({ + ...field, + value: "secret-password", + })), + }), + ], + undefined, + true, + ); + try { + expect( + ( + await fixture.call("manage_vaults", { + action: "create", + name: target.vault, + }) + ).isError, + ).toBeUndefined(); + const browser = await fixture.call("manage_browsers", { + action: "create", + vaults: [{ name: target.vault }], + headless: false, + }); + expect(browser.isError).toBeUndefined(); + expect(fixture.requests[1].body).toMatchObject({ + vaults: [{ name: target.vault }], + }); + const created = toolResultJSON( + await fixture.call("manage_vault_credentials", { + ...target, + action: "create", + spec, + }), + ); + expect(created.item.action.url).toBe(pending.action.url); + expect(created.item.spec.fields).toEqual(spec.fields); + expect( + ( + await fixture.call("manage_vault_items", { + ...target, + action: "invoke", + operation: "collect", + }) + ).isError, + ).toBeUndefined(); + const observed = toolResultJSON( + await fixture.call("manage_vault_items", { + ...target, + action: "get", + wait: 60, + }), + ); + expect(observed.item.state.status).toBe("ready"); + expect(JSON.stringify(observed)).not.toContain("secret-username"); + const result = await fixture.call("manage_vault_items", invoke); + expect(result.isError).toBe(false); + expect(toolResultJSON(result).result).toEqual(completed); + expect(JSON.stringify(result)).not.toContain("secret-password"); + expect(fixture.requests.map(({ method }) => method)).toEqual([ + "POST", + "POST", + "PUT", + "GET", + "POST", + "GET", + "GET", + "POST", + ]); + expect(fixture.requests[2].body).toEqual({ type: "credential", spec }); + expect(fixture.requests.at(-1)?.body).toEqual({ type: "fill", ...fill }); + expect(fixture.requests[5].path).toContain("wait=60"); + } finally { + await fixture.close(); + } + }); + + test("updates with version and immutable identity, preserving null/empty clearing", async () => { + const fixture = await connectVaultTest([Response.json(pending)]); + try { + const update = { + description: "Example", + fields: { username: { value: "" }, password: { value: null } }, + }; + const result = await fixture.call("manage_vault_credentials", { + ...target, + action: "update", + version: 2, + expected_item_id: "item-1", + spec: update, + }); + expect(result.isError).toBeUndefined(); + expect(fixture.requests[0].method).toBe("PATCH"); + expect(fixture.requests[0].body).toEqual({ + type: "credential", + version: 2, + expected_item_id: "item-1", + spec: update, + }); + } finally { + await fixture.close(); + } + }); + + test("redacts supplied initial values even if echoed in metadata", async () => { + const secret = "private-password-value"; + const fixture = await connectVaultTest([ + Response.json({ ...pending, spec: { ...spec, description: secret } }), + ]); + try { + const result = await fixture.call("manage_vault_credentials", { + ...target, + action: "create", + spec: { + ...spec, + fields: { + password: { type: "password", sensitive: true, value: secret }, + }, + }, + }); + expect(result.isError).toBeUndefined(); + expect(JSON.stringify(result)).not.toContain(secret); + expect(fixture.requests[0].body).toHaveProperty( + "spec.fields.password.value", + secret, + ); + } finally { + await fixture.close(); + } + }); + + test.each([ + { action: "update", spec: { description: "Example" } }, + { action: "create", version: 1, spec }, + { action: "create", expected_item_id: "item-1", spec }, + { action: "create", spec: { fields: {} } }, + { + action: "create", + spec: { fields: { password: { type: "password", sensitive: false } } }, + }, + { + action: "create", + spec: { + fields: { username: { type: "text", private_key: "secret-value" } }, + }, + }, + { + action: "update", + version: 1, + spec: { fields: { username: { type: "text", value: "secret-value" } } }, + }, + { action: "update", version: 1, spec: {} }, + ])( + "rejects invalid credential writes without HTTP requests", + async (args) => { + const fixture = await connectVaultTest([]); + try { + const result = await fixture.call("manage_vault_credentials", { + ...target, + ...args, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).not.toContain("secret-value"); + expect(fixture.requests).toHaveLength(0); + } finally { + await fixture.close(); + } + }, + ); + + for (const operation of ["create", "update", "fill"] as const) { + test.each([409, 429, 500, "transport"])( + `${operation} does not retry %s`, + async (failure) => { + const reply = + failure === "transport" + ? new Error("private-transport-error") + : Response.json( + { message: "private-upstream-error" }, + { status: Number(failure) }, + ); + const fixture = await connectVaultTest( + operation === "fill" ? [Response.json(ready), reply] : [reply], + ); + try { + const result = + operation === "fill" + ? await fixture.call("manage_vault_items", invoke) + : await fixture.call("manage_vault_credentials", { + ...target, + action: operation, + ...(operation === "update" + ? { version: 2, spec: { description: "Example" } } + : { spec }), + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).not.toContain("private-"); + expect( + fixture.requests.filter(({ method }) => method !== "GET"), + ).toHaveLength(1); + if (operation === "fill") + expect(JSON.stringify(result)).toContain( + "Never automatically retry", + ); + } finally { + await fixture.close(); + } + }, + ); + } + + test.each(["failed", "unknown"])( + "preserves ordered %s fill outcomes without retry", + async (status) => { + const outcome = { + type: "fill", + status, + fields: [ + { index: 0, status: "filled" }, + { index: 1, status, error_code: "timeout", value: "secret-value" }, + ], + }; + const fixture = await connectVaultTest([ + Response.json(ready), + Response.json(outcome), + ]); + try { + const result = await fixture.call("manage_vault_items", invoke); + expect(result.isError).toBe(true); + expect(toolResultJSON(result).result.status).toBe(status); + expect(toolResultJSON(result).result.fields[0]).toEqual({ + index: 0, + status: "filled", + }); + expect(JSON.stringify(result)).not.toContain("secret-value"); + expect(fixture.requests).toHaveLength(2); + } finally { + await fixture.close(); + } + }, + ); + + test("checks current availability before fill", async () => { + const fixture = await connectVaultTest([Response.json(pending)]); + try { + expect((await fixture.call("manage_vault_items", invoke)).isError).toBe( + true, + ); + expect(fixture.requests.map(({ method }) => method)).toEqual(["GET"]); + } finally { + await fixture.close(); + } + }); + + test.each([ + { ...fill, fields: [] }, + { + ...fill, + fields: [ + { field: "password", selector: "#password", value: "secret-value" }, + ], + }, + { ...fill, frame_id: "frame-1" }, + { ...fill, timeout_ms: 30001 }, + ])("rejects invalid fill inputs before requests", async (parameters) => { + const fixture = await connectVaultTest([]); + try { + const result = await fixture.call("manage_vault_items", { + ...invoke, + fill: parameters, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).not.toContain("secret-value"); + expect(fixture.requests).toHaveLength(0); + } finally { + await fixture.close(); + } + }); + + test("does not treat a malformed fill response as a successful item", async () => { + const fixture = await connectVaultTest([ + Response.json(ready), + Response.json(ready), + ]); + try { + const result = await fixture.call("manage_vault_items", invoke); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toContain("may have been written"); + expect(JSON.stringify(result)).not.toContain("secret-username"); + } finally { + await fixture.close(); + } + }); +}); diff --git a/src/lib/mcp/tools/vault-credentials.ts b/src/lib/mcp/tools/vault-credentials.ts new file mode 100644 index 0000000..56c5781 --- /dev/null +++ b/src/lib/mcp/tools/vault-credentials.ts @@ -0,0 +1,150 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { VaultItem } from "@onkernel/sdk/resources/vaults/items"; +import type { McpDependencies } from "@/lib/mcp/dependencies"; +import { projectForOperation } from "@/lib/mcp/project-selection"; +import { errorResponse } from "@/lib/mcp/responses"; +import { throwVaultError, vaultItemResponse } from "@/lib/mcp/vault-responses"; +import { + vaultItemSchema, + vaultKeySchema, + vaultToolInput, +} from "@/lib/mcp/vault-schemas"; + +const text = () => + z.string().refine((value) => Buffer.byteLength(value, "utf8") <= 16384); +const fieldName = () => z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/); +const definition = z + .object({ + type: z.enum(["text", "email", "password", "totp"]), + required: z.boolean().optional(), + sensitive: z.boolean().optional(), + value: text().optional(), + }) + .strict() + .refine( + (field) => + !(["password", "totp"].includes(field.type) && field.sensitive === false), + ); +const createSpec = z + .object({ + description: text().optional(), + fields: z + .record(fieldName(), definition) + .refine( + (fields) => + Object.keys(fields).length >= 1 && Object.keys(fields).length <= 32, + ), + }) + .strict(); +const updateSpec = z + .object({ + description: text().optional(), + fields: z + .record(fieldName(), z.object({ value: text().nullable() }).strict()) + .refine( + (fields) => + Object.keys(fields).length >= 1 && Object.keys(fields).length <= 32, + ) + .optional(), + }) + .strict() + .refine( + (spec) => spec.description !== undefined || spec.fields !== undefined, + ); + +export function registerVaultCredentialTools( + server: McpServer, + dependencies: McpDependencies, +) { + server.tool( + "manage_vault_credentials", + "Create or update credential items in a per-end-user vault. Use only the recognizable site name as description; explicitly set sensitive:false for ordinary usernames/emails. Passwords and TOTP seeds must be sensitive. Never store payment-card data here. For human collection, omit values and present the returned bearer collection URL privately to the intended user, outside the agent-controlled browser. Never ask for passwords or TOTP seeds in chat. TOTP seeds require trusted provisioning and have no hosted input. Create definitions with fields keyed by name; update accepts only description and fields containing value. Updates require the latest version and optionally expected_item_id from an earlier read; definitions are immutable. Omitted values are preserved; null or empty strings clear supported values. Clearing required TOTP is unsupported. Hosted forms require populated required inputs. Use manage_vault_items get with wait for readiness, then invoke fill with fill parameters. For edits to already-ready items compare versions without wait. All stored values are omitted from responses. Writes are never automatically retried; reconcile conflicts or uncertain outcomes before any further write.", + vaultToolInput({ + ...vaultItemSchema, + key: vaultKeySchema(), + action: z.enum(["create", "update"]), + spec: z + .union([createSpec, updateSpec]) + .refine( + (spec) => + Buffer.byteLength(JSON.stringify(spec), "utf8") <= 128 * 1024, + ), + version: z + .number() + .int() + .safe() + .positive() + .describe("Required for update; current item version.") + .optional(), + expected_item_id: z + .string() + .min(1) + .describe( + "Update-only immutable identity precondition from an earlier read.", + ) + .optional(), + }), + { + title: "Configure Kernel vault credentials", + 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.action === "create" && + (params.version !== undefined || + params.expected_item_id !== undefined) + ) + return errorResponse("version and expected_item_id are update-only."); + let item: VaultItem; + if (params.action === "create") { + item = await client.vaults.items.upsert( + params.key, + { + id_or_name: params.vault, + type: "credential", + spec: createSpec.parse(params.spec), + }, + options, + ); + } else { + if (params.version === undefined) + return errorResponse("version is required for update."); + item = await client.vaults.items.update( + params.key, + { + id_or_name: params.vault, + type: "credential", + version: params.version, + ...(params.expected_item_id !== undefined && { + expected_item_id: params.expected_item_id, + }), + spec: updateSpec.parse(params.spec), + }, + options, + ); + } + return vaultItemResponse( + item, + { project, vault: params.vault, key: params.key }, + Object.values(params.spec.fields ?? {}).map( + (field) => field.value ?? undefined, + ), + ); + } catch (error) { + throwVaultError("manage_vault_credentials", params.action, error); + } + }, + ); +} diff --git a/src/lib/mcp/tools/vault-items.test.ts b/src/lib/mcp/tools/vault-items.test.ts index eef6dd7..cc60b92 100644 --- a/src/lib/mcp/tools/vault-items.test.ts +++ b/src/lib/mcp/tools/vault-items.test.ts @@ -126,12 +126,13 @@ describe("advertised vault operations", () => { }); expect(result.isError).toBe(true); expect(JSON.stringify(result)).toContain( - `${operation} requires additional inputs`, + operation === "fill" + ? "fill parameters are required" + : `${operation} requires additional inputs`, + ); + expect(fixture.requests.map((request) => request.method)).toEqual( + operation === "fill" ? ["GET"] : ["GET", "GET"], ); - expect(fixture.requests.map((request) => request.method)).toEqual([ - "GET", - "GET", - ]); } finally { await fixture.close(); } diff --git a/src/lib/mcp/tools/vault-items.ts b/src/lib/mcp/tools/vault-items.ts index 312895d..eccbc1a 100644 --- a/src/lib/mcp/tools/vault-items.ts +++ b/src/lib/mcp/tools/vault-items.ts @@ -1,6 +1,11 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { APIError } from "@onkernel/sdk"; import { z } from "zod"; +import { + vaultFillSchema, + vaultFillResponse, + unconfirmedVaultFillResponse, +} from "@/lib/mcp/vault-fill"; import type { McpDependencies } from "@/lib/mcp/dependencies"; import { projectForOperation } from "@/lib/mcp/project-selection"; import { longOperationOptions } from "@/lib/mcp/request-options"; @@ -27,7 +32,7 @@ export function registerVaultItemTools( ) { server.tool( "manage_vault_items", - 'Inspect credential and payment vault items and immutable audit events. "list" reads items without renewing collection links; "get" reads state, safe field metadata, version, required user actions, available_operations, and available_expansions. MCP omits all stored credential values, even non-sensitive ones. For credentials, present the collection URL only to the intended user, outside the agent-controlled browser; never ask for passwords or TOTP seeds in chat. "collect" reopens the full form without clearing values or changing version; TOTP has no hosted input. wait observes readiness, not edits to ready credentials: compare versions using get without wait. Credential creation and updates require the Kernel API or CLI; use a per-user vault, site-name-only description, and sensitive:false for ordinary usernames/emails. Never store credit card data in credential items. "invoke" fetches the item again and submits only an advertised operation; read its description and obtain explicit user approval first. Provider actions (OAuth, enrollment, MFA, approval) must be completed by the user, not invoked as operations. "events" observes outcomes; use the last event ID as after. "delete" invalidates an item credential; confirm with the user first. Unresolved payments can block item and parent deletion; the API decides whether explicit abandonment is allowed, and deletion never proves a payment did not occur. recovery_required is not decline or expiry: stop payment attempts and reconcile with the provider or support; no reset exists. Credential ready means required values exist, not that login succeeded; payment ready does not mean paid. fill and prepare_checkout require additional inputs and are API-only here; never substitute another operation or retry an uncertain attempt. Requests are never automatically retried. Do not retry failed, timed-out, rejected, or indeterminate payments; inspect state/events instead.', + 'Inspect credential and payment vault items and immutable audit events. "list" reads items without renewing collection links; "get" reads state, safe field metadata, version, required user actions, available_operations, and available_expansions. MCP omits all stored credential values, even non-sensitive ones. For credentials, present the collection URL only to the intended user, outside the agent-controlled browser; never ask for passwords or TOTP seeds in chat. "collect" reopens the full form without clearing values or changing version; TOTP has no hosted input. wait observes readiness, not edits to ready credentials: compare versions using get without wait. Use manage_vault_credentials for credential creation and updates; use a per-user vault, site-name-only description, and sensitive:false for ordinary usernames/emails. Never store credit card data in credential items. "invoke" fetches the item again and submits only an advertised operation; read its description and obtain explicit user approval first. Provider actions (OAuth, enrollment, MFA, approval) must be completed by the user, not invoked as operations. "events" observes outcomes; use the last event ID as after. "delete" invalidates an item credential; confirm with the user first. Unresolved payments can block item and parent deletion; the API decides whether explicit abandonment is allowed, and deletion never proves a payment did not occur. recovery_required is not decline or expiry: stop payment attempts and reconcile with the provider or support; no reset exists. Credential ready means required values exist, not that login succeeded; payment ready does not mean paid. For fill, supply the fill object with browser_id and ordered field/selector bindings; values stay server-side until entering the browser. prepare_checkout remains API-only here; never substitute another operation or retry an uncertain attempt. Requests are never automatically retried. Do not retry failed, timed-out, rejected, or indeterminate payments; inspect state/events instead.', vaultToolInput({ ...vaultItemSchema, action: z.enum(["list", "get", "invoke", "events", "delete"]), @@ -39,9 +44,14 @@ export function registerVaultItemTools( .min(1) .refine((value) => value.trim().length > 0) .describe( - "(invoke) Type advertised in available_operations. Only operations requiring no extra inputs are supported; fill and prepare_checkout require the Kernel API. Availability is API-controlled, not inferred from provider or state.", + "(invoke) Type advertised in available_operations. For fill, supply the fill object. prepare_checkout still requires the Kernel API. Availability is API-controlled, not inferred from provider or state.", ) .optional(), + fill: vaultFillSchema + .optional() + .describe( + "(invoke fill only) Value-free field bindings. Authorize the destination; each selector must resolve uniquely across all frames. Credentials forbid format; cards require HTTPS page_url. No navigation, submission, rollback, or automatic retry.", + ), expand: z .array(z.enum(["payment_methods"])) .describe( @@ -82,6 +92,19 @@ export function registerVaultItemTools( "wait is only supported for get and events; invoke does not wait for collection or authorization.", ); } + if ( + params.fill !== undefined && + (params.action !== "invoke" || params.operation !== "fill") + ) + return errorResponse( + "fill parameters are only supported for invoke fill.", + ); + if ( + params.action === "invoke" && + params.operation === "fill" && + params.fill === undefined + ) + return errorResponse("fill parameters are required for invoke fill."); if (params.action === "list") { const items = await client.vaults.items.list(params.vault, options); return jsonResponse({ @@ -122,6 +145,35 @@ export function registerVaultItemTools( return errorResponse( "Operation is not advertised in available_operations. Inspect the item before taking further action.", ); + if (operation.type === "fill" && params.fill) { + if ( + item.type === "credential" && + params.fill.fields.some((field) => field.format !== undefined) + ) + return errorResponse("Credential fields must omit format."); + if ( + item.type !== "credential" && + (item.type !== "card" || + item.spec.provider !== "link" || + !params.fill.page_url || + new URL(params.fill.page_url).protocol !== "https:" || + new URL(params.fill.page_url).username || + new URL(params.fill.page_url).password) + ) + return errorResponse( + "Fill requires a credential or Link card; cards require an HTTPS page_url without credentials.", + ); + try { + const result = await client.vaults.items.performOperation( + params.key, + { id_or_name: params.vault, type: "fill", ...params.fill }, + options, + ); + return vaultFillResponse(result, params.fill.fields.length); + } catch { + return unconfirmedVaultFillResponse(); + } + } if (vaultOperationRequiresInputs(operation.type)) { return errorResponse( `${operation.type} requires additional inputs not supported by this tool. Use the Kernel API for this operation.`, diff --git a/src/lib/mcp/tools/vaults.test-fixtures.ts b/src/lib/mcp/tools/vaults.test-fixtures.ts index 606fc7e..61b78a4 100644 --- a/src/lib/mcp/tools/vaults.test-fixtures.ts +++ b/src/lib/mcp/tools/vaults.test-fixtures.ts @@ -5,6 +5,8 @@ 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"; +import { registerBrowserCapabilities } from "@/lib/mcp/tools/browsers"; +import type { McpDependencies } from "@/lib/mcp/dependencies"; export const vault = { id: "vlt_123", @@ -51,12 +53,13 @@ type RequestRecord = { }; export async function connectVaultTest( - replies: Response[], + replies: Array, authInfo: AuthInfo | null = projectScopedAuthInfo(), + includeBrowsers = false, ) { const requests: RequestRecord[] = []; const server = new McpServer({ name: "vault-test", version: "0.0.0" }); - registerVaultCapabilities(server, { + const dependencies: McpDependencies = { createKernelClient: (token, project) => new Kernel({ apiKey: token, @@ -72,8 +75,10 @@ export async function connectVaultTest( headers: request.headers, ...(text && { body: JSON.parse(text) }), }); + const reply = replies.shift(); + if (reply instanceof Error) throw reply; return ( - replies.shift() ?? + reply ?? Response.json( { message: "Unexpected extra request" }, { status: 500 }, @@ -81,7 +86,9 @@ export async function connectVaultTest( ); }, }), - }); + }; + registerVaultCapabilities(server, dependencies); + if (includeBrowsers) registerBrowserCapabilities(server, dependencies); const client = new Client({ name: "vault-test-client", version: "0.0.0" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); diff --git a/src/lib/mcp/tools/vaults.test.ts b/src/lib/mcp/tools/vaults.test.ts index 5ff6ebf..74b20bd 100644 --- a/src/lib/mcp/tools/vaults.test.ts +++ b/src/lib/mcp/tools/vaults.test.ts @@ -19,6 +19,7 @@ describe("vault SDK request contracts", () => { const { tools } = await fixture.client.listTools(); expect(tools.map((tool) => tool.name).sort()).toEqual([ "manage_vault_cards", + "manage_vault_credentials", "manage_vault_items", "manage_vault_provider_configs", "manage_vault_wallets", diff --git a/src/lib/mcp/tools/vaults.ts b/src/lib/mcp/tools/vaults.ts index b7444d9..e3a65ed 100644 --- a/src/lib/mcp/tools/vaults.ts +++ b/src/lib/mcp/tools/vaults.ts @@ -22,6 +22,7 @@ import { vaultSelectorSchema, vaultToolInput, } from "@/lib/mcp/vault-schemas"; +import { registerVaultCredentialTools } from "@/lib/mcp/tools/vault-credentials"; import { registerVaultWalletTools } from "@/lib/mcp/tools/vault-wallets"; import { registerVaultCardTools } from "@/lib/mcp/tools/vault-cards"; import { registerVaultItemTools } from "@/lib/mcp/tools/vault-items"; @@ -34,11 +35,12 @@ export function registerVaultCapabilities( registerVaultProviderConfigTools(server, dependencies); registerVaultWalletTools(server, dependencies); registerVaultCardTools(server, dependencies); + registerVaultCredentialTools(server, dependencies); registerVaultItemTools(server, dependencies); server.tool( "manage_vaults", - 'Manage project-owned vaults for end-user credentials and payment items. Use a separate vault per end user, with an immutable name such as user-123; do not mix unrelated users. Vaults store credentials, not authenticated browser sessions, and do not submit website forms or merchant payments. "create" creates or retrieves a vault by immutable name; "list" lists the effective project only; "get" reads one; "delete" invalidates the vault and every item credential. Confirm deletion with the user first; unresolved payment operations block deletion and require provider/support reconciliation. Connect a payment wallet with manage_vault_wallets, configure a card with manage_vault_cards, and inspect credentials or payment items with manage_vault_items. Credential creation and updates use the Kernel API or CLI, not the wallet/card tools. For credentials, use only the recognizable site name as description and set sensitive:false explicitly for ordinary usernames/emails; passwords and TOTP seeds must be sensitive. Never put credit card data in credential items. Attach vaults when creating a browser; bindings cannot change later. Requests are not automatically retried.', + 'Manage project-owned vaults for end-user credentials and payment items. Use a separate vault per end user, with an immutable name such as user-123; do not mix unrelated users. Vaults store credentials, not authenticated browser sessions, and do not submit website forms or merchant payments. "create" creates or retrieves a vault by immutable name; "list" lists the effective project only; "get" reads one; "delete" invalidates the vault and every item credential. Confirm deletion with the user first; unresolved payment operations block deletion and require provider/support reconciliation. Connect a payment wallet with manage_vault_wallets, configure a card with manage_vault_cards, and inspect credentials or payment items with manage_vault_items. Use manage_vault_credentials to create definitions or update values, then manage_vault_items to collect, observe readiness, and invoke fill with value-free bindings. For credentials, use only the recognizable site name as description and set sensitive:false explicitly for ordinary usernames/emails; passwords and TOTP seeds must be sensitive. Never put credit card data in credential items. Attach vaults when creating a browser; bindings cannot change later. Requests are not automatically retried.', vaultToolInput({ ...vaultProjectSchema, action: z.enum(["create", "list", "get", "delete"]), diff --git a/src/lib/mcp/vault-fill.ts b/src/lib/mcp/vault-fill.ts new file mode 100644 index 0000000..9936b21 --- /dev/null +++ b/src/lib/mcp/vault-fill.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; +import { jsonResponse, errorResponse } from "@/lib/mcp/responses"; + +export const vaultFillSchema = z + .object({ + browser_id: z + .string() + .min(1) + .describe( + "Browser session ID, not a reusable name. The vault must already be attached.", + ), + page_url: z + .string() + .url() + .regex(/^\S+$/) + .optional() + .describe( + "Exact existing top-level page URL; never navigates. Optional only for credentials with exactly one open page.", + ), + fields: z + .array( + z + .object({ + field: z + .string() + .min(1) + .describe( + "Declared credential field name or supported card field, not a value.", + ), + selector: z.string().min(1), + format: z + .enum(["MM/YY", "MM/YYYY"]) + .optional() + .describe( + "Only for a card's combined expiration field. Forbidden for credential fields.", + ), + }) + .strict(), + ) + .min(1) + .max(32), + timeout_ms: z.number().int().min(1).max(30000).optional(), + }) + .strict() + .refine( + (fill) => Buffer.byteLength(JSON.stringify(fill), "utf8") <= 128 * 1024, + ); + +const resultSchema = z.object({ + type: z.literal("fill"), + status: z.enum(["completed", "failed", "unknown"]), + fields: z.array( + z.object({ + index: z.number().int().min(0), + status: z.enum(["filled", "failed", "unknown", "not_attempted"]), + error_code: z + .enum([ + "target_changed", + "element_not_found", + "ambiguous_selector", + "element_not_editable", + "option_not_found", + "timeout", + "execution_failed", + ]) + .optional(), + }), + ), +}); + +export function unconfirmedVaultFillResponse() { + return errorResponse( + "Fill did not return a confirmed result; browser fields may have been written. Inspect the browser. Never automatically retry or fall back to aliases.", + ); +} + +export function vaultFillResponse(value: unknown, count: number) { + const parsed = resultSchema.safeParse(value); + if ( + !parsed.success || + parsed.data.fields.length !== count || + parsed.data.fields.some((field, index) => field.index !== index) || + (parsed.data.status === "completed" && + parsed.data.fields.some((field) => field.status !== "filled")) + ) + return unconfirmedVaultFillResponse(); + return { + ...jsonResponse({ + result: parsed.data, + guidance: + "Fill never submits or navigates. Completed means fields were filled, not website acceptance. Real values enter the browser and can be observed by an agent with browser access. Failed or unknown may leave partial writes; inspect the browser and never automatically retry or fall back to aliases.", + }), + isError: parsed.data.status !== "completed", + }; +} diff --git a/src/lib/mcp/vault-responses.ts b/src/lib/mcp/vault-responses.ts index fdfc9f9..fea871d 100644 --- a/src/lib/mcp/vault-responses.ts +++ b/src/lib/mcp/vault-responses.ts @@ -263,14 +263,14 @@ export function vaultItemResponse( "Present the collection URL only to the intended user in a private surface, outside the agent-controlled browser. It is a bearer credential. Never ask for passwords or TOTP seeds in chat; TOTP seeds require trusted backend provisioning, not hosted collection.", "MCP returns field definitions, has_value, version, and collection expiry, never stored field values. Ready means required values exist, not that login succeeded. Listing does not renew collection links; use get or the advertised collect operation.", "collect reopens the full form without clearing values or changing readiness or version. wait observes readiness, not edits to ready items. Compare versions with get without wait; a change can also come from an API update, so it does not identify a specific form submission.", - "Create or update credentials through the Kernel API or CLI. Use a per-user vault, a recognizable site-name-only description, and sensitive:false for usernames/emails. Passwords and TOTP must be sensitive. Updates require the current version; supply expected_item_id when bound to an earlier read. Omitted values remain; null or empty strings clear supported fields, including required text/email/password fields. Hosted forms still require populated required inputs. Do not store payment-card data in credential items.", - "Invocation hints are not approval to execute. fill is API-only in this MCP server: bind the vault at browser creation, authorize the destination, and follow the advertised description. Fill does not submit or navigate; real values enter the browser and may be read by an agent with browser access. Never retry an uncertain fill or fall back to aliases.", + "Create or update credentials with manage_vault_credentials. Use a per-user vault, a recognizable site-name-only description, and sensitive:false for usernames/emails. Passwords and TOTP must be sensitive. Updates require the current version; supply expected_item_id when bound to an earlier read. Omitted values remain; null or empty strings clear supported fields, including required text/email/password fields. Hosted forms still require populated required inputs. Do not store payment-card data in credential items.", + "Invocation hints are not approval to execute. Invoke fill with manage_vault_items using a fill object containing browser_id and ordered fields of field/selector bindings, never values. Bind the vault at browser creation, authorize the destination, and follow the advertised description. Fill does not submit or navigate; real values enter the browser and may be read by an agent with browser access. Never retry an uncertain fill or fall back to aliases.", ] : [ "Ask the user to complete returned provider actions. Never request card data or OAuth codes/tokens in chat; imported grants must come from a trusted backend. Read operation descriptions and obtain explicit user approval before invoking.", - "Fill is the preferred browser-checkout path when advertised, but requires the Kernel API because this tool does not accept fill inputs. Aliases are an alternative only for explicitly chosen egress-substitution integrations in a browser created with this vault attached, respecting returned permitted domains. Never fall back to aliases after an uncertain fill. Ready does not mean paid.", + "Fill is the preferred browser-checkout path when advertised: use manage_vault_items invoke with operation fill and a fill object containing browser_id, exact HTTPS page_url, and ordered field/selector bindings. Aliases are an alternative only for explicitly chosen egress-substitution integrations in a browser created with this vault attached, respecting returned permitted domains. Never fall back to aliases after an uncertain fill. 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. API-advertised fill and prepare_checkout operations require additional inputs and must use the Kernel API, not this tool.", + "Invocation hints are not approval to execute. Availability may change; invoke rechecks the advertised operations. Fill requires caller-chosen bindings in the fill object, so no ready-to-run invocation hint is emitted. prepare_checkout still requires the Kernel API.", "For API-only prepare_checkout, deliver the returned approval URL and keep the approval page open. Poll the item until ready_to_submit, then submit native Pay before state.preparation.expires_at. Readiness lasts at most 30 seconds; polling does not extend it. Preparations are single-use even after failure or expiry. Preparation consumed means claimed, not payment success.", "recovery_required is an unresolved original outcome, not decline or expiry. Stop payment attempts; reconcile with the provider or support. No reset exists, and deletion may be blocked for this item and its parents.", ], @@ -326,7 +326,7 @@ export function throwVaultError( throwToolError( tool, action, - new Error("spec must match the selected provider's documented schema"), + new Error("spec must match the selected action's documented schema"), ); } if (error instanceof APIError && typeof error.status === "number") { diff --git a/src/lib/mcp/vault-steering.test.ts b/src/lib/mcp/vault-steering.test.ts index c7a0e46..6abc436 100644 --- a/src/lib/mcp/vault-steering.test.ts +++ b/src/lib/mcp/vault-steering.test.ts @@ -53,7 +53,7 @@ const credential = { }; describe("vault OpenAPI steering", () => { - test("tool discovery steers per-user credential collection without advertising unsupported writes", async () => { + test("tool discovery exposes credential creation and steers per-user collection", async () => { const fixture = await connectVaultTest([]); try { const { tools } = await fixture.client.listTools(); @@ -63,7 +63,7 @@ describe("vault OpenAPI steering", () => { expect(vaults?.description).toContain("sensitive:false"); expect(items?.description).toContain("without renewing collection links"); expect(items?.description).toContain("API-only"); - expect(tools.map(({ name }) => name)).not.toContain( + expect(tools.map(({ name }) => name)).toContain( "manage_vault_credentials", ); expect(fixture.requests).toEqual([]); @@ -104,7 +104,7 @@ describe("vault OpenAPI steering", () => { "expected_item_id", "site-name-only", "wait observes readiness", - "API-only", + "manage_vault_credentials", "Never retry an uncertain fill", ]) expect(guidance).toContain(text); From 58601b061a7cfc8b59c11fc504e9eacbe0adceba Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:19:10 +0000 Subject: [PATCH 3/5] Preserve public credential values and actionable fill errors --- README.md | 2 +- docs/vault-payments.md | 10 +- .../mcp/tools/vault-credential-flow.test.ts | 113 ++++++++++++++++-- src/lib/mcp/tools/vault-credentials.ts | 43 +++++-- src/lib/mcp/tools/vault-items.ts | 40 ++----- src/lib/mcp/vault-fill.ts | 108 ++++++++++++++++- src/lib/mcp/vault-responses.ts | 47 +++++++- src/lib/mcp/vault-steering.test.ts | 42 ++++++- 8 files changed, 348 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 4b2ee0f..734b371 100644 --- a/README.md +++ b/README.md @@ -321,7 +321,7 @@ Call `get_connection_context` before deciding whether to create or select a proj - `manage_vault_wallets` - Connect Kernel-managed or configured Link/AgentCard wallets, import Link grants from a trusted backend, and inspect live payment methods. - `manage_vault_cards` - Create or update card requests according to the API's lifecycle rules; does not implicitly authorize Link cards. - `manage_vault_credentials` - Create credential definitions for private human collection; update values or description with version and optional immutable item identity preconditions. -- `manage_vault_items` - List, get, invoke advertised operations (including fill with value-free bindings), observe events, and delete vault items. Read credential definitions, presence, version, and collection links without stored values. `collect` reopens the full form; provider approvals remain user actions. Ready is not login or payment success. +- `manage_vault_items` - List, get, invoke advertised operations (including fill with value-free bindings), observe events, and delete vault items. Read credential definitions, presence, version, collection links, and explicitly non-sensitive values; sensitive values remain hidden. `collect` reopens the full form; provider approvals remain user actions. Ready is not login or payment success. 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 six 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. Credential create → collect → readiness → fill is supported entirely through MCP tools. `prepare_checkout` remains API/CLI-only. The SDK dependency is pinned in `bun.lock`. diff --git a/docs/vault-payments.md b/docs/vault-payments.md index dc66da1..5b2e2d4 100644 --- a/docs/vault-payments.md +++ b/docs/vault-payments.md @@ -21,8 +21,9 @@ set `sensitive: false` explicitly for ordinary usernames/emails. Passwords and T seeds must be sensitive. Payment-card data belongs in wallet/card items, not credentials. `manage_vault_items` can read existing credential items and invoke advertised `collect`. -It returns field definitions, `has_value`, version, and collection-link expiry, but -omits all stored values, even non-sensitive ones. Share the bearer collection link +It returns field definitions, `has_value`, version, collection-link expiry, and +explicitly non-sensitive text/email values. Sensitive values and TOTP seeds are +omitted. Share the bearer collection link only with the intended user, outside the agent-controlled browser. Never request a password or TOTP seed in chat; hosted collection cannot accept TOTP seeds. @@ -87,6 +88,11 @@ fall back to payment aliases. The response has a value-free `result` with ordered field outcomes. `failed` and `unknown` are tool errors, not invitations to retry; fields may already be written. + API validation errors (400/403/404/409) retain HTTP status and recognized error codes, + with actionable explanations and confirmation that this request wrote no fields. + Inspect and correct the cause before deciding on a new fill. Transport loss and + other uncertain failures retain the no-retry warning. Raw upstream error bodies + are never returned. Fill does not navigate or submit. Submit separately only after confirming the fill completed and submission is authorized. TOTP bindings send only the field name; the API generates each current code immediately before writing, never exposing seeds. diff --git a/src/lib/mcp/tools/vault-credential-flow.test.ts b/src/lib/mcp/tools/vault-credential-flow.test.ts index 4204352..3844579 100644 --- a/src/lib/mcp/tools/vault-credential-flow.test.ts +++ b/src/lib/mcp/tools/vault-credential-flow.test.ts @@ -61,6 +61,96 @@ const completed = { const invoke = { ...target, action: "invoke", operation: "fill", fill }; describe("MCP credential flow", () => { + test.each(["create", "update"])( + "%s preserves non-sensitive values, collection URLs, and hints", + async (action) => { + const url = `https://vault.example/collect#token=${target.vault}.token`; + const response = { + ...ready, + action: { name: "collect", url }, + state: { + ...ready.state, + fields: { + username: { has_value: true, value: target.vault }, + password: { has_value: true, value: "private-password" }, + }, + }, + }; + const fixture = await connectVaultTest([Response.json(response)]); + try { + const result = toolResultJSON( + await fixture.call("manage_vault_credentials", { + ...target, + action, + ...(action === "update" + ? { + version: 2, + spec: { fields: { username: { value: target.vault } } }, + } + : { + spec: { + ...spec, + fields: { + ...spec.fields, + username: { + ...spec.fields.username, + value: target.vault, + }, + }, + }, + }), + }), + ); + expect(result.item.state.fields.username.value).toBe(target.vault); + expect(result.item.action.url).toBe(url); + expect(result.hints.observation.length).toBeGreaterThan(0); + expect(result.hints.invocation.length).toBeGreaterThan(0); + expect(JSON.stringify(result)).not.toContain("private-password"); + } finally { + await fixture.close(); + } + }, + ); + + test.each([ + { status: 400, code: "ambiguous_selector", message: "multiple targets" }, + { status: 403, code: "destination_denied", message: "not authorized" }, + { status: 404, code: "not_found", message: "not found" }, + { status: 409, code: "conflict", message: "not ready" }, + { + status: 400, + code: "field_unavailable", + message: "no usable stored value", + }, + { + status: 400, + code: "private-unknown-code", + message: "Fill request failed", + }, + ])( + "preserves meaningful pre-write fill errors ($status $code)", + async ({ status, code, message }) => { + const fixture = await connectVaultTest([ + Response.json(ready), + Response.json({ code, message: "private-upstream-secret" }, { status }), + ]); + try { + const result = await fixture.call("manage_vault_items", invoke); + const text = JSON.stringify(result); + expect(result.isError).toBe(true); + expect(text).toContain(String(status)); + expect(text).toContain(message); + expect(text).toContain("No fields were written"); + expect(text).not.toContain("may have been written"); + expect(text).not.toContain("private-"); + expect( + fixture.requests.filter((request) => request.method === "POST"), + ).toHaveLength(1); + } finally { + await fixture.close(); + } + }, + ); test("advertises inline credential and fill schemas", async () => { const fixture = await connectVaultTest([]); try { @@ -124,11 +214,13 @@ describe("MCP credential flow", () => { { provider: "link", page_url: undefined, allowed: false }, { provider: "agentcard", page_url: "https://example.com", allowed: false }, ])( - "enforces card fill provider and page URL boundaries", + "forwards card fill policy decisions to the API", async ({ provider, page_url, allowed }) => { const fixture = await connectVaultTest([ Response.json({ ...ready, type: "card", spec: { provider } }), - Response.json(completed), + allowed + ? Response.json(completed) + : Response.json({ code: "invalid_request" }, { status: 400 }), ]); try { const result = await fixture.call("manage_vault_items", { @@ -145,15 +237,18 @@ describe("MCP credential flow", () => { expect(result.isError).toBe(!allowed); expect( fixture.requests.filter((request) => request.method === "POST"), - ).toHaveLength(allowed ? 1 : 0); + ).toHaveLength(1); } finally { await fixture.close(); } }, ); - test("rejects credential format before any operation write", async () => { - const fixture = await connectVaultTest([Response.json(ready)]); + test("surfaces API credential format validation", async () => { + const fixture = await connectVaultTest([ + Response.json(ready), + Response.json({ code: "invalid_request" }, { status: 400 }), + ]); try { const result = await fixture.call("manage_vault_items", { ...invoke, @@ -167,7 +262,9 @@ describe("MCP credential flow", () => { expect(result.isError).toBe(true); expect(fixture.requests.map((request) => request.method)).toEqual([ "GET", + "POST", ]); + expect(JSON.stringify(result)).toContain("No fields were written"); } finally { await fixture.close(); } @@ -238,7 +335,7 @@ describe("MCP credential flow", () => { }), ); expect(observed.item.state.status).toBe("ready"); - expect(JSON.stringify(observed)).not.toContain("secret-username"); + expect(observed.item.state.fields.username.value).toBe("secret-username"); const result = await fixture.call("manage_vault_items", invoke); expect(result.isError).toBe(false); expect(toolResultJSON(result).result).toEqual(completed); @@ -386,7 +483,9 @@ describe("MCP credential flow", () => { ).toHaveLength(1); if (operation === "fill") expect(JSON.stringify(result)).toContain( - "Never automatically retry", + failure === 409 + ? "No fields were written" + : "Never automatically retry", ); } finally { await fixture.close(); diff --git a/src/lib/mcp/tools/vault-credentials.ts b/src/lib/mcp/tools/vault-credentials.ts index 56c5781..1161039 100644 --- a/src/lib/mcp/tools/vault-credentials.ts +++ b/src/lib/mcp/tools/vault-credentials.ts @@ -4,7 +4,11 @@ import type { VaultItem } from "@onkernel/sdk/resources/vaults/items"; import type { McpDependencies } from "@/lib/mcp/dependencies"; import { projectForOperation } from "@/lib/mcp/project-selection"; import { errorResponse } from "@/lib/mcp/responses"; -import { throwVaultError, vaultItemResponse } from "@/lib/mcp/vault-responses"; +import { + throwVaultError, + vaultItemResponse, + isPublicCredentialField, +} from "@/lib/mcp/vault-responses"; import { vaultItemSchema, vaultKeySchema, @@ -18,8 +22,17 @@ const definition = z .object({ type: z.enum(["text", "email", "password", "totp"]), required: z.boolean().optional(), - sensitive: z.boolean().optional(), - value: text().optional(), + sensitive: z + .boolean() + .optional() + .describe( + "Explicitly false for ordinary usernames/emails. Passwords/TOTP must be true; omission defaults to true.", + ), + value: text() + .optional() + .describe( + "Optional initial value. Omit secrets for private human collection; TOTP uses a seed, not a current code.", + ), }) .strict() .refine( @@ -28,7 +41,11 @@ const definition = z ); const createSpec = z .object({ - description: text().optional(), + description: text() + .optional() + .describe( + "Recognizable site or service name only; display text, not destination policy.", + ), fields: z .record(fieldName(), definition) .refine( @@ -39,7 +56,11 @@ const createSpec = z .strict(); const updateSpec = z .object({ - description: text().optional(), + description: text() + .optional() + .describe( + "Replacement site/service display name. Empty string clears it.", + ), fields: z .record(fieldName(), z.object({ value: text().nullable() }).strict()) .refine( @@ -59,7 +80,7 @@ export function registerVaultCredentialTools( ) { server.tool( "manage_vault_credentials", - "Create or update credential items in a per-end-user vault. Use only the recognizable site name as description; explicitly set sensitive:false for ordinary usernames/emails. Passwords and TOTP seeds must be sensitive. Never store payment-card data here. For human collection, omit values and present the returned bearer collection URL privately to the intended user, outside the agent-controlled browser. Never ask for passwords or TOTP seeds in chat. TOTP seeds require trusted provisioning and have no hosted input. Create definitions with fields keyed by name; update accepts only description and fields containing value. Updates require the latest version and optionally expected_item_id from an earlier read; definitions are immutable. Omitted values are preserved; null or empty strings clear supported values. Clearing required TOTP is unsupported. Hosted forms require populated required inputs. Use manage_vault_items get with wait for readiness, then invoke fill with fill parameters. For edits to already-ready items compare versions without wait. All stored values are omitted from responses. Writes are never automatically retried; reconcile conflicts or uncertain outcomes before any further write.", + "Create or update credential items in a per-end-user vault. Use only the recognizable site name as description; explicitly set sensitive:false for ordinary usernames/emails. Passwords and TOTP seeds must be sensitive. Never store payment-card data here. For human collection, omit values and present the returned bearer collection URL privately to the intended user, outside the agent-controlled browser. Never ask for passwords or TOTP seeds in chat. TOTP seeds require trusted provisioning and have no hosted input. Create definitions with fields keyed by name; update accepts only description and fields containing value. Updates require the latest version and optionally expected_item_id from an earlier read; definitions are immutable. Omitted values are preserved; null or empty strings clear supported values. Clearing required TOTP is unsupported. Hosted forms require populated required inputs. Use manage_vault_items get with wait for readiness, then invoke fill with fill parameters. For edits to already-ready items compare versions without wait. Explicitly non-sensitive text/email values are returned; sensitive values and TOTP seeds are omitted. Writes are never automatically retried; reconcile conflicts or uncertain outcomes before any further write.", vaultToolInput({ ...vaultItemSchema, key: vaultKeySchema(), @@ -138,9 +159,13 @@ export function registerVaultCredentialTools( return vaultItemResponse( item, { project, vault: params.vault, key: params.key }, - Object.values(params.spec.fields ?? {}).map( - (field) => field.value ?? undefined, - ), + Object.entries(params.spec.fields ?? {}) + .filter( + ([name]) => + item.type !== "credential" || + !isPublicCredentialField(item.spec.fields[name]), + ) + .map(([, field]) => field.value ?? undefined), ); } catch (error) { throwVaultError("manage_vault_credentials", params.action, error); diff --git a/src/lib/mcp/tools/vault-items.ts b/src/lib/mcp/tools/vault-items.ts index eccbc1a..e923f00 100644 --- a/src/lib/mcp/tools/vault-items.ts +++ b/src/lib/mcp/tools/vault-items.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { vaultFillSchema, vaultFillResponse, - unconfirmedVaultFillResponse, + throwVaultFillError, } from "@/lib/mcp/vault-fill"; import type { McpDependencies } from "@/lib/mcp/dependencies"; import { projectForOperation } from "@/lib/mcp/project-selection"; @@ -32,7 +32,7 @@ export function registerVaultItemTools( ) { server.tool( "manage_vault_items", - 'Inspect credential and payment vault items and immutable audit events. "list" reads items without renewing collection links; "get" reads state, safe field metadata, version, required user actions, available_operations, and available_expansions. MCP omits all stored credential values, even non-sensitive ones. For credentials, present the collection URL only to the intended user, outside the agent-controlled browser; never ask for passwords or TOTP seeds in chat. "collect" reopens the full form without clearing values or changing version; TOTP has no hosted input. wait observes readiness, not edits to ready credentials: compare versions using get without wait. Use manage_vault_credentials for credential creation and updates; use a per-user vault, site-name-only description, and sensitive:false for ordinary usernames/emails. Never store credit card data in credential items. "invoke" fetches the item again and submits only an advertised operation; read its description and obtain explicit user approval first. Provider actions (OAuth, enrollment, MFA, approval) must be completed by the user, not invoked as operations. "events" observes outcomes; use the last event ID as after. "delete" invalidates an item credential; confirm with the user first. Unresolved payments can block item and parent deletion; the API decides whether explicit abandonment is allowed, and deletion never proves a payment did not occur. recovery_required is not decline or expiry: stop payment attempts and reconcile with the provider or support; no reset exists. Credential ready means required values exist, not that login succeeded; payment ready does not mean paid. For fill, supply the fill object with browser_id and ordered field/selector bindings; values stay server-side until entering the browser. prepare_checkout remains API-only here; never substitute another operation or retry an uncertain attempt. Requests are never automatically retried. Do not retry failed, timed-out, rejected, or indeterminate payments; inspect state/events instead.', + 'Inspect credential and payment vault items and immutable audit events. "list" reads items without renewing collection links; "get" reads state, safe field metadata, version, required user actions, available_operations, and available_expansions. MCP returns explicitly non-sensitive text/email values; sensitive values and TOTP seeds are omitted. For credentials, present the collection URL only to the intended user, outside the agent-controlled browser; never ask for passwords or TOTP seeds in chat. "collect" reopens the full form without clearing values or changing version; TOTP has no hosted input. wait observes readiness, not edits to ready credentials: compare versions using get without wait. Use manage_vault_credentials for credential creation and updates; use a per-user vault, site-name-only description, and sensitive:false for ordinary usernames/emails. Never store credit card data in credential items. "invoke" fetches the item again and submits only an advertised operation; read its description and obtain explicit user approval first. Provider actions (OAuth, enrollment, MFA, approval) must be completed by the user, not invoked as operations. "events" observes outcomes; use the last event ID as after. "delete" invalidates an item credential; confirm with the user first. Unresolved payments can block item and parent deletion; the API decides whether explicit abandonment is allowed, and deletion never proves a payment did not occur. recovery_required is not decline or expiry: stop payment attempts and reconcile with the provider or support; no reset exists. Credential ready means required values exist, not that login succeeded; payment ready does not mean paid. For fill, supply the fill object with browser_id and ordered field/selector bindings; values stay server-side until entering the browser. prepare_checkout remains API-only here; never substitute another operation or retry an uncertain attempt. Requests are never automatically retried. Do not retry failed, timed-out, rejected, or indeterminate payments; inspect state/events instead.', vaultToolInput({ ...vaultItemSchema, action: z.enum(["list", "get", "invoke", "events", "delete"]), @@ -82,6 +82,7 @@ export function registerVaultItemTools( project, ); const options = { maxRetries: 0, signal: extra.signal }; + let fillRequested = false; try { if ( params.wait !== undefined && @@ -146,33 +147,13 @@ export function registerVaultItemTools( "Operation is not advertised in available_operations. Inspect the item before taking further action.", ); if (operation.type === "fill" && params.fill) { - if ( - item.type === "credential" && - params.fill.fields.some((field) => field.format !== undefined) - ) - return errorResponse("Credential fields must omit format."); - if ( - item.type !== "credential" && - (item.type !== "card" || - item.spec.provider !== "link" || - !params.fill.page_url || - new URL(params.fill.page_url).protocol !== "https:" || - new URL(params.fill.page_url).username || - new URL(params.fill.page_url).password) - ) - return errorResponse( - "Fill requires a credential or Link card; cards require an HTTPS page_url without credentials.", - ); - try { - const result = await client.vaults.items.performOperation( - params.key, - { id_or_name: params.vault, type: "fill", ...params.fill }, - options, - ); - return vaultFillResponse(result, params.fill.fields.length); - } catch { - return unconfirmedVaultFillResponse(); - } + fillRequested = true; + const result = await client.vaults.items.performOperation( + params.key, + { id_or_name: params.vault, type: "fill", ...params.fill }, + options, + ); + return vaultFillResponse(result, params.fill.fields.length); } if (vaultOperationRequiresInputs(operation.type)) { return errorResponse( @@ -229,6 +210,7 @@ export function registerVaultItemTools( } } } catch (error) { + if (fillRequested) throwVaultFillError(error); if ( params.action === "delete" && error instanceof APIError && diff --git a/src/lib/mcp/vault-fill.ts b/src/lib/mcp/vault-fill.ts index 9936b21..ee9e188 100644 --- a/src/lib/mcp/vault-fill.ts +++ b/src/lib/mcp/vault-fill.ts @@ -1,5 +1,10 @@ import { z } from "zod"; -import { jsonResponse, errorResponse } from "@/lib/mcp/responses"; +import { APIError } from "@onkernel/sdk"; +import { + jsonResponse, + errorResponse, + throwToolError, +} from "@/lib/mcp/responses"; export const vaultFillSchema = z .object({ @@ -39,7 +44,15 @@ export const vaultFillSchema = z ) .min(1) .max(32), - timeout_ms: z.number().int().min(1).max(30000).optional(), + timeout_ms: z + .number() + .int() + .min(1) + .max(30000) + .optional() + .describe( + "Total operation deadline in milliseconds, not per field. Default 10000.", + ), }) .strict() .refine( @@ -68,6 +81,97 @@ const resultSchema = z.object({ ), }); +const fillErrorMessages = new Map([ + [ + "invalid_request", + "Invalid fill request. Check field names, formats, and browser parameters.", + ], + [ + "invalid_selector", + "Invalid selector. Inspect the page and correct the selector.", + ], + [ + "duplicate_target", + "Multiple bindings resolve to the same element. Use distinct targets.", + ], + [ + "destination_denied", + "Destination or browser vault binding is not authorized. Check the bound browser and destination.", + ], + [ + "not_found", + "The requested vault, item, or browser was not found. Check the identifiers and project.", + ], + [ + "conflict", + "The item or browser is not ready for fill. Inspect readiness, binding, and any unresolved prior operation.", + ], + [ + "page_not_found", + "No open page matches page_url. Inspect the browser and use its exact current URL.", + ], + [ + "ambiguous_page", + "More than one page matches. Supply a URL identifying exactly one open page.", + ], + [ + "element_not_found", + "No editable target matches a selector. Inspect the page and correct the binding.", + ], + [ + "ambiguous_selector", + "A selector matches multiple targets across frames. Use a unique selector.", + ], + [ + "element_not_editable", + "A selected element is not editable. Choose an editable input or select.", + ], + ["option_not_found", "The select has no matching option value."], + [ + "field_unavailable", + "A requested field has no usable stored value. Inspect field definitions and presence; collect missing values.", + ], + [ + "target_changed", + "The page or target changed. Inspect the current page before choosing new bindings.", + ], + ["timeout", "The fill deadline elapsed."], +]); + +export function throwVaultFillError(error: unknown): never { + if (error instanceof APIError && typeof error.status === "number") { + const parsed = z + .object({ code: z.string().optional() }) + .safeParse(error.error); + const code = parsed.success ? parsed.data.code : undefined; + const message = code ? fillErrorMessages.get(code) : undefined; + const preWrite = [400, 403, 404, 409].includes(error.status); + const guidance = preWrite + ? "No fields were written by this request. Inspect and correct the cause before deciding on a new fill; do not automatically retry." + : "Browser fields may have been written. Inspect the browser. Never automatically retry or fall back to aliases."; + throwToolError( + "manage_vault_items", + "invoke", + APIError.generate( + error.status, + { + message: `${message ?? "Fill request failed."} ${guidance}`, + ...(message !== undefined && { code }), + }, + undefined, + new Headers(), + ), + ); + } + throwToolError( + "manage_vault_items", + "invoke", + new Error( + "Fill did not return a confirmed result; browser fields may have been written. Inspect the browser. Never automatically retry or fall back to aliases.", + ), + ); +} + export function unconfirmedVaultFillResponse() { return errorResponse( "Fill did not return a confirmed result; browser fields may have been written. Inspect the browser. Never automatically retry or fall back to aliases.", diff --git a/src/lib/mcp/vault-responses.ts b/src/lib/mcp/vault-responses.ts index fea871d..12f931a 100644 --- a/src/lib/mcp/vault-responses.ts +++ b/src/lib/mcp/vault-responses.ts @@ -26,8 +26,8 @@ const paymentMethodFields = { 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. +// Allow public metadata, including future operation names, but never unknown +// provider fields, free-form metadata, or opaque event data. export const vaultItemFields: OutputFields = { ...fields("id key type version created_at updated_at expires_at"), available_operations: operationFields, @@ -113,6 +113,27 @@ export function isDisplaySafeVaultURL(value: string): boolean { } } +export function isPublicCredentialField( + field: { type?: string; sensitive?: boolean } | undefined, +): boolean { + return ( + field?.sensitive === false && + (field.type === "text" || field.type === "email") + ); +} + +const credentialValuesSchema = z.object({ + type: z.literal("credential"), + spec: z.object({ + fields: z.record(z.object({ type: z.string(), sensitive: z.boolean() })), + }), + state: z.object({ + fields: z.record( + z.object({ has_value: z.boolean(), value: z.string().optional() }), + ), + }), +}); + export function projectVaultOutput( value: unknown, allowed: OutputFields | null, @@ -147,6 +168,26 @@ export function projectVaultOutput( } result[key] = projectVaultOutput(field, children); } + if (allowed === vaultItemFields && result.type === "credential") { + const credential = credentialValuesSchema.safeParse(value); + if (credential.success) { + const { spec, state } = credential.data; + result.state = { + ...z.record(z.unknown()).parse(result.state), + fields: Object.fromEntries( + Object.entries(state.fields).map(([name, field]) => [ + name, + { + has_value: field.has_value, + ...(isPublicCredentialField(spec.fields[name]) && + field.has_value && + field.value !== undefined && { value: field.value }), + }, + ]), + ), + }; + } + } return result; } @@ -261,7 +302,7 @@ export function vaultItemResponse( guidance: credential ? [ "Present the collection URL only to the intended user in a private surface, outside the agent-controlled browser. It is a bearer credential. Never ask for passwords or TOTP seeds in chat; TOTP seeds require trusted backend provisioning, not hosted collection.", - "MCP returns field definitions, has_value, version, and collection expiry, never stored field values. Ready means required values exist, not that login succeeded. Listing does not renew collection links; use get or the advertised collect operation.", + "MCP returns field definitions, has_value, version, collection expiry, and explicitly non-sensitive text/email values. Sensitive values and TOTP seeds are never returned. Ready means required values exist, not that login succeeded. Listing does not renew collection links; use get or the advertised collect operation.", "collect reopens the full form without clearing values or changing readiness or version. wait observes readiness, not edits to ready items. Compare versions with get without wait; a change can also come from an API update, so it does not identify a specific form submission.", "Create or update credentials with manage_vault_credentials. Use a per-user vault, a recognizable site-name-only description, and sensitive:false for usernames/emails. Passwords and TOTP must be sensitive. Updates require the current version; supply expected_item_id when bound to an earlier read. Omitted values remain; null or empty strings clear supported fields, including required text/email/password fields. Hosted forms still require populated required inputs. Do not store payment-card data in credential items.", "Invocation hints are not approval to execute. Invoke fill with manage_vault_items using a fill object containing browser_id and ordered fields of field/selector bindings, never values. Bind the vault at browser creation, authorize the destination, and follow the advertised description. Fill does not submit or navigate; real values enter the browser and may be read by an agent with browser access. Never retry an uncertain fill or fall back to aliases.", diff --git a/src/lib/mcp/vault-steering.test.ts b/src/lib/mcp/vault-steering.test.ts index 6abc436..c66a66a 100644 --- a/src/lib/mcp/vault-steering.test.ts +++ b/src/lib/mcp/vault-steering.test.ts @@ -53,6 +53,34 @@ const credential = { }; describe("vault OpenAPI steering", () => { + test.each([ + { type: "text", sensitive: false, visible: true }, + { type: "email", sensitive: false, visible: true }, + { type: "text", sensitive: true, visible: false }, + { type: "password", sensitive: false, visible: false }, + { type: "totp", sensitive: false, visible: false }, + { type: "text", sensitive: undefined, visible: false }, + ])( + "only exposes explicitly public text/email values", + ({ type, sensitive, visible }) => { + const result = toolResultJSON( + vaultItemResponse( + { + ...credential, + spec: { fields: { field: { type, sensitive } } }, + state: { + status: "ready", + fields: { field: { has_value: true, value: "test-value" } }, + }, + }, + target, + ), + ); + expect(result.item.state.fields.field.value).toBe( + visible ? "test-value" : undefined, + ); + }, + ); test("tool discovery exposes credential creation and steers per-user collection", async () => { const fixture = await connectVaultTest([]); try { @@ -72,7 +100,7 @@ describe("vault OpenAPI steering", () => { } }); test.each(["ready", "pending_collection"])( - "preserves %s credential metadata, not values", + "preserves %s public credential values, not secrets", (status) => { const result = toolResultJSON( vaultItemResponse( @@ -90,7 +118,9 @@ describe("vault OpenAPI steering", () => { expect(result.item.state.fields.otp).toEqual({ has_value: true }); expect(result.item.action.expires_at).toBe(credential.action.expires_at); expect(result.item.action.url).toBe(credential.action.url); - expect(JSON.stringify(result)).not.toContain("private-"); + expect(result.item.state.fields.username.value).toBe("private-user"); + expect(JSON.stringify(result)).not.toContain("private-password"); + expect(JSON.stringify(result)).not.toContain("private-seed"); expect( result.hints.invocation.map( (hint: { arguments: { operation: string } }) => @@ -127,7 +157,9 @@ describe("vault OpenAPI steering", () => { ); expect(result.item.version).toBe(7); expect(result.item.spec.fields.password.sensitive).toBe(true); - expect(JSON.stringify(result)).not.toContain("private-"); + expect(result.item.state.fields.username.value).toBe("private-user"); + expect(JSON.stringify(result)).not.toContain("private-password"); + expect(JSON.stringify(result)).not.toContain("private-seed"); expect( fixture.requests.map(({ method, body }) => ({ method, body })), ).toEqual([ @@ -151,7 +183,9 @@ describe("vault OpenAPI steering", () => { expect(result.items[0].state.fields.password).toEqual({ has_value: true, }); - expect(JSON.stringify(result)).not.toContain("private-"); + expect(result.items[0].state.fields.username.value).toBe("private-user"); + expect(JSON.stringify(result)).not.toContain("private-password"); + expect(JSON.stringify(result)).not.toContain("private-seed"); expect(fixture.requests).toHaveLength(1); expect(fixture.requests[0].method).toBe("GET"); } finally { From c49f6e77350098c7e5a88ad49987ac8e763f9401 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:24:46 +0000 Subject: [PATCH 4/5] Retain fill transport categories and per-field visibility --- src/lib/mcp/vault-fill.test.ts | 36 ++++++++++++++++++++++++++++++ src/lib/mcp/vault-fill.ts | 25 ++++++++++++++------- src/lib/mcp/vault-responses.ts | 4 +++- src/lib/mcp/vault-steering.test.ts | 19 ++++++++++++++++ 4 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 src/lib/mcp/vault-fill.test.ts diff --git a/src/lib/mcp/vault-fill.test.ts b/src/lib/mcp/vault-fill.test.ts new file mode 100644 index 0000000..b2a9779 --- /dev/null +++ b/src/lib/mcp/vault-fill.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { + APIConnectionError, + APIConnectionTimeoutError, + APIUserAbortError, +} from "@onkernel/sdk"; +import { throwVaultFillError } from "@/lib/mcp/vault-fill"; + +describe("fill transport error classification", () => { + test.each([ + { + error: new APIConnectionError({ message: "private-cause" }), + name: "KernelApiConnectionError", + }, + { + error: new APIConnectionTimeoutError({ message: "private-cause" }), + name: "KernelApiTimeout", + }, + { + error: new APIUserAbortError({ message: "private-cause" }), + name: "KernelApiAborted", + }, + ])("retains $name without exposing transport details", ({ error, name }) => { + let caught: unknown; + try { + throwVaultFillError(error); + } catch (result) { + caught = result; + } + expect(caught).toMatchObject({ + name, + message: expect.stringContaining("may have been written"), + }); + expect(String(caught)).not.toContain("private-cause"); + }); +}); diff --git a/src/lib/mcp/vault-fill.ts b/src/lib/mcp/vault-fill.ts index ee9e188..8a1abe2 100644 --- a/src/lib/mcp/vault-fill.ts +++ b/src/lib/mcp/vault-fill.ts @@ -1,5 +1,10 @@ import { z } from "zod"; -import { APIError } from "@onkernel/sdk"; +import { + APIError, + APIConnectionError, + APIConnectionTimeoutError, + APIUserAbortError, +} from "@onkernel/sdk"; import { jsonResponse, errorResponse, @@ -163,13 +168,17 @@ export function throwVaultFillError(error: unknown): never { ), ); } - throwToolError( - "manage_vault_items", - "invoke", - new Error( - "Fill did not return a confirmed result; browser fields may have been written. Inspect the browser. Never automatically retry or fall back to aliases.", - ), - ); + const message = + "Fill did not return a confirmed result; browser fields may have been written. Inspect the browser. Never automatically retry or fall back to aliases."; + const sanitized = + error instanceof APIConnectionTimeoutError + ? new APIConnectionTimeoutError({ message }) + : error instanceof APIUserAbortError + ? new APIUserAbortError({ message }) + : error instanceof APIConnectionError + ? new APIConnectionError({ message }) + : new Error(message); + throwToolError("manage_vault_items", "invoke", sanitized); } export function unconfirmedVaultFillResponse() { diff --git a/src/lib/mcp/vault-responses.ts b/src/lib/mcp/vault-responses.ts index 12f931a..b4fbd0d 100644 --- a/src/lib/mcp/vault-responses.ts +++ b/src/lib/mcp/vault-responses.ts @@ -125,7 +125,9 @@ export function isPublicCredentialField( const credentialValuesSchema = z.object({ type: z.literal("credential"), spec: z.object({ - fields: z.record(z.object({ type: z.string(), sensitive: z.boolean() })), + fields: z.record( + z.object({ type: z.string(), sensitive: z.boolean().optional() }), + ), }), state: z.object({ fields: z.record( diff --git a/src/lib/mcp/vault-steering.test.ts b/src/lib/mcp/vault-steering.test.ts index c66a66a..be446a3 100644 --- a/src/lib/mcp/vault-steering.test.ts +++ b/src/lib/mcp/vault-steering.test.ts @@ -53,6 +53,25 @@ const credential = { }; describe("vault OpenAPI steering", () => { + test("an omitted sensitivity flag does not hide other public values", () => { + const result = toolResultJSON( + vaultItemResponse( + { + ...credential, + spec: { + fields: { + username: { type: "text", sensitive: false }, + password: { type: "password" }, + }, + }, + }, + target, + ), + ); + expect(result.item.state.fields.username.value).toBe("private-user"); + expect(result.item.state.fields.password.value).toBeUndefined(); + expect(result.item.state.fields.otp.value).toBeUndefined(); + }); test.each([ { type: "text", sensitive: false, visible: true }, { type: "email", sensitive: false, visible: true }, From 0e74a2d9b4162d9694ad75190269c7cb9fde3849 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:45:22 +0000 Subject: [PATCH 5/5] Spell out collection operation arguments in tool guidance --- src/lib/mcp/tools/vault-credentials.ts | 2 +- src/lib/mcp/tools/vault-items.ts | 2 +- src/lib/mcp/vault-responses.ts | 2 +- src/lib/mcp/vault-steering.test.ts | 7 +++++++ 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/lib/mcp/tools/vault-credentials.ts b/src/lib/mcp/tools/vault-credentials.ts index 1161039..41b63a7 100644 --- a/src/lib/mcp/tools/vault-credentials.ts +++ b/src/lib/mcp/tools/vault-credentials.ts @@ -80,7 +80,7 @@ export function registerVaultCredentialTools( ) { server.tool( "manage_vault_credentials", - "Create or update credential items in a per-end-user vault. Use only the recognizable site name as description; explicitly set sensitive:false for ordinary usernames/emails. Passwords and TOTP seeds must be sensitive. Never store payment-card data here. For human collection, omit values and present the returned bearer collection URL privately to the intended user, outside the agent-controlled browser. Never ask for passwords or TOTP seeds in chat. TOTP seeds require trusted provisioning and have no hosted input. Create definitions with fields keyed by name; update accepts only description and fields containing value. Updates require the latest version and optionally expected_item_id from an earlier read; definitions are immutable. Omitted values are preserved; null or empty strings clear supported values. Clearing required TOTP is unsupported. Hosted forms require populated required inputs. Use manage_vault_items get with wait for readiness, then invoke fill with fill parameters. For edits to already-ready items compare versions without wait. Explicitly non-sensitive text/email values are returned; sensitive values and TOTP seeds are omitted. Writes are never automatically retried; reconcile conflicts or uncertain outcomes before any further write.", + 'Create or update credential items in a per-end-user vault. Use only the recognizable site name as description; explicitly set sensitive:false for ordinary usernames/emails. Passwords and TOTP seeds must be sensitive. Never store payment-card data here. For human collection, omit values and present the returned bearer collection URL privately to the intended user, outside the agent-controlled browser. Never ask for passwords or TOTP seeds in chat. TOTP seeds require trusted provisioning and have no hosted input. Create definitions with fields keyed by name; update accepts only description and fields containing value. Updates require the latest version and optionally expected_item_id from an earlier read; definitions are immutable. Omitted values are preserved; null or empty strings clear supported values. Clearing required TOTP is unsupported. Hosted forms require populated required inputs. To reopen collection, use manage_vault_items with action: "invoke" and operation: "collect". Use manage_vault_items get with wait for readiness, then invoke fill with fill parameters. For edits to already-ready items compare versions without wait. Explicitly non-sensitive text/email values are returned; sensitive values and TOTP seeds are omitted. Writes are never automatically retried; reconcile conflicts or uncertain outcomes before any further write.', vaultToolInput({ ...vaultItemSchema, key: vaultKeySchema(), diff --git a/src/lib/mcp/tools/vault-items.ts b/src/lib/mcp/tools/vault-items.ts index e923f00..461f4fe 100644 --- a/src/lib/mcp/tools/vault-items.ts +++ b/src/lib/mcp/tools/vault-items.ts @@ -32,7 +32,7 @@ export function registerVaultItemTools( ) { server.tool( "manage_vault_items", - 'Inspect credential and payment vault items and immutable audit events. "list" reads items without renewing collection links; "get" reads state, safe field metadata, version, required user actions, available_operations, and available_expansions. MCP returns explicitly non-sensitive text/email values; sensitive values and TOTP seeds are omitted. For credentials, present the collection URL only to the intended user, outside the agent-controlled browser; never ask for passwords or TOTP seeds in chat. "collect" reopens the full form without clearing values or changing version; TOTP has no hosted input. wait observes readiness, not edits to ready credentials: compare versions using get without wait. Use manage_vault_credentials for credential creation and updates; use a per-user vault, site-name-only description, and sensitive:false for ordinary usernames/emails. Never store credit card data in credential items. "invoke" fetches the item again and submits only an advertised operation; read its description and obtain explicit user approval first. Provider actions (OAuth, enrollment, MFA, approval) must be completed by the user, not invoked as operations. "events" observes outcomes; use the last event ID as after. "delete" invalidates an item credential; confirm with the user first. Unresolved payments can block item and parent deletion; the API decides whether explicit abandonment is allowed, and deletion never proves a payment did not occur. recovery_required is not decline or expiry: stop payment attempts and reconcile with the provider or support; no reset exists. Credential ready means required values exist, not that login succeeded; payment ready does not mean paid. For fill, supply the fill object with browser_id and ordered field/selector bindings; values stay server-side until entering the browser. prepare_checkout remains API-only here; never substitute another operation or retry an uncertain attempt. Requests are never automatically retried. Do not retry failed, timed-out, rejected, or indeterminate payments; inspect state/events instead.', + 'Inspect credential and payment vault items and immutable audit events. "list" reads items without renewing collection links; "get" reads state, safe field metadata, version, required user actions, available_operations, and available_expansions. MCP returns explicitly non-sensitive text/email values; sensitive values and TOTP seeds are omitted. For credentials, present the collection URL only to the intended user, outside the agent-controlled browser; never ask for passwords or TOTP seeds in chat. Use action: "invoke" with operation: "collect" to reopen the full form without clearing values or changing version; TOTP has no hosted input. wait observes readiness, not edits to ready credentials: compare versions using get without wait. Use manage_vault_credentials for credential creation and updates; use a per-user vault, site-name-only description, and sensitive:false for ordinary usernames/emails. Never store credit card data in credential items. "invoke" fetches the item again and submits only an advertised operation; read its description and obtain explicit user approval first. Provider actions (OAuth, enrollment, MFA, approval) must be completed by the user, not invoked as operations. "events" observes outcomes; use the last event ID as after. "delete" invalidates an item credential; confirm with the user first. Unresolved payments can block item and parent deletion; the API decides whether explicit abandonment is allowed, and deletion never proves a payment did not occur. recovery_required is not decline or expiry: stop payment attempts and reconcile with the provider or support; no reset exists. Credential ready means required values exist, not that login succeeded; payment ready does not mean paid. For fill, supply the fill object with browser_id and ordered field/selector bindings; values stay server-side until entering the browser. prepare_checkout remains API-only here; never substitute another operation or retry an uncertain attempt. Requests are never automatically retried. Do not retry failed, timed-out, rejected, or indeterminate payments; inspect state/events instead.', vaultToolInput({ ...vaultItemSchema, action: z.enum(["list", "get", "invoke", "events", "delete"]), diff --git a/src/lib/mcp/vault-responses.ts b/src/lib/mcp/vault-responses.ts index b4fbd0d..722ad4f 100644 --- a/src/lib/mcp/vault-responses.ts +++ b/src/lib/mcp/vault-responses.ts @@ -305,7 +305,7 @@ export function vaultItemResponse( ? [ "Present the collection URL only to the intended user in a private surface, outside the agent-controlled browser. It is a bearer credential. Never ask for passwords or TOTP seeds in chat; TOTP seeds require trusted backend provisioning, not hosted collection.", "MCP returns field definitions, has_value, version, collection expiry, and explicitly non-sensitive text/email values. Sensitive values and TOTP seeds are never returned. Ready means required values exist, not that login succeeded. Listing does not renew collection links; use get or the advertised collect operation.", - "collect reopens the full form without clearing values or changing readiness or version. wait observes readiness, not edits to ready items. Compare versions with get without wait; a change can also come from an API update, so it does not identify a specific form submission.", + 'Use manage_vault_items with action: "invoke" and operation: "collect" to reopen the full form without clearing values or changing readiness or version. wait observes readiness, not edits to ready items. Compare versions with get without wait; a change can also come from an API update, so it does not identify a specific form submission.', "Create or update credentials with manage_vault_credentials. Use a per-user vault, a recognizable site-name-only description, and sensitive:false for usernames/emails. Passwords and TOTP must be sensitive. Updates require the current version; supply expected_item_id when bound to an earlier read. Omitted values remain; null or empty strings clear supported fields, including required text/email/password fields. Hosted forms still require populated required inputs. Do not store payment-card data in credential items.", "Invocation hints are not approval to execute. Invoke fill with manage_vault_items using a fill object containing browser_id and ordered fields of field/selector bindings, never values. Bind the vault at browser creation, authorize the destination, and follow the advertised description. Fill does not submit or navigate; real values enter the browser and may be read by an agent with browser access. Never retry an uncertain fill or fall back to aliases.", ] diff --git a/src/lib/mcp/vault-steering.test.ts b/src/lib/mcp/vault-steering.test.ts index be446a3..178554a 100644 --- a/src/lib/mcp/vault-steering.test.ts +++ b/src/lib/mcp/vault-steering.test.ts @@ -109,6 +109,13 @@ describe("vault OpenAPI steering", () => { expect(vaults?.description).toContain("separate vault per end user"); expect(vaults?.description).toContain("sensitive:false"); expect(items?.description).toContain("without renewing collection links"); + expect(items?.description).toContain( + 'action: "invoke" with operation: "collect"', + ); + expect( + tools.find(({ name }) => name === "manage_vault_credentials") + ?.description, + ).toContain('action: "invoke" and operation: "collect"'); expect(items?.description).toContain("API-only"); expect(tools.map(({ name }) => name)).toContain( "manage_vault_credentials",