diff --git a/src/lib/mcp/tools/vault-cards.ts b/src/lib/mcp/tools/vault-cards.ts index 5cc24af..b600c92 100644 --- a/src/lib/mcp/tools/vault-cards.ts +++ b/src/lib/mcp/tools/vault-cards.ts @@ -20,7 +20,7 @@ export function registerVaultCardTools( "manage_vault_cards", { description: - '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.', + '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 a checkout-preparation operation for supported tokenization checkout; invoke it through manage_vault_items with the API-required checkout inputs. 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.', inputSchema: vaultToolInput({ ...vaultItemSchema, key: vaultKeySchema(), diff --git a/src/lib/mcp/tools/vault-credential-flow.test.ts b/src/lib/mcp/tools/vault-credential-flow.test.ts index 301f24d..5dfebd2 100644 --- a/src/lib/mcp/tools/vault-credential-flow.test.ts +++ b/src/lib/mcp/tools/vault-credential-flow.test.ts @@ -69,7 +69,7 @@ const completed = { { index: 1, status: "filled" }, ], }; -const invoke = { ...target, action: "invoke", operation: "fill", fill }; +const invoke = { ...target, action: "invoke", operation: "fill", inputs: fill }; describe("MCP credential flow", () => { test.each(["create", "update"])( @@ -122,23 +122,15 @@ describe("MCP credential flow", () => { ); 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", - }, + { status: 400, code: "ambiguous_selector" }, + { status: 403, code: "destination_denied" }, + { status: 404, code: "not_found" }, + { status: 409, code: "conflict" }, + { status: 400, code: "field_unavailable" }, + { status: 400, code: "private-unknown-code" }, ])( - "preserves meaningful pre-write fill errors ($status $code)", - async ({ status, code, message }) => { + "curates operation errors without interpreting the operation type ($status $code)", + async ({ status, code }) => { const fixture = await connectVaultTest([ Response.json(ready), Response.json({ code, message: "private-upstream-secret" }, { status }), @@ -148,9 +140,7 @@ describe("MCP credential flow", () => { 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).toContain("The operation may have partially completed"); expect(text).not.toContain("private-"); expect( fixture.requests.filter((request) => request.method === "POST"), @@ -176,7 +166,7 @@ describe("MCP credential flow", () => { expect(credentials?.inputSchema.properties).toHaveProperty( "expected_item_id", ); - expect(items?.inputSchema.properties).toHaveProperty("fill"); + expect(items?.inputSchema.properties).toHaveProperty("inputs"); expect( JSON.stringify([credentials?.inputSchema, items?.inputSchema]), ).not.toContain('"$ref"'); @@ -214,9 +204,9 @@ describe("MCP credential flow", () => { }; const result = await fixture.call("manage_vault_items", { ...invoke, - fill: parameters, + inputs: parameters, }); - expect(result.isError).toBe(false); + expect(result.isError).toBeUndefined(); expect(fixture.requests[1].body).toEqual({ type: "fill", ...parameters }); } finally { await fixture.close(); @@ -245,7 +235,7 @@ describe("MCP credential flow", () => { try { const result = await fixture.call("manage_vault_items", { ...invoke, - fill: { + inputs: { ...fill, page_url, fields: [ @@ -254,7 +244,7 @@ describe("MCP credential flow", () => { ], }, }); - expect(result.isError).toBe(!allowed); + expect(Boolean(result.isError)).toBe(!allowed); expect( fixture.requests.filter((request) => request.method === "POST"), ).toHaveLength(1); @@ -272,7 +262,7 @@ describe("MCP credential flow", () => { try { const result = await fixture.call("manage_vault_items", { ...invoke, - fill: { + inputs: { ...fill, fields: [ { field: "password", selector: "#password", format: "MM/YY" }, @@ -284,7 +274,7 @@ describe("MCP credential flow", () => { "GET", "POST", ]); - expect(JSON.stringify(result)).toContain("No fields were written"); + expect(JSON.stringify(result)).toContain("may have partially completed"); } finally { await fixture.close(); } @@ -360,7 +350,7 @@ describe("MCP credential flow", () => { expect(observed.item.state.status).toBe("ready"); 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(result.isError).toBeUndefined(); expect(toolResultJSON(result).result).toEqual(completed); expect(JSON.stringify(result)).not.toContain("secret-password"); expect(fixture.requests.map(({ method }) => method)).toEqual([ @@ -588,9 +578,7 @@ describe("MCP credential flow", () => { ).toHaveLength(1); if (operation === "fill") expect(JSON.stringify(result)).toContain( - failure === 409 - ? "No fields were written" - : "Never automatically retry", + "Do not retry automatically", ); } finally { await fixture.close(); @@ -652,31 +640,49 @@ describe("MCP credential flow", () => { }, { ...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(); - } - }); + ])( + "delegates operation-specific input validation to the API", + async (parameters) => { + const fixture = await connectVaultTest([ + Response.json(ready), + Response.json({ code: "invalid_request" }, { status: 400 }), + ]); + try { + const result = await fixture.call("manage_vault_items", { + ...invoke, + inputs: parameters, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).not.toContain("secret-value"); + expect(fixture.requests.map(({ method }) => method)).toEqual([ + "GET", + "POST", + ]); + } finally { + await fixture.close(); + } + }, + ); - test("does not treat a malformed fill response as a successful item", async () => { + test("projects an item-shaped operation response without exposing private fields", async () => { const fixture = await connectVaultTest([ Response.json(ready), - Response.json(ready), + Response.json({ + ...ready, + state: { + ...ready.state, + fields: { + ...ready.state.fields, + password: { has_value: true, value: "secret-password" }, + }, + }, + }), ]); 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"); + expect(result.isError).toBeUndefined(); + expect(toolResultJSON(result).item.key).toBe("login"); + expect(JSON.stringify(result)).not.toContain("secret-password"); } finally { await fixture.close(); } diff --git a/src/lib/mcp/tools/vault-items.test.ts b/src/lib/mcp/tools/vault-items.test.ts index cc60b92..3f52297 100644 --- a/src/lib/mcp/tools/vault-items.test.ts +++ b/src/lib/mcp/tools/vault-items.test.ts @@ -80,8 +80,8 @@ describe("advertised vault operations", () => { }, ); - test.each(["fill", "prepare_checkout"])( - "rejects advertised %s without submitting an incomplete operation", + test.each(["prepare_checkout", "future_operation"])( + "discovers and invokes advertised %s with operation-specific inputs", async (operation) => { const advertisedItem = { ...item, @@ -90,9 +90,16 @@ describe("advertised vault operations", () => { { type: operation, description: "Requires additional inputs." }, ], }; + const inputs = { + checkout: { + browser_id: "browser-1", + merchant_origin: "https://shop.example", + }, + }; const fixture = await connectVaultTest([ Response.json(advertisedItem), Response.json(advertisedItem), + Response.json({ ...item, available_operations: [] }), ]); try { const observed = toolResultJSON( @@ -105,34 +112,183 @@ describe("advertised vault operations", () => { expect(observed.item.available_operations).toEqual( advertisedItem.available_operations, ); - expect(observed.hints.invocation).toEqual([ - { - tool: "manage_vault_items", - arguments: { - project: "proj_test", - vault: "checkout", - key: "order-1", - action: "invoke", - operation: "authorize", - }, - requires_user_approval: true, - }, - ]); + expect( + observed.hints.invocation.map( + (hint: { arguments: { operation: string } }) => + hint.arguments.operation, + ), + ).toEqual(["authorize", operation]); const result = await fixture.call("manage_vault_items", { action: "invoke", vault: "checkout", key: "order-1", operation, + inputs, + }); + expect(result.isError).toBeUndefined(); + expect(fixture.requests.map((request) => request.method)).toEqual([ + "GET", + "GET", + "POST", + ]); + expect(fixture.requests[2].body).toEqual({ + type: operation, + ...inputs, + }); + } finally { + await fixture.close(); + } + }, + ); + + test("handles field results for newly advertised operation types", async () => { + const inputs = { + browser_id: "browser-1", + fields: [{ field: "username", selector: "#username" }], + }; + const fixture = await connectVaultTest([ + Response.json({ + ...item, + available_operations: [ + { type: "future_operation", description: "Write fields." }, + ], + }), + Response.json({ + type: "future_operation", + status: "completed", + fields: [{ index: 0, status: "filled" }], + opaque: "hidden", + }), + ]); + try { + const result = toolResultJSON( + await fixture.call("manage_vault_items", { + action: "invoke", + vault: "checkout", + key: "order-1", + operation: "future_operation", + inputs, + }), + ); + expect(result.result).toMatchObject({ + type: "future_operation", + status: "completed", + }); + expect(JSON.stringify(result)).not.toContain("hidden"); + expect(fixture.requests[1].body).toEqual({ + type: "future_operation", + ...inputs, + }); + } finally { + await fixture.close(); + } + }); + + test("does not select a response type from input field names", async () => { + const fixture = await connectVaultTest([ + Response.json({ + ...item, + available_operations: [ + { type: "future_operation", description: "Do work." }, + ], + }), + Response.json({ + type: "future_operation", + status: "pending", + opaque: "hidden", + }), + ]); + try { + const result = await fixture.call("manage_vault_items", { + action: "invoke", + vault: "checkout", + key: "order-1", + operation: "future_operation", + inputs: { fields: [{ name: "example" }] }, + }); + expect(result.isError).toBeUndefined(); + expect(toolResultJSON(result).result).toEqual({ + type: "future_operation", + status: "pending", + }); + expect(JSON.stringify(result)).not.toContain("hidden"); + } finally { + await fixture.close(); + } + }); + + test("projects unknown operation results without exposing opaque fields", async () => { + const fixture = await connectVaultTest([ + Response.json({ + ...item, + available_operations: [ + { type: "future_operation", description: "Run it." }, + ], + }), + Response.json({ + type: "future_operation", + status: "pending", + opaque: "hidden", + }), + ]); + try { + const result = toolResultJSON( + await fixture.call("manage_vault_items", { + action: "invoke", + vault: "checkout", + key: "order-1", + operation: "future_operation", + }), + ); + expect(result.result).toEqual({ + type: "future_operation", + status: "pending", + }); + expect(JSON.stringify(result)).not.toContain("hidden"); + expect(fixture.requests.map((request) => request.method)).toEqual([ + "GET", + "POST", + ]); + } finally { + await fixture.close(); + } + }); + + test("does not report an unrecognized operation response as success", async () => { + const fixture = await connectVaultTest([ + Response.json(item), + Response.json({ opaque: "hidden" }), + ]); + try { + const result = await fixture.call("manage_vault_items", { + action: "invoke", + vault: "checkout", + key: "order-1", + operation: "authorize", + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toContain("unrecognized response"); + expect(JSON.stringify(result)).not.toContain("hidden"); + } finally { + await fixture.close(); + } + }); + + test.each(["type", "id_or_name"])( + "does not let invocation inputs override %s", + async (field) => { + const fixture = await connectVaultTest([]); + try { + const result = await fixture.call("manage_vault_items", { + action: "invoke", + vault: "checkout", + key: "order-1", + operation: "authorize", + inputs: { [field]: "hidden" }, }); expect(result.isError).toBe(true); - expect(JSON.stringify(result)).toContain( - operation === "fill" - ? "fill parameters are required" - : `${operation} requires additional inputs`, - ); - expect(fixture.requests.map((request) => request.method)).toEqual( - operation === "fill" ? ["GET"] : ["GET", "GET"], - ); + expect(JSON.stringify(result)).not.toContain("hidden"); + expect(fixture.requests).toHaveLength(0); } finally { await fixture.close(); } @@ -167,6 +323,134 @@ describe("advertised vault operations", () => { } }); + test.each([400, 403, 409, 422])( + "returns the exact provider message for authorize HTTP %s", + async (status) => { + const reason = `Funding method cannot be used for this purchase (${status}).`; + const fixture = await connectVaultTest([ + Response.json(item), + Response.json( + { + code: "invalid_spend_request", + message: `Payment provider rejected card authorization: ${reason}`, + inner_error: { code: "provider_rejection_reason", message: reason }, + opaque: "hidden", + }, + { status }, + ), + ]); + try { + const result = await fixture.call("manage_vault_items", { + action: "invoke", + vault: "checkout", + key: "order-1", + operation: "authorize", + }); + const text = JSON.stringify(result); + expect(result.isError).toBe(true); + expect(text).toContain(reason); + expect(text).toContain("Inspect item state and events before acting."); + expect(text).toContain("Do not retry automatically."); + expect(text).toContain("[code: invalid_spend_request]"); + expect(text).not.toContain("hidden"); + expect(text).not.toContain( + "The payment provider could not complete the vault request.", + ); + expect(fixture.requests.map((request) => request.method)).toEqual([ + "GET", + "POST", + ]); + } finally { + await fixture.close(); + } + }, + ); + + test("keeps unmarked rate limits distinct from provider rejections", async () => { + const fixture = await connectVaultTest([ + Response.json(item), + Response.json( + { + code: "spend_request_rate_limited", + message: "private provider text", + }, + { status: 429 }, + ), + ]); + try { + const result = await fixture.call("manage_vault_items", { + action: "invoke", + vault: "checkout", + key: "order-1", + operation: "authorize", + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toContain("rate limited spend requests"); + expect(JSON.stringify(result)).not.toContain("private provider text"); + } finally { + await fixture.close(); + } + }); + + test("uses the API rejection marker rather than an operation-name check", async () => { + const fixture = await connectVaultTest([ + Response.json({ + ...item, + available_operations: [{ type: "future_operation", description: "" }], + }), + Response.json( + { + code: "future_decline", + message: "Public error wrapper", + inner_error: { + code: "provider_rejection_reason", + message: "Provider declined the request.", + }, + }, + { status: 400 }, + ), + ]); + try { + const result = await fixture.call("manage_vault_items", { + action: "invoke", + vault: "checkout", + key: "order-1", + operation: "future_operation", + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toContain( + "Provider declined the request.", + ); + } finally { + await fixture.close(); + } + }); + + test("keeps unmarked provider errors curated for other operations", async () => { + const fixture = await connectVaultTest([ + Response.json({ + ...item, + available_operations: [{ type: "future_operation", description: "" }], + }), + Response.json( + { code: "provider_error", message: "access_token=hidden" }, + { status: 400 }, + ), + ]); + try { + const result = await fixture.call("manage_vault_items", { + action: "invoke", + vault: "checkout", + key: "order-1", + operation: "future_operation", + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).not.toContain("hidden"); + } finally { + await fixture.close(); + } + }); + test.each(["get", "post"])( "does not retry an operation's failed %s", async (stage) => { diff --git a/src/lib/mcp/tools/vault-items.ts b/src/lib/mcp/tools/vault-items.ts index 801f093..6b920b9 100644 --- a/src/lib/mcp/tools/vault-items.ts +++ b/src/lib/mcp/tools/vault-items.ts @@ -1,11 +1,6 @@ import type { McpServer } from "@modelcontextprotocol/server"; import { APIError } from "@onkernel/sdk"; import { z } from "zod"; -import { - vaultFillSchema, - vaultFillResponse, - throwVaultFillError, -} 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"; @@ -13,6 +8,7 @@ import { errorResponse, jsonResponse } from "@/lib/mcp/responses"; import { projectVaultOutput, throwVaultError, + vaultOperationResultFields, vaultEventFields, vaultItemFields, vaultItemResponse, @@ -21,7 +17,6 @@ import { import { vaultItemSchema, vaultKeySchema, - vaultOperationRequiresInputs, vaultWaitSchema, vaultToolInput, } from "@/lib/mcp/vault-schemas"; @@ -34,7 +29,7 @@ export function registerVaultItemTools( "manage_vault_items", { description: - '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. Link cards use advertised fill, not aliases or egress substitution: fill.page_url must be the exact current HTTPS top-level page URL at the approved merchant origin, and the browser must retain its vault attachment. Fill returns no card values but does not isolate them from browser/CDP access or explicitly submit checkout; failed or unknown fills may leave partial writes. Never automatically retry or fall back to aliases. AgentCard aliases and checkout hold/approval/replay remain supported. 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. Reopen collection using its advertised operation when available; 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 browser field writes, supply operation-specific inputs with browser_id and ordered field/selector bindings; values stay server-side until entering the browser. Link cards use the advertised browser field-writing operation, not aliases or egress substitution: inputs.page_url must be the exact current HTTPS top-level page URL at the approved merchant origin, and the browser must retain its vault attachment. Browser field writes return no card values but do not isolate them from browser/CDP access or explicitly submit checkout; failed or unknown writes may leave partial changes. Never automatically retry or fall back to aliases. AgentCard aliases and checkout hold/approval/replay remain supported. Follow each advertised operation\'s API contract for inputs and outcome handling; 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.', inputSchema: vaultToolInput({ ...vaultItemSchema, action: z.enum(["list", "get", "invoke", "events", "delete"]), @@ -46,13 +41,20 @@ export function registerVaultItemTools( .min(1) .refine((value) => value.trim().length > 0) .describe( - "(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.", + "(invoke) Type advertised in available_operations. Availability and required inputs are API-controlled, not inferred from provider or state.", ) .optional(), - fill: vaultFillSchema + inputs: z + .record(z.string(), z.unknown()) + .refine( + (value) => + !("type" in value) && + !("id_or_name" in value) && + Buffer.byteLength(JSON.stringify(value), "utf8") <= 128 * 1024, + ) .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.", + "(invoke) Optional operation-specific request body fields. Read available_operations and the API contract for required inputs. Do not include type or id_or_name; the tool sets those. Never supply secret values in chat.", ), expand: z .array(z.enum(["payment_methods"])) @@ -85,7 +87,7 @@ export function registerVaultItemTools( project, ); const options = { maxRetries: 0, signal: ctx.mcpReq.signal }; - let fillRequested = false; + let operationSubmitted = false; try { if ( params.wait !== undefined && @@ -93,22 +95,11 @@ export function registerVaultItemTools( params.action !== "events" ) { return errorResponse( - "wait is only supported for get and events; invoke does not wait for collection or authorization.", + "wait is only supported for get and events; invoke does not wait for operation outcomes.", ); } - 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.inputs !== undefined && params.action !== "invoke") + return errorResponse("inputs are only supported for invoke."); if (params.action === "list") { const items = await client.vaults.items.list(params.vault, options); return jsonResponse({ @@ -149,29 +140,50 @@ 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) { - 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( - `${operation.type} requires additional inputs not supported by this tool. Use the Kernel API for this operation.`, - ); - } - const updated = await client.vaults.items.performOperation( + operationSubmitted = true; + // The generated SDK union is closed; the API advertises types at runtime. + const result = await client.vaults.items.performOperation( params.key, { id_or_name: params.vault, type: operation.type, - }, + ...params.inputs, + } as Parameters[1], options, ); - return vaultItemResponse(updated, target); + if (!result || typeof result !== "object" || Array.isArray(result)) + return errorResponse( + "Operation returned an unrecognized response. Inspect item state and events before acting; do not retry automatically.", + ); + if ("available_operations" in result) + return vaultItemResponse(result, target); + const projected = projectVaultOutput( + result, + vaultOperationResultFields, + ); + if ( + !projected || + typeof projected !== "object" || + !("type" in projected) || + typeof projected.type !== "string" + ) + return errorResponse( + "Operation returned an unrecognized response. Inspect item state and events before acting; do not retry automatically.", + ); + return { + ...jsonResponse({ + result: projected, + guidance: + "Inspect item state and events for the outcome. Do not automatically retry an uncertain operation.", + }), + ...(typeof projected === "object" && + projected !== null && + "status" in projected && + (projected.status === "failed" || + projected.status === "unknown") && { + isError: true as const, + }), + }; } case "events": { const events = await client.vaults.items.events( @@ -213,7 +225,6 @@ export function registerVaultItemTools( } } } catch (error) { - if (fillRequested) throwVaultFillError(error); if ( params.action === "delete" && error instanceof APIError && @@ -225,7 +236,12 @@ export function registerVaultItemTools( key: params.key, }); } - throwVaultError("manage_vault_items", params.action, error); + throwVaultError( + "manage_vault_items", + params.action, + error, + operationSubmitted, + ); } }, ); diff --git a/src/lib/mcp/vault-fill.ts b/src/lib/mcp/vault-fill.ts deleted file mode 100644 index 8a1abe2..0000000 --- a/src/lib/mcp/vault-fill.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { z } from "zod"; -import { - APIError, - APIConnectionError, - APIConnectionTimeoutError, - APIUserAbortError, -} from "@onkernel/sdk"; -import { - jsonResponse, - errorResponse, - throwToolError, -} 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() - .describe( - "Total operation deadline in milliseconds, not per field. Default 10000.", - ), - }) - .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(), - }), - ), -}); - -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(), - ), - ); - } - 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() { - 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-fill.test.ts b/src/lib/mcp/vault-operation-errors.test.ts similarity index 76% rename from src/lib/mcp/vault-fill.test.ts rename to src/lib/mcp/vault-operation-errors.test.ts index b2a9779..b0b5e54 100644 --- a/src/lib/mcp/vault-fill.test.ts +++ b/src/lib/mcp/vault-operation-errors.test.ts @@ -4,9 +4,9 @@ import { APIConnectionTimeoutError, APIUserAbortError, } from "@onkernel/sdk"; -import { throwVaultFillError } from "@/lib/mcp/vault-fill"; +import { throwVaultError } from "@/lib/mcp/vault-responses"; -describe("fill transport error classification", () => { +describe("vault operation transport errors", () => { test.each([ { error: new APIConnectionError({ message: "private-cause" }), @@ -23,13 +23,13 @@ describe("fill transport error classification", () => { ])("retains $name without exposing transport details", ({ error, name }) => { let caught: unknown; try { - throwVaultFillError(error); + throwVaultError("manage_vault_items", "invoke", error, true); } catch (result) { caught = result; } expect(caught).toMatchObject({ name, - message: expect.stringContaining("may have been written"), + message: expect.stringContaining("may have partially completed"), }); expect(String(caught)).not.toContain("private-cause"); }); diff --git a/src/lib/mcp/vault-payment-guidance.test.ts b/src/lib/mcp/vault-payment-guidance.test.ts index 997a152..57c11c2 100644 --- a/src/lib/mcp/vault-payment-guidance.test.ts +++ b/src/lib/mcp/vault-payment-guidance.test.ts @@ -36,32 +36,30 @@ describe("provider-specific vault payment guidance", () => { ); const guidance = result.guidance.join(" "); for (const text of [ - "Link cards use fill", + "Link cards use browser field writes", + "only when advertised", "does not expose aliases or support egress substitution", "fail closed on supported payment shapes", "retain this vault attachment in the same project", "origin of spec.merchant_url", "ready and unexpired", "non-deleted parent wallet", - "nested fill object", - "browser_id", + "pass inputs with browser_id", "exact current top-level page_url", "field/selector bindings, never values", "format MM/YY or MM/YYYY", - "fill.timeout_ms", - "without returning them in the API response", - "browser or CDP access may read", - "partial writes without rollback", - "Never automatically retry", - "or fall back to aliases", - "not payment or merchant acceptance", + "returns no card values", + "browser access can expose written values", + "Failed or unknown writes may leave partial changes", + "Never automatically retry or fall back to aliases", + "not that the payment succeeded", ]) expect(guidance).toContain(text); expect(guidance).not.toContain( "explicitly chosen egress-substitution integrations", ); expect(guidance).not.toContain("prepare_checkout"); - expect(result.hints.invocation).toEqual([]); + expect(result.hints.invocation[0].arguments.operation).toBe("fill"); if (!legacy) expect(result.item.state).not.toHaveProperty("aliases"); }, ); @@ -99,12 +97,14 @@ describe("provider-specific vault payment guidance", () => { expect(guidance).toContain( "Checkout hold, approval, and replay remain supported", ); - expect(guidance).toContain("API-only prepare_checkout"); + expect(guidance).toContain("For checkout preparation"); expect(guidance).toContain("Preparations are single-use"); expect(guidance).toContain("Never fall back to aliases"); expect(guidance).not.toContain("nested fill object"); expect(guidance).not.toContain("Link cards"); - expect(result.hints.invocation).toEqual([]); + expect(result.hints.invocation[0].arguments.operation).toBe( + "prepare_checkout", + ); }); test.each(["link", "agentcard"])( @@ -139,7 +139,7 @@ describe("provider-specific vault payment guidance", () => { ); expect(result.guidance).toHaveLength(5); expect(result.guidance[4]).toBe( - "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.", + "Invocation hints are not approval to execute. Invoke the advertised browser field-writing operation with manage_vault_items using an inputs 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.", ); expect(result.guidance.join(" ")).not.toContain("Link cards"); expect(result.guidance.join(" ")).not.toContain("AgentCard aliases"); @@ -151,9 +151,9 @@ describe("provider-specific vault payment guidance", () => { const { tools } = await fixture.client.listTools(); const items = tools.find(({ name }) => name === "manage_vault_items"); expect(items?.description).toContain( - "Link cards use advertised fill, not aliases or egress substitution", + "Link cards use the advertised browser field-writing operation, not aliases or egress substitution", ); - expect(items?.description).toContain("fill.page_url"); + expect(items?.description).toContain("inputs.page_url"); expect(items?.description).toContain( "AgentCard aliases and checkout hold/approval/replay remain supported", ); @@ -194,9 +194,9 @@ describe("provider-specific vault payment guidance", () => { ...target, action: "invoke", operation: "fill", - fill, + inputs: fill, }); - expect(result.isError).toBe(false); + expect(result.isError).toBeUndefined(); expect(toolResultJSON(result).result).toEqual(outcome); expect( fixture.requests.map(({ method, body }) => ({ method, body })), diff --git a/src/lib/mcp/vault-responses.ts b/src/lib/mcp/vault-responses.ts index cd1d347..916bef4 100644 --- a/src/lib/mcp/vault-responses.ts +++ b/src/lib/mcp/vault-responses.ts @@ -6,7 +6,6 @@ import { } from "@onkernel/sdk"; import { z } from "zod"; import { jsonResponse, throwToolError } from "@/lib/mcp/responses"; -import { vaultOperationRequiresInputs } from "@/lib/mcp/vault-schemas"; type OutputFields = { [key: string]: OutputFields | null }; @@ -66,6 +65,11 @@ export const vaultItemFields: OutputFields = { }, }; +export const vaultOperationResultFields: OutputFields = { + ...fields("type status"), + fields: fields("index status error_code"), +}; + export const vaultEventFields: OutputFields = { ...fields("id name created_at browser_id"), data: fields( @@ -306,10 +310,6 @@ export function vaultItemResponse( payment.success && payment.data.type === "card" ? payment.data.spec.provider : undefined; - const canFill = - cardProvider !== undefined && - advertised.success && - advertised.data.available_operations.some(({ type }) => type === "fill"); const secretValues = secretVariants(secrets); const safeHint = (hint: unknown) => !containsVaultSecret(hint, secretValues); return vaultResponse( @@ -319,7 +319,6 @@ export function vaultItemResponse( observation: vaultObservationHints(target).filter(safeHint), invocation: advertised.success ? advertised.data.available_operations - .filter(({ type }) => !vaultOperationRequiresInputs(type)) .map(({ type }) => ({ tool: "manage_vault_items", arguments: { ...target, action: "invoke", operation: type }, @@ -331,10 +330,10 @@ 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, 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.", - '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.', + "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 collection operation.", + 'Use manage_vault_items with action: "invoke" and the advertised collection operation 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. On create, inspect the website and list the named field definitions in its natural top-to-bottom order; that array order directly controls the user-facing collection form. Use optional non-secret labels for human-readable text; stable names remain authoritative for state, updates, and fill. 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.", + "Invocation hints are not approval to execute. Invoke the advertised browser field-writing operation with manage_vault_items using an inputs 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.", @@ -346,19 +345,14 @@ export function vaultItemResponse( : []), ...(cardProvider === "link" ? [ - "Link cards use fill for browser checkout, only when advertised. Link does not expose aliases or support egress substitution; do not use aliases from older responses, which fail closed on supported payment shapes. The browser must retain this vault attachment in the same project. The exact current HTTPS top-level page URL must have the origin of spec.merchant_url. The card must remain ready and unexpired with stored card material and a non-deleted parent wallet; lifecycle and destination checks still apply.", - ] - : []), - ...(canFill - ? [ - "Use manage_vault_items with action invoke, operation fill, and a nested fill object containing browser_id, exact current top-level page_url (including path, query, and fragment), and ordered fields of field/selector bindings, never values. For a combined expiration field, include format MM/YY or MM/YYYY on that binding; fill.timeout_ms is optional. Attach the vault at browser creation. No ready-to-run fill hint is emitted because bindings are caller-chosen.", - "Fill writes real values into the browser without returning them in the API response or explicitly submitting checkout. An agent with browser or CDP access may read those values; input/change events can trigger site behavior. Inspect the value-free result and ordered field outcomes. Failed or unknown fills may leave partial writes without rollback. Never automatically retry a failed or unknown fill or fall back to aliases. Completed means fields were filled, not payment or merchant acceptance; submit separately only after confirming completion and user authorization.", + "Link cards use browser field writes for checkout only when advertised. Link does not expose aliases or support egress substitution; do not use aliases from older responses, which fail closed on supported payment shapes. The browser must retain this vault attachment in the same project. The exact current HTTPS top-level page URL must have the origin of spec.merchant_url. The card must remain ready and unexpired with stored card material and a non-deleted parent wallet; lifecycle and destination checks still apply.", + "When the field-writing operation is advertised, pass inputs with browser_id, exact current top-level page_url (including path, query, and fragment), and ordered field/selector bindings, never values. A combined expiration field requires format MM/YY or MM/YYYY. Attach the vault at browser creation. The operation returns no card values and does not explicitly submit checkout; browser access can expose written values. Failed or unknown writes may leave partial changes. Never automatically retry or fall back to aliases. Completion means fields were written, not that the payment succeeded.", ] : []), ...(cardProvider === "agentcard" ? [ "AgentCard aliases remain supported for explicitly chosen egress-substitution integrations: use only returned state.aliases in a browser created with this vault attached, respecting returned permitted domains. Checkout hold, approval, and replay remain supported; observe checkout authorization and approval URLs. Never fall back to aliases after an uncertain fill or preparation.", - "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.", + "For checkout preparation, supply the API-required checkout context and 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.", ] : []), "Observe get/events for outcomes. Do not retry failed, timed-out, rejected, or indeterminate payments or reconfigure a card to retry them.", @@ -411,16 +405,19 @@ export function throwVaultError( tool: string, action: string, error: unknown, + operationSubmitted = false, ): never { + const guidance = operationSubmitted + ? "The operation may have partially completed. Inspect item state, events, and browser before acting. Do not retry automatically." + : vaultErrorGuidance; if (error instanceof z.ZodError) { throwToolError( tool, action, - new Error("spec must match the selected action's documented schema"), + new Error("Vault request must match the documented schema."), ); } if (error instanceof APIError && typeof error.status === "number") { - // Neither provider messages nor unknown codes are safe to return, even as strings. const body = error.error; const code = body && @@ -429,6 +426,35 @@ export function throwVaultError( typeof body.code === "string" ? body.code : undefined; + const providerReason = z + .object({ + inner_error: z.object({ + code: z.literal("provider_rejection_reason"), + message: z.string().min(1), + }), + }) + .safeParse(body); + if ( + operationSubmitted && + error.status >= 400 && + error.status < 500 && + providerReason.success + ) { + throwToolError( + tool, + action, + APIError.generate( + error.status, + { + message: `${providerReason.data.inner_error.message} Inspect item state and events before acting. Do not retry automatically.`, + ...(code !== undefined && + /^[a-zA-Z0-9_.-]{1,128}$/.test(code) && { code }), + }, + undefined, + new Headers(), + ), + ); + } const message = code === undefined ? undefined : vaultErrorMessages.get(code); throwToolError( @@ -437,7 +463,7 @@ export function throwVaultError( APIError.generate( error.status, { - message: `${message ?? "Vault request failed."} ${vaultErrorGuidance}`, + message: `${message ?? "Vault request failed."} ${guidance}`, ...(message !== undefined && { code }), }, undefined, @@ -446,26 +472,17 @@ export function throwVaultError( ); } if (error instanceof APIConnectionTimeoutError) { - throwToolError(tool, action, new APIConnectionTimeoutError()); - } - if (error instanceof APIUserAbortError) { - throwToolError(tool, action, new APIUserAbortError()); - } - if (error instanceof APIConnectionError) { throwToolError( tool, action, - new APIConnectionError({ - message: - "Vault connection failed; inspect item state/events before taking further action. Do not replay a payment.", - }), + new APIConnectionTimeoutError({ message: guidance }), ); } - throwToolError( - tool, - action, - new Error( - "Vault request failed; inspect item state/events before taking further action. Do not replay a payment.", - ), - ); + if (error instanceof APIUserAbortError) { + throwToolError(tool, action, new APIUserAbortError({ message: guidance })); + } + if (error instanceof APIConnectionError) { + throwToolError(tool, action, new APIConnectionError({ message: guidance })); + } + throwToolError(tool, action, new Error(`Vault request failed; ${guidance}`)); } diff --git a/src/lib/mcp/vault-schemas.ts b/src/lib/mcp/vault-schemas.ts index 0a6e52d..4f3ab97 100644 --- a/src/lib/mcp/vault-schemas.ts +++ b/src/lib/mcp/vault-schemas.ts @@ -22,12 +22,6 @@ export const vaultItemSchema = { vault: vaultSelectorSchema().describe("Vault ID or immutable name."), }; -export function vaultOperationRequiresInputs( - operation: string, -): operation is "fill" | "prepare_checkout" { - return operation === "fill" || operation === "prepare_checkout"; -} - export function vaultKeySchema() { return vaultSelectorSchema().describe( "Immutable item key within the vault, not the item ID.", diff --git a/src/lib/mcp/vault-steering.test.ts b/src/lib/mcp/vault-steering.test.ts index 4622fbc..d507eeb 100644 --- a/src/lib/mcp/vault-steering.test.ts +++ b/src/lib/mcp/vault-steering.test.ts @@ -137,7 +137,7 @@ describe("vault OpenAPI steering", () => { expect(vaults?.description).toContain("sensitive:false"); expect(items?.description).toContain("without renewing collection links"); expect(items?.description).toContain( - 'action: "invoke" with operation: "collect"', + "Reopen collection using its advertised operation", ); const credentials = tools.find( ({ name }) => name === "manage_vault_credentials", @@ -146,7 +146,7 @@ describe("vault OpenAPI steering", () => { 'action: "invoke" and operation: "collect"', ); expect(credentials?.description).toContain("natural top-to-bottom order"); - expect(items?.description).toContain("API-only"); + expect(items?.description).toContain("operation-specific inputs"); expect(tools.map(({ name }) => name)).toContain( "manage_vault_credentials", ); @@ -184,7 +184,7 @@ describe("vault OpenAPI steering", () => { (hint: { arguments: { operation: string } }) => hint.arguments.operation, ), - ).toEqual(["collect"]); + ).toEqual(["collect", "fill"]); const guidance = result.guidance.join(" "); for (const text of [ "bearer credential", @@ -280,7 +280,9 @@ describe("vault OpenAPI steering", () => { ), ); expect(result.item.state.preparation).toEqual(preparation); - expect(result.hints.invocation).toEqual([]); + expect(result.hints.invocation[0].arguments.operation).toBe( + "prepare_checkout", + ); 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");