Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/lib/mcp/tools/vault-cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
106 changes: 56 additions & 50 deletions src/lib/mcp/tools/vault-credential-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"])(
Expand Down Expand Up @@ -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 }),
Expand All @@ -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"),
Expand All @@ -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"');
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -245,7 +235,7 @@ describe("MCP credential flow", () => {
try {
const result = await fixture.call("manage_vault_items", {
...invoke,
fill: {
inputs: {
...fill,
page_url,
fields: [
Expand All @@ -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);
Expand All @@ -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" },
Expand All @@ -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();
}
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
}
Expand Down
Loading
Loading