Skip to content

Commit 4631da4

Browse files
committed
Curate vault errors and document live-only cards
1 parent ee4a7a0 commit 4631da4

6 files changed

Lines changed: 148 additions & 19 deletions

File tree

docs/vault-payments.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ The vault tools prepare and observe payment credentials. They do **not** submit
44
merchant payments, expose real card values, or complete provider approval actions.
55
They use the same vault API as the Kernel CLI.
66

7+
**These are live payment cards. Test-mode creation is unsupported.** Do not assume
8+
that a development or staging MCP endpoint makes a card request a test transaction.
9+
710
## Tools and scope
811

912
| Tool | Actions |
@@ -177,6 +180,8 @@ A reusable card remaining `ready` does not establish that the last payment succe
177180
public state, non-secret aliases, masks, safe action/approval URLs, advertised
178181
operations/expansions, and payment outcomes. Unknown provider fields, opaque
179182
event data, free-form metadata, and URLs carrying OAuth codes/tokens are omitted.
183+
API errors retain the HTTP status but use curated messages for recognized error
184+
codes. Unknown codes use a generic fallback; upstream error text is never returned.
180185
There is no raw-output or raw-card tool.
181186
- Vault lists return `{items, has_more, next_offset}`. Item lists return `{items}`.
182187
`get` with `expand: ["payment_methods"]` is equivalent to the wallet

src/lib/mcp/tools/vault-cards.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export function registerVaultCardTools(
1717
) {
1818
server.tool(
1919
"manage_vault_cards",
20-
'Configure payment card requests, not merchant payments. "create" creates or retrieves an identical card request by immutable key. "update" replaces the ENTIRE spec, removing omitted optional fields, only when the API permits it. Neither implicitly authorizes Link: inspect available_operations with manage_vault_items and obtain explicit user approval before invoking. AgentCard authorizes at checkout. Amounts are integer minor currency units. No card data, OAuth tokens, provider secrets, or domain configuration. Never reconfigure a card to retry a failed, timed-out, rejected, or indeterminate payment. Requests are not automatically retried.',
20+
'Configure requests for live payment cards, not merchant payments. Test-mode creation is unsupported. "create" creates or retrieves an identical card request by immutable key. "update" replaces the ENTIRE spec, removing omitted optional fields, only when the API permits it. Neither implicitly authorizes Link: inspect available_operations with manage_vault_items and obtain explicit user approval before invoking. AgentCard authorizes at checkout. Amounts are integer minor currency units. No card data, OAuth tokens, provider secrets, or domain configuration. Never reconfigure a card to retry a failed, timed-out, rejected, or indeterminate payment. Requests are not automatically retried.',
2121
{
2222
...vaultItemSchema,
2323
key: vaultKeySchema(),

src/lib/mcp/tools/vault-items.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ describe("advertised vault operations", () => {
113113
async (stage) => {
114114
const failure = Response.json(
115115
{
116-
code: "provider_unavailable",
116+
code: "provider_error",
117117
message: "Provider unavailable",
118118
opaque: "hidden",
119119
},
@@ -130,7 +130,7 @@ describe("advertised vault operations", () => {
130130
operation: "authorize",
131131
});
132132
expect(result.isError).toBe(true);
133-
expect(JSON.stringify(result)).toContain("provider_unavailable");
133+
expect(JSON.stringify(result)).toContain("provider_error");
134134
expect(JSON.stringify(result)).not.toContain("hidden");
135135
expect(fixture.requests).toHaveLength(stage === "get" ? 1 : 2);
136136
} finally {

src/lib/mcp/tools/vaults.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ describe("vault SDK request contracts", () => {
3232
openWorldHint: true,
3333
});
3434
}
35+
const cards = tools.find((tool) => tool.name === "manage_vault_cards");
36+
expect(cards?.description).toContain("live payment cards");
37+
expect(cards?.description).toContain("Test-mode creation is unsupported");
3538
expect(fixture.requests).toHaveLength(0);
3639
} finally {
3740
await fixture.close();

src/lib/mcp/vault-responses.test.ts

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,90 @@ describe("vault public responses", () => {
218218
}
219219
});
220220

221+
test.each([
222+
[400, "invalid_request", "Invalid vault request."],
223+
[404, "not_found", "Vault, item, or project not found or unavailable."],
224+
[409, "conflict", "conflicts with the current configuration or state"],
225+
[500, "project_error", "Unable to resolve the vault's project."],
226+
[500, "db_error", "The vault storage request could not be completed."],
227+
[
228+
500,
229+
"provider_error",
230+
"The payment provider could not complete the vault request.",
231+
],
232+
[500, "provider_rate_limited", "rate limited requests"],
233+
[429, "spend_request_rate_limited", "rate limited spend requests"],
234+
] as const)(
235+
"returns curated text for HTTP %s / %s",
236+
async (status, code, message) => {
237+
const fixture = await connectVaultTest([
238+
Response.json(
239+
{
240+
code,
241+
message: "access_token=hidden-free-text",
242+
details: "hidden-details",
243+
},
244+
{ status },
245+
),
246+
]);
247+
try {
248+
const result = await fixture.call("manage_vault_items", {
249+
action: "get",
250+
vault: "checkout",
251+
key: "order-1",
252+
});
253+
const text = JSON.stringify(result);
254+
expect(result.isError).toBe(true);
255+
expect(text).toContain(`${status} `);
256+
expect(text).toContain(message);
257+
expect(text).toContain(`[code: ${code}]`);
258+
expect(text).toContain("Do not replay a payment.");
259+
expect(text).not.toContain("hidden");
260+
} finally {
261+
await fixture.close();
262+
}
263+
},
264+
);
265+
266+
test.each(
267+
[
268+
undefined,
269+
null,
270+
123,
271+
{},
272+
[],
273+
"",
274+
"unknown_error",
275+
"__proto__",
276+
"constructor",
277+
"invalid_request hidden-suffix",
278+
].map((code) => ({ code })),
279+
)(
280+
"uses a generic fallback for unrecognized error codes",
281+
async ({ code }) => {
282+
const fixture = await connectVaultTest([
283+
Response.json(
284+
{ code, message: "password=hidden-password" },
285+
{ status: 400 },
286+
),
287+
]);
288+
try {
289+
const result = await fixture.call("manage_vault_items", {
290+
action: "get",
291+
vault: "checkout",
292+
key: "order-1",
293+
});
294+
const text = JSON.stringify(result);
295+
expect(result.isError).toBe(true);
296+
expect(text).toContain("400 Vault request failed.");
297+
expect(text).not.toContain("[code:");
298+
expect(text).not.toContain("hidden");
299+
} finally {
300+
await fixture.close();
301+
}
302+
},
303+
);
304+
221305
test.each([
222306
{
223307
message: "Expansion unavailable",
@@ -226,6 +310,12 @@ describe("vault public responses", () => {
226310
headers: { authorization: "hidden-auth" },
227311
},
228312
{ raw_provider: { secret: "hidden-without-message" } },
313+
{
314+
code: "invalid_request",
315+
message: "access_token=hidden-plaintext-secret",
316+
},
317+
{ code: "access_token=hidden-code-secret", message: "Invalid request" },
318+
{ code: "conflict", message: "password=hidden-password-secret" },
229319
{
230320
message: "Follow https://provider.example/?code=hidden-code",
231321
code: "action_required",
@@ -245,9 +335,17 @@ describe("vault public responses", () => {
245335
});
246336
expect(result.isError).toBe(true);
247337
expect(JSON.stringify(result)).not.toContain("hidden");
248-
if (body.code) expect(JSON.stringify(result)).toContain(body.code);
338+
if (
339+
["provider_error", "invalid_request", "conflict"].includes(
340+
body.code ?? "",
341+
)
342+
) {
343+
expect(JSON.stringify(result)).toContain(`[code: ${body.code}]`);
344+
} else {
345+
expect(JSON.stringify(result)).not.toContain("[code:");
346+
}
249347
if (body.message === "Expansion unavailable")
250-
expect(JSON.stringify(result)).toContain(body.message);
348+
expect(JSON.stringify(result)).not.toContain(body.message);
251349
} finally {
252350
await fixture.close();
253351
}

src/lib/mcp/vault-responses.ts

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -139,12 +139,36 @@ export function vaultItemResponse(item: unknown) {
139139
});
140140
}
141141

142-
function publicErrorMessage(value: unknown): string {
143-
if (typeof value !== "string") return "Vault request failed";
144-
return value.replace(/https?:\/\/[^\s"'<>]+/g, (url) =>
145-
isDisplaySafeVaultURL(url) ? url : "[redacted URL]",
146-
);
147-
}
142+
const vaultErrorMessages = new Map([
143+
[
144+
"invalid_request",
145+
"Invalid vault request. Check the tool's documented inputs.",
146+
],
147+
["not_found", "Vault, item, or project not found or unavailable."],
148+
[
149+
"conflict",
150+
"The vault request conflicts with the current configuration or state. Inspect the item and its advertised operations and expansions.",
151+
],
152+
[
153+
"project_error",
154+
"Unable to resolve the vault's project. Check connection scope and project selection.",
155+
],
156+
["db_error", "The vault storage request could not be completed."],
157+
[
158+
"provider_error",
159+
"The payment provider could not complete the vault request.",
160+
],
161+
[
162+
"provider_rate_limited",
163+
"The payment provider has rate limited requests. Stop and wait before taking further action.",
164+
],
165+
[
166+
"spend_request_rate_limited",
167+
"The payment provider has rate limited spend requests. Stop and wait before taking further action.",
168+
],
169+
]);
170+
const vaultErrorGuidance =
171+
"Inspect item state/events before taking further action. Do not replay a payment.";
148172

149173
export function throwVaultError(
150174
tool: string,
@@ -159,27 +183,26 @@ export function throwVaultError(
159183
);
160184
}
161185
if (error instanceof APIError && typeof error.status === "number") {
162-
// SDK errors can stringify the entire provider body when no message is present.
163-
// Retain only the standard public message/code, never that fallback or headers.
186+
// Neither provider messages nor unknown codes are safe to return, even as strings.
164187
const body = error.error;
165-
const message = publicErrorMessage(
166-
body && typeof body === "object" && "message" in body
167-
? body.message
168-
: undefined,
169-
);
170188
const code =
171189
body &&
172190
typeof body === "object" &&
173191
"code" in body &&
174192
typeof body.code === "string"
175193
? body.code
176194
: undefined;
195+
const message =
196+
code === undefined ? undefined : vaultErrorMessages.get(code);
177197
throwToolError(
178198
tool,
179199
action,
180200
APIError.generate(
181201
error.status,
182-
{ message, code },
202+
{
203+
message: `${message ?? "Vault request failed."} ${vaultErrorGuidance}`,
204+
...(message !== undefined && { code }),
205+
},
183206
undefined,
184207
new Headers(),
185208
),

0 commit comments

Comments
 (0)