diff --git a/README.md b/README.md
index c489f56..cd26db5 100644
--- a/README.md
+++ b/README.md
@@ -292,7 +292,7 @@ Many other MCP-capable tools accept:
Configure these values wherever the tool expects MCP server settings.
-## Tools (20 model-facing, plus 1 app-only helper)
+## Tools (21 model-facing, plus 1 app-only helper)
Each Kernel feature has a single `manage_*` tool with an `action` parameter, keeping the tool set small and consistent. Standalone tools handle high-frequency and interactive workflows.
@@ -316,6 +316,7 @@ Call `get_connection_context` before deciding whether to create or select a proj
- `manage_auth_connections` - Create, list, get, update, delete, login, submit, inspect timelines, and wait for managed-auth connections in every client. Supports health-check and automatic re-auth settings, managed-auth browser configuration, and canonical interaction-bound field/choice submissions. Use domain-filtered `list` for discovery. App-capable clients additionally receive `open_auth_login`; the programmatic actions remain available there too.
- `manage_credentials` - Create, list, get, update, and delete stored credentials; fetch a current TOTP code for credentials with a configured totp_secret.
- `manage_credential_providers` - Create, list, get, update, and delete external credential providers (e.g. 1Password); list available items and test the provider connection.
+- `manage_vaults` - Create/select project-owned vaults, manage Link and AgentCard wallet/card items, perform advertised authorization operations, and inspect state and events. Attach vaults using `manage_browsers.vaults` at browser creation. These tools prepare and observe credentials; they do not submit merchant payments.
### Standalone tools
@@ -383,6 +384,20 @@ Example: “Log me into my Hacker News account and update my profile to add a ra
The secure App defaults `record_session` and `browser_telemetry.enabled` to `true`, recording replay video plus the operational telemetry categories (`control`, `connection`, `system`, and `captcha`) for managed-auth browser sessions. Callers can explicitly disable either setting. The programmatic `manage_auth_connections` create, update, and login actions pass browser telemetry through the API’s current nested `browser.telemetry` configuration while preserving defaults and inheritance when the MCP parameter is omitted.
+### Prepare payment credentials with a vault
+
+This surface uses a [pinned, vendored SDK preview](vendor/README.md). It requires an API deployment with the corresponding vault support enabled; installing the MCP server does not enable or deploy that API.
+
+1. Call `get_connection_context` and select the owning project. Use `manage_vaults` with `action: "list"`, `"get"`, or `"upsert"` (`name` required for upsert) to select/create a vault. Omitted project selection uses the fixed connection project or the API default project, including for list. Ownership and the vault name are immutable.
+2. Use `action: "upsert_item"`, `id_or_name`, `key`, and an `item` containing exactly `type` and `spec`. For a Link wallet, use `{"type":"wallet","spec":{"provider":"link","authorization":{"method":"oauth","client":{"type":"kernel_managed"}}}}`. Follow the returned OAuth action URL until connected. For AgentCard, use `{"type":"wallet","spec":{"provider":"agentcard"}}` and follow `card_enrollment`. An optional AgentCard `user_id` must already be enrolled by a wallet in this organization. Never send card data or OAuth tokens/codes to MCP.
+3. Read the wallet with `action: "get_item"`. When advertised in `available_expansions`, pass `expand: ["payment_methods"]` to obtain display-safe funding methods. Create a card item with its provider's complete spec: Link requires `wallet`, `payment_method_id`, `merchant_name`, `merchant_url`, `amount` in minor currency units, `currency`, at least 100 characters of purchase `context`, and explicit `test: true` or `false`. AgentCard requires `wallet`, `merchant`, `amount`, and `currency`, with optional `card_id`; sandbox/live mode is deployment-configured, not an item flag. Confirm the intended mode before checkout. Permitted Link domains are returned in `state.domains`; this preview has no writable domains field.
+4. For Link, read `available_operations` and their descriptions, then call `action: "perform_item_operation"` with `operation: "authorize"` only when advertised. Creating or updating a Link card does not authorize it. Follow the returned approval/collection actions on provider-hosted surfaces; if no usable surface is supplied, request user assistance rather than inventing a callback. `update_item` accepts a complete card `spec`: Link only while requested, AgentCard before/between authorizations when permitted. AgentCard checkout submission triggers its own approval-gated authorization; there is no separate AgentCard authorize operation here.
+5. Create a browser in the same project with `manage_browsers`, `action: "create"`, and `vaults: [{"name":"checkout"}]` (or an ID instead of a name). Up to 20 references are supported, each with exactly one identifier and no duplicates; attachments cannot change after creation. Use returned non-secret `state.aliases` in checkout. Observe outcomes using `get_item` and `item_events`; for events, continue with `after` set to the last returned event ID. Both reads support `wait` from 0 to 60 seconds; configure the MCP client timeout above `wait + 30` seconds.
+
+**Payment safety:** vault API calls do not submit a merchant payment or authorize arbitrary retries. Never retry a failed, timed-out, rejected, or indeterminate payment, including by creating another item/browser. `ready`, `consumed`, or an `approved` authorization alone does not prove a successful charge. Inspect typed item state, authorization outcomes, and event names before deciding what happened. Never request raw card values, OAuth tokens/codes, ciphertext, provider secrets, or sensitive provider responses.
+
+MCP returns allowlisted metadata, actions, aliases, funding-method displays, and typed outcomes. It omits arbitrary spec metadata, event payloads, unstructured authorization reasons, unknown fields, and raw API error details. All vault SDK calls and vault-attached browser creation disable automatic retries. Deleting a vault invalidates its items; deleting a wallet also invalidates dependent cards. There is no rename, project move, callback, raw-secret, or payment-retry tool.
+
### Set up browser profiles for authentication
```
diff --git a/bun.lock b/bun.lock
index 6e978dc..2f1244c 100644
--- a/bun.lock
+++ b/bun.lock
@@ -10,7 +10,7 @@
"@clerk/themes": "^2.4.19",
"@modelcontextprotocol/sdk": "1.26.0",
"@onkernel/managed-auth-react": "0.5.1",
- "@onkernel/sdk": "^0.98.0",
+ "@onkernel/sdk": "file:vendor/kernel-sdk-768cea122220150aacc33074ea00a1c0afaf879e.tgz",
"@posthog/mcp": "0.10.1",
"@types/jsonwebtoken": "^9.0.10",
"@types/redis": "^4.0.11",
@@ -149,7 +149,7 @@
"@onkernel/managed-auth-react": ["@onkernel/managed-auth-react@0.5.1", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-tRnx91QTqlop2otlXyOxmI+jHodAMCW0dytWyk4hvu26cjACqH0387S0nP4IfgBVkZW6rwdNoBYKOheQfIxMgA=="],
- "@onkernel/sdk": ["@onkernel/sdk@0.98.0", "", {}, "sha512-FTvTDEPj3rS6INa+JTqso1XpnGkBKP3axvcAumX8dfd5nAI157mIlaZDWQ2k61MxwKpHDEvD5iNwD+sUk4OEcw=="],
+ "@onkernel/sdk": ["@onkernel/sdk@vendor/kernel-sdk-768cea122220150aacc33074ea00a1c0afaf879e.tgz", {}],
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eJopQrUk0WR7jViYDC29+Rp50xGvs4GtWOXBeqCoFMzutkkO3CZvHehA4JqnjfWMTSS8toqvRhCSOpOz62Wf9w=="],
diff --git a/package.json b/package.json
index 9a78182..ad7ca81 100644
--- a/package.json
+++ b/package.json
@@ -40,7 +40,7 @@
"@clerk/themes": "^2.4.19",
"@modelcontextprotocol/sdk": "1.26.0",
"@onkernel/managed-auth-react": "0.5.1",
- "@onkernel/sdk": "^0.98.0",
+ "@onkernel/sdk": "file:vendor/kernel-sdk-768cea122220150aacc33074ea00a1c0afaf879e.tgz",
"@posthog/mcp": "0.10.1",
"@types/jsonwebtoken": "^9.0.10",
"@types/redis": "^4.0.11",
diff --git a/src/lib/mcp/register.test.ts b/src/lib/mcp/register.test.ts
index b0c628c..7dd2039 100644
--- a/src/lib/mcp/register.test.ts
+++ b/src/lib/mcp/register.test.ts
@@ -20,6 +20,7 @@ const NON_AUTH_TOOLSETS = [
"replays",
"credentials",
"credential_providers",
+ "vaults",
].join(",");
function captureRegistration(mcpApps: boolean) {
@@ -115,6 +116,30 @@ describe("MCP toolset allowlist", () => {
});
});
+test("enables and disables vaults through the existing toolset selectors", () => {
+ const previousEnabled = process.env.KERNEL_MCP_ENABLED_TOOLSETS;
+ const previousDisabled = process.env.KERNEL_MCP_DISABLED_TOOLSETS;
+ process.env.KERNEL_MCP_ENABLED_TOOLSETS = "manage_vaults";
+ delete process.env.KERNEL_MCP_DISABLED_TOOLSETS;
+ try {
+ expect(captureRegistration(false).legacyTools).toEqual([
+ "get_connection_context",
+ "manage_vaults",
+ ]);
+ process.env.KERNEL_MCP_DISABLED_TOOLSETS = "vaults";
+ expect(captureRegistration(false).legacyTools).toEqual([
+ "get_connection_context",
+ ]);
+ } finally {
+ if (previousEnabled === undefined)
+ delete process.env.KERNEL_MCP_ENABLED_TOOLSETS;
+ else process.env.KERNEL_MCP_ENABLED_TOOLSETS = previousEnabled;
+ if (previousDisabled === undefined)
+ delete process.env.KERNEL_MCP_DISABLED_TOOLSETS;
+ else process.env.KERNEL_MCP_DISABLED_TOOLSETS = previousDisabled;
+ }
+});
+
describe("project selection registration", () => {
const projectScopedTools = [
"manage_profiles",
@@ -130,6 +155,7 @@ describe("project selection registration", () => {
"manage_replays",
"manage_auth_connections",
"manage_credentials",
+ "manage_vaults",
"open_auth_login",
"begin_auth_login",
];
diff --git a/src/lib/mcp/register.ts b/src/lib/mcp/register.ts
index 2f25b8c..e336525 100644
--- a/src/lib/mcp/register.ts
+++ b/src/lib/mcp/register.ts
@@ -24,6 +24,7 @@ import { registerProxyTools } from "@/lib/mcp/tools/proxies";
import { registerReplayTools } from "@/lib/mcp/tools/replays";
import { registerShellTool } from "@/lib/mcp/tools/shell";
import { registerWebMcpTool } from "@/lib/mcp/tools/webmcp";
+import { registerVaultTools } from "@/lib/mcp/tools/vaults";
type McpToolOptions = McpDependencies;
type McpRegistrationOptions = {
mcpApps?: boolean;
@@ -54,6 +55,7 @@ const mcpToolRegistrations = [
["auth_connections", registerManagedAuthCapabilities],
["credentials", registerCredentialTools],
["credential_providers", registerCredentialProviderTools],
+ ["vaults", registerVaultTools],
] as const satisfies readonly (readonly [string, RegisterMcpToolset])[];
type McpToolset = (typeof mcpToolRegistrations)[number][0];
diff --git a/src/lib/mcp/responses.test.ts b/src/lib/mcp/responses.test.ts
index f7eaa53..d2885ff 100644
--- a/src/lib/mcp/responses.test.ts
+++ b/src/lib/mcp/responses.test.ts
@@ -90,6 +90,25 @@ describe("throwToolError classification", () => {
).toContain("[code: last_active_project]");
});
+ test("replaces sensitive error text without losing API classification", () => {
+ try {
+ throwToolError(
+ "manage_vaults",
+ "upsert_item",
+ codedApiError(409, "secret-code", "secret-provider-response"),
+ "State conflict. Read the item; do not retry the payment.",
+ );
+ } catch (error) {
+ expect(error).toBeInstanceOf(Error);
+ expect((error as Error).name).toBe("KernelApiError409");
+ expect((error as Error).message).toBe(
+ "Error in manage_vaults (upsert_item): State conflict. Read the item; do not retry the payment.",
+ );
+ return;
+ }
+ throw new Error("throwToolError did not throw");
+ });
+
test("ignores absent and non-string API codes", () => {
const absent = apiError(409, "conflict");
expect(caught(absent).message).not.toContain("[code:");
diff --git a/src/lib/mcp/responses.ts b/src/lib/mcp/responses.ts
index f2675a5..7903463 100644
--- a/src/lib/mcp/responses.ts
+++ b/src/lib/mcp/responses.ts
@@ -123,10 +123,11 @@ export function throwToolError(
toolName: string,
action: string,
error: unknown,
+ safeMessage?: string,
): never {
throw new ToolCallError(
errorName(error),
- `Error in ${toolName} (${action}): ${errorMessage(error)}`,
+ `Error in ${toolName} (${action}): ${safeMessage ?? errorMessage(error)}`,
);
}
diff --git a/src/lib/mcp/tools/browser-vaults.test.ts b/src/lib/mcp/tools/browser-vaults.test.ts
new file mode 100644
index 0000000..c31d5c8
--- /dev/null
+++ b/src/lib/mcp/tools/browser-vaults.test.ts
@@ -0,0 +1,131 @@
+///
+
+import Kernel from "@onkernel/sdk";
+import { expect, test } from "bun:test";
+import { connectTestMcp, toolResultJSON } from "@/lib/mcp/mcp-test-fixtures";
+import { registerBrowserCapabilities } from "@/lib/mcp/tools/browsers";
+
+test("attaches vault references only at creation and disables automatic retries", async () => {
+ const calls: unknown[] = [];
+ const vaults = [{ id: "vault_1" }, { name: "checkout" }];
+ const { client, close } = await connectTestMcp(registerBrowserCapabilities, {
+ browsers: {
+ create: async (...args: unknown[]) => {
+ calls.push(args);
+ return { session_id: "browser_1", vaults };
+ },
+ update: async () => {
+ throw new Error("must not change vault bindings");
+ },
+ },
+ });
+ try {
+ const result = toolResultJSON(
+ await client.callTool({
+ name: "manage_browsers",
+ arguments: { action: "create", vaults, headless: false },
+ }),
+ );
+ expect(result.browser.vaults).toEqual(vaults);
+ expect(calls).toEqual([
+ [
+ { vaults, headless: false },
+ { maxRetries: 0, signal: expect.any(AbortSignal) },
+ ],
+ ]);
+ const update = await client.callTool({
+ name: "manage_browsers",
+ arguments: {
+ action: "update",
+ session_id: "browser_1",
+ vaults: [],
+ name: "renamed",
+ },
+ });
+ expect(update.isError).toBeTrue();
+ expect(update.content).toEqual([
+ {
+ type: "text",
+ text: "Error: vaults is create-only; browser vault bindings are immutable.",
+ },
+ ]);
+ } finally {
+ await close();
+ }
+});
+
+test("rejects ambiguous, empty, oversized, and secret-bearing vault references", async () => {
+ let creates = 0;
+ const { client, close } = await connectTestMcp(registerBrowserCapabilities, {
+ browsers: {
+ create: async () => {
+ creates++;
+ return { session_id: "browser_1" };
+ },
+ },
+ });
+ try {
+ for (const vaults of [
+ [{}],
+ [{ id: "vault_1", name: "checkout" }],
+ [{ id: "" }],
+ [{ name: "" }],
+ [{ id: "vault_1", secret: "sensitive-value" }],
+ Array(21).fill({ name: "checkout" }),
+ ]) {
+ expect(
+ (
+ await client.callTool({
+ name: "manage_browsers",
+ arguments: { action: "create", vaults },
+ })
+ ).isError,
+ ).toBeTrue();
+ }
+ expect(creates).toBe(0);
+ } finally {
+ await close();
+ }
+});
+
+test("forwards browser vaults through the real preview SDK and withholds provider errors", async () => {
+ const requests: unknown[] = [];
+ const sdk = new Kernel({
+ apiKey: "test-key",
+ baseURL: "https://api.example.test",
+ fetch: async (input, init) => {
+ const request = new Request(input, init);
+ requests.push({
+ method: request.method,
+ path: new URL(request.url).pathname,
+ body: await request.json(),
+ });
+ return Response.json(
+ { message: "sensitive-value", provider_secret: "sensitive-value" },
+ { status: 500 },
+ );
+ },
+ });
+ const { client, close } = await connectTestMcp(
+ registerBrowserCapabilities,
+ sdk,
+ );
+ try {
+ const result = await client.callTool({
+ name: "manage_browsers",
+ arguments: { action: "create", vaults: [{ name: "checkout" }] },
+ });
+ expect(requests).toEqual([
+ {
+ method: "POST",
+ path: "/browsers",
+ body: { vaults: [{ name: "checkout" }] },
+ },
+ ]);
+ expect(result.isError).toBeTrue();
+ expect(JSON.stringify(result)).not.toContain("sensitive-value");
+ expect(JSON.stringify(result)).toContain("Do not retry a payment");
+ } finally {
+ await close();
+ }
+});
diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts
index bea79ec..444b344 100644
--- a/src/lib/mcp/tools/browsers.ts
+++ b/src/lib/mcp/tools/browsers.ts
@@ -11,6 +11,7 @@ import {
type McpDependencies,
} from "@/lib/mcp/dependencies";
import type { KernelClient } from "@/lib/mcp/kernel-client";
+import { vaultErrorMessage } from "@/lib/mcp/tools/vault-responses";
import {
registerJsonResourceCollection,
registerJsonResourceTemplate,
@@ -433,7 +434,7 @@ export function registerBrowserCapabilities(
// manage_browsers -- Manage browser sessions and read archived telemetry
server.tool(
"manage_browsers",
- 'Manage browser sessions and their archived telemetry. Use "list" to choose an existing session, "create" before browser control, "update" to change supported session settings, "get" for full details, "get_telemetry" to diagnose active or deleted sessions, and "delete" when finished. Live sessions can be addressed by ID or by the name given at creation or set on update; deleted sessions only by ID. get_telemetry compacts events by default; set compact=false with explicit categories and a limit of at most 5 when raw headers, request data, response bodies, or other omitted fields are needed.',
+ 'Manage browser sessions and their archived telemetry. Use "list" to choose an existing session, "create" before browser control, "update" to change supported session settings, "get" for full details, "get_telemetry" to diagnose active or deleted sessions, and "delete" when finished. Live sessions can be addressed by ID or by the name given at creation or set on update; deleted sessions only by ID. get_telemetry compacts events by default; set compact=false with explicit categories and a limit of at most 5 when raw headers, request data, response bodies, or other omitted fields are needed. For payment workflows, first prepare a project-owned vault with manage_vaults, then attach it using vaults at creation in the same project; bindings are immutable. Use only returned non-secret item aliases in checkout and inspect item state/events for outcomes. Never extract raw payment credentials or retry failed, timed-out, rejected, or indeterminate payments.',
{
...projectSelectionInputSchema(),
action: z
@@ -476,6 +477,27 @@ export function registerBrowserCapabilities(
"(create) Chrome enterprise policy overrides. Kernel-managed policies such as extensions, proxy, CDP, and automation are blocked by the API.",
)
.optional(),
+ vaults: z
+ .array(
+ z
+ .object({
+ id: z.string().min(1).optional(),
+ name: z.string().min(1).max(255).optional(),
+ })
+ .strict()
+ .refine(
+ ({ id, name }) => (id !== undefined) !== (name !== undefined),
+ {
+ message:
+ "Each vault reference must provide exactly one of id or name.",
+ },
+ ),
+ )
+ .max(20)
+ .describe(
+ "(create only) Project-owned vaults prepared with manage_vaults. Each reference supplies exactly one ID or name; no duplicates. Links are immutable after creation. Attaching a vault does not submit or authorize a merchant payment.",
+ )
+ .optional(),
headless: z
.boolean()
.describe("(create) Launch without GUI. Faster but no live view.")
@@ -676,9 +698,16 @@ export function registerBrowserCapabilities(
);
try {
+ if (params.vaults !== undefined && params.action !== "create") {
+ return errorResponse(
+ "Error: vaults is create-only; browser vault bindings are immutable.",
+ );
+ }
switch (params.action) {
case "create": {
const createParams: BrowserCreateParams = {};
+ if (params.vaults !== undefined)
+ createParams.vaults = params.vaults;
if (params.headless !== undefined)
createParams.headless = params.headless;
if (params.gpu !== undefined) createParams.gpu = params.gpu;
@@ -707,7 +736,12 @@ export function registerBrowserCapabilities(
if (telemetry.value !== undefined)
createParams.telemetry = telemetry.value;
- const browser = await client.browsers.create(createParams);
+ const browser = await client.browsers.create(
+ createParams,
+ params.vaults !== undefined
+ ? { maxRetries: 0, signal: extra.signal }
+ : undefined,
+ );
if (!browser)
return errorResponse("Failed to create browser session");
@@ -829,7 +863,12 @@ export function registerBrowserCapabilities(
}
}
} catch (error) {
- throwToolError("manage_browsers", params.action, error);
+ throwToolError(
+ "manage_browsers",
+ params.action,
+ error,
+ params.vaults !== undefined ? vaultErrorMessage(error) : undefined,
+ );
}
},
);
diff --git a/src/lib/mcp/tools/vault-responses.ts b/src/lib/mcp/tools/vault-responses.ts
new file mode 100644
index 0000000..d33aa39
--- /dev/null
+++ b/src/lib/mcp/tools/vault-responses.ts
@@ -0,0 +1,188 @@
+import { APIError } from "@onkernel/sdk";
+import { z } from "zod";
+import {
+ agentCardCardSpecSchema,
+ agentCardWalletSpecSchema,
+ linkCardSpecSchema,
+ linkWalletSpecSchema,
+} from "@/lib/mcp/tools/vault-schemas";
+
+// SDK types alone do not strip unexpected provider fields from JSON responses.
+export const vaultOutputSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ created_at: z.string(),
+ updated_at: z.string(),
+});
+
+const aliases = z.object({
+ number: z.string(),
+ cvc: z.string(),
+ exp_month: z.string(),
+ exp_year: z.string(),
+});
+const masks = z.object({
+ brand: z.string().optional(),
+ last4: z.string().optional(),
+});
+const authorization = z.object({
+ id: z.string(),
+ status: z.enum(["awaiting_approval", "approved", "declined", "expired"]),
+ psp: z.string(),
+ merchant: z.string(),
+ amount_cents: z.number(),
+ currency: z.string(),
+ created_at: z.string(),
+ browser_id: z.string().optional(),
+ amount: z.string().optional(),
+ amount_authority: z.string().optional(),
+ approval_url: z.string().optional(),
+ expires_at: z.string().optional(),
+ psp_error_code: z.string().optional(),
+ expected_cents: z.number().optional(),
+ actual_cents: z.number().optional(),
+ amount_verified: z.boolean().optional(),
+ charged_amount_cents: z.number().optional(),
+ charged_currency: z.string().optional(),
+ charged_kind: z.string().optional(),
+ replay_attempted: z.boolean().optional(),
+ replay_status: z.number().optional(),
+ replay_delivered: z.boolean().optional(),
+});
+const action = z.discriminatedUnion("name", [
+ z.object({ name: z.literal("link_oauth"), url: z.string() }),
+ z.object({ name: z.literal("spend_approval"), url: z.string() }),
+ z.object({ name: z.literal("card_enrollment"), url: z.string() }),
+ z.object({ name: z.literal("push_approval") }),
+ z.object({ name: z.literal("collect") }),
+ z.object({ name: z.literal("mfa") }),
+ z.object({ name: z.literal("embedded_ceremony") }),
+]);
+const itemFields = {
+ id: z.string(),
+ key: z.string(),
+ created_at: z.string(),
+ updated_at: z.string(),
+ expires_at: z.string().optional(),
+ action: action.optional(),
+ available_operations: z.array(
+ z.object({ type: z.literal("authorize"), description: z.string() }),
+ ),
+ available_expansions: z.array(
+ z.object({ type: z.literal("payment_methods"), description: z.string() }),
+ ),
+};
+
+export const vaultItemOutputSchema = z.discriminatedUnion("type", [
+ z.object({
+ ...itemFields,
+ type: z.literal("wallet"),
+ spec: z.discriminatedUnion("provider", [
+ linkWalletSpecSchema().strip(),
+ agentCardWalletSpecSchema().strip(),
+ ]),
+ state: z.discriminatedUnion("provider", [
+ z.object({
+ provider: z.literal("link"),
+ status: z.enum([
+ "pending_authorization",
+ "connected",
+ "declined",
+ "reconnect_required",
+ "degraded",
+ ]),
+ status_reason: z.string().optional(),
+ }),
+ z.object({
+ provider: z.literal("agentcard"),
+ status: z.enum(["pending_authorization", "connected", "degraded"]),
+ status_reason: z.string().optional(),
+ user_id: z.string().optional(),
+ }),
+ ]),
+ expanded: z
+ .object({
+ payment_methods: z
+ .array(
+ z.object({
+ id: z.string(),
+ provider: z.string(),
+ type: z.string(),
+ is_default: z.boolean(),
+ display: z.object({
+ brand: z.string().optional(),
+ label: z.string().optional(),
+ last4: z.string().optional(),
+ }),
+ capabilities: z.object({
+ single_use_card: z
+ .object({
+ eligible: z.boolean(),
+ reasons: z.array(z.string()),
+ })
+ .optional(),
+ }),
+ }),
+ )
+ .optional(),
+ })
+ .optional(),
+ }),
+ z.object({
+ ...itemFields,
+ type: z.literal("card"),
+ spec: z.discriminatedUnion("provider", [
+ linkCardSpecSchema().omit({ metadata: true }).strip(),
+ agentCardCardSpecSchema().strip(),
+ ]),
+ state: z.discriminatedUnion("provider", [
+ z.object({
+ provider: z.literal("link"),
+ status: z.enum([
+ "requested",
+ "pending_authorization",
+ "ready",
+ "consumed",
+ "expired",
+ "declined",
+ ]),
+ status_reason: z.string().optional(),
+ aliases: aliases.optional(),
+ masks: masks.optional(),
+ domains: z.array(z.string()).optional(),
+ }),
+ z.object({
+ provider: z.literal("agentcard"),
+ status: z.enum(["requested", "ready", "pending_approval", "degraded"]),
+ status_reason: z.string().optional(),
+ aliases: aliases.optional(),
+ masks: masks.optional(),
+ authorization: authorization.optional(),
+ }),
+ ]),
+ }),
+]);
+
+// Event data is an untyped provider envelope. Outcomes remain visible through
+// event names and the item's typed state/authorization, without raw payloads.
+export const vaultEventOutputSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ created_at: z.string(),
+ browser_id: z.string().optional(),
+});
+
+export function vaultErrorMessage(error: unknown) {
+ const status = error instanceof APIError ? error.status : undefined;
+ const detail =
+ status === 400
+ ? "Invalid vault request. Check the input schema and required fields."
+ : status === 401 || status === 403
+ ? "Vault access denied. Check connection scope and API availability."
+ : status === 404
+ ? "Vault or item unavailable. Check the selected project, identifier, and API availability."
+ : status === 409
+ ? "Vault state conflict. Read the item and its available_operations; ownership, names, keys, and provider are immutable. Card updates require an allowed state."
+ : "Vault request did not complete successfully; its outcome may be unknown.";
+ return `${status ? `HTTP ${status}. ` : ""}${detail} Do not retry a payment or repeat a mutation. Inspect get_item and item_events to determine the current state. Provider error details are withheld.`;
+}
diff --git a/src/lib/mcp/tools/vault-schemas.ts b/src/lib/mcp/tools/vault-schemas.ts
new file mode 100644
index 0000000..aa39eb7
--- /dev/null
+++ b/src/lib/mcp/tools/vault-schemas.ts
@@ -0,0 +1,173 @@
+import { z } from "zod";
+
+// Fresh instances keep tools/list schemas inline for clients that cannot resolve $ref.
+export function vaultItemKeySchema() {
+ return z.string().regex(/^[a-zA-Z0-9._-]{1,255}$/);
+}
+
+function currencySchema() {
+ return z.string().regex(/^[A-Za-z]{3}$/);
+}
+
+function linkTotalSchema() {
+ return z
+ .object({
+ type: z.string(),
+ display_text: z.string(),
+ amount: z.number().int().describe("Amount in minor currency units."),
+ })
+ .strict();
+}
+
+function linkLineItemSchema() {
+ return z
+ .object({
+ name: z.string(),
+ quantity: z.number().int().min(1).optional(),
+ unit_amount: z.number().int().optional(),
+ description: z.string().optional(),
+ sku: z.string().optional(),
+ url: z.string().optional(),
+ image_url: z.string().optional(),
+ product_url: z.string().optional(),
+ totals: z.array(linkTotalSchema()).optional(),
+ })
+ .strict();
+}
+
+export function linkWalletSpecSchema() {
+ return z
+ .object({
+ provider: z.literal("link"),
+ authorization: z
+ .object({
+ method: z.literal("oauth"),
+ client: z.object({ type: z.literal("kernel_managed") }).strict(),
+ })
+ .strict()
+ .describe(
+ "Kernel-managed OAuth only. Follow the returned action URL; never supply OAuth codes, tokens, or client secrets.",
+ ),
+ })
+ .strict();
+}
+
+export function agentCardWalletSpecSchema() {
+ return z
+ .object({
+ provider: z.literal("agentcard"),
+ user_id: z
+ .string()
+ .regex(/^usr_[A-Za-z0-9_]+$/)
+ .describe(
+ "Optional user ID already enrolled by a wallet in this organization. Otherwise follow the returned card_enrollment action. Sandbox/live mode is deployment-configured, not an item option.",
+ )
+ .optional(),
+ })
+ .strict();
+}
+
+export function linkCardSpecSchema() {
+ return z
+ .object({
+ provider: z.literal("link"),
+ wallet: vaultItemKeySchema().describe(
+ "Connected Link wallet item key in this vault.",
+ ),
+ payment_method_id: z
+ .string()
+ .min(1)
+ .describe(
+ "Select an ID from this wallet's advertised payment_methods expansion. Missing capability data is unknown, not ineligible; the provider decides eligibility.",
+ ),
+ amount: z
+ .number()
+ .int()
+ .min(1)
+ .max(500000)
+ .describe(
+ "Requested amount in minor currency units, not a decimal price.",
+ ),
+ currency: currencySchema(),
+ merchant_name: z.string().min(1).max(255),
+ merchant_url: z
+ .string()
+ .url()
+ .describe(
+ "Merchant URL for this purchase. Permitted domains are returned in state.domains; there is no writable domains field.",
+ ),
+ context: z
+ .string()
+ .min(100)
+ .describe(
+ "At least 100 characters of non-sensitive purchase context for the approval request.",
+ ),
+ test: z
+ .boolean()
+ .describe(
+ "Required explicit intent: true requests test credentials; false requests a live credential. Neither submits a merchant payment.",
+ ),
+ expires_at: z.number().int().optional(),
+ line_items: z.array(linkLineItemSchema()).optional(),
+ totals: z.array(linkTotalSchema()).optional(),
+ metadata: z
+ .record(z.string())
+ .describe(
+ "Non-sensitive purchase metadata only. Never include card data, tokens, codes, ciphertext, or provider secrets. Omitted from MCP responses.",
+ )
+ .optional(),
+ })
+ .strict();
+}
+
+export function agentCardCardSpecSchema() {
+ return z
+ .object({
+ provider: z.literal("agentcard"),
+ wallet: vaultItemKeySchema().describe(
+ "AgentCard wallet item key in this vault. Complete its enrollment before checkout.",
+ ),
+ merchant: z
+ .string()
+ .min(1)
+ .max(120)
+ .describe("Merchant shown on the cardholder's approval screen."),
+ amount: z
+ .number()
+ .int()
+ .min(1)
+ .max(Number.MAX_SAFE_INTEGER)
+ .describe("Amount in minor currency units for the checkout approval."),
+ currency: currencySchema(),
+ card_id: z
+ .string()
+ .regex(/^vc_[A-Za-z0-9_]+$/)
+ .describe(
+ "Optional vaulted card ID from the wallet's payment_methods expansion; omit to let the cardholder choose on the approval screen. No per-item test flag: confirm deployment sandbox/live mode before checkout.",
+ )
+ .optional(),
+ })
+ .strict();
+}
+
+export function cardSpecSchema() {
+ return z.discriminatedUnion("provider", [
+ linkCardSpecSchema(),
+ agentCardCardSpecSchema(),
+ ]);
+}
+
+export function vaultItemInputSchema() {
+ return z.discriminatedUnion("type", [
+ z
+ .object({
+ type: z.literal("wallet"),
+ spec: z.discriminatedUnion("provider", [
+ linkWalletSpecSchema(),
+ agentCardWalletSpecSchema(),
+ ]),
+ })
+ .strict(),
+ z.object({ type: z.literal("card"), spec: cardSpecSchema() }).strict(),
+ ]);
+}
diff --git a/src/lib/mcp/tools/vaults.test.ts b/src/lib/mcp/tools/vaults.test.ts
new file mode 100644
index 0000000..810becd
--- /dev/null
+++ b/src/lib/mcp/tools/vaults.test.ts
@@ -0,0 +1,903 @@
+///
+
+import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import Kernel, { APIConnectionTimeoutError, APIError } from "@onkernel/sdk";
+import type {
+ CardVaultItemSpec,
+ VaultItem,
+ WalletVaultItemSpec,
+} from "@onkernel/sdk/resources/vaults/items";
+import { describe, expect, test } from "bun:test";
+import { connectTestMcp, toolResultJSON } from "@/lib/mcp/mcp-test-fixtures";
+import {
+ cardSpecSchema as createCardSpecSchema,
+ vaultItemInputSchema as createVaultItemInputSchema,
+} from "@/lib/mcp/tools/vault-schemas";
+import { registerVaultTools } from "@/lib/mcp/tools/vaults";
+
+const cardSpecSchema = createCardSpecSchema();
+const vaultItemInputSchema = createVaultItemInputSchema();
+
+const dates = {
+ created_at: "2026-09-01T00:00:00Z",
+ updated_at: "2026-09-01T00:00:00Z",
+};
+const vault = { id: "vault_1", name: "checkout", ...dates };
+const linkWallet = {
+ provider: "link",
+ authorization: { method: "oauth", client: { type: "kernel_managed" } },
+} satisfies WalletVaultItemSpec;
+const linkCard = {
+ provider: "link",
+ wallet: "wallet",
+ payment_method_id: "pm_1",
+ merchant_name: "Test shop",
+ merchant_url: "https://shop.example.test",
+ amount: 1250,
+ currency: "usd",
+ context:
+ "The user requested this purchase from the specified merchant. The requested amount and items have been checked against the cart.",
+ test: true,
+} satisfies CardVaultItemSpec;
+const agentCard = {
+ provider: "agentcard",
+ wallet: "wallet",
+ merchant: "Test shop",
+ amount: 1250,
+ currency: "usd",
+} satisfies CardVaultItemSpec;
+const aliases = {
+ number: "0000000000000000",
+ cvc: "000",
+ exp_month: "01",
+ exp_year: "2030",
+};
+
+function walletItem(
+ spec: WalletVaultItemSpec = linkWallet,
+): VaultItem.WalletVaultItem {
+ return {
+ id: "item_wallet",
+ key: "wallet",
+ type: "wallet",
+ spec,
+ state: { provider: spec.provider, status: "connected" },
+ available_operations: [],
+ available_expansions: [
+ {
+ type: "payment_methods",
+ description: "Read display-safe funding methods.",
+ },
+ ],
+ ...dates,
+ };
+}
+function cardItem(spec: CardVaultItemSpec = linkCard): VaultItem.CardVaultItem {
+ return {
+ id: "item_card",
+ key: "card",
+ type: "card",
+ spec,
+ state: { provider: spec.provider, status: "requested" },
+ available_operations:
+ spec.provider === "link"
+ ? [
+ {
+ type: "authorize",
+ description:
+ "Authorize this card request with the connected wallet.",
+ },
+ ]
+ : [],
+ available_expansions: [],
+ ...dates,
+ };
+}
+
+function text(result: Awaited>) {
+ return (result.content as Array<{ text: string }>)[0].text;
+}
+
+describe("vault schemas", () => {
+ test("accepts both provider variants without inventing implicit authorization", () => {
+ for (const spec of [
+ linkWallet,
+ { provider: "agentcard" },
+ { provider: "agentcard", user_id: "usr_existing" },
+ ]) {
+ expect(
+ vaultItemInputSchema.safeParse({ type: "wallet", spec }).success,
+ ).toBeTrue();
+ }
+ for (const spec of [
+ linkCard,
+ { ...linkCard, test: false },
+ agentCard,
+ { ...agentCard, card_id: "vc_selected" },
+ ]) {
+ expect(cardSpecSchema.safeParse(spec).success).toBeTrue();
+ }
+ expect(cardSpecSchema.parse(linkCard)).not.toHaveProperty("authorize");
+ expect(cardSpecSchema.parse(agentCard)).not.toHaveProperty("test");
+ });
+
+ test.each([
+ { ...linkCard, amount: 0 },
+ { ...linkCard, amount: 1.5 },
+ { ...linkCard, amount: 500001 },
+ { ...linkCard, currency: "dollars" },
+ { ...linkCard, payment_method_id: "" },
+ { ...linkCard, context: "too short" },
+ { ...linkCard, test: undefined },
+ { ...linkCard, authorize: true },
+ { ...linkCard, domains: ["shop.example.test"] },
+ { ...linkCard, card_number: "sensitive-value" },
+ { ...linkCard, line_items: [{ name: "test", quantity: 0 }] },
+ { ...linkCard, totals: [{ type: "subtotal", amount: 1250 }] },
+ { ...agentCard, test: true },
+ { ...agentCard, amount: Number.MAX_SAFE_INTEGER + 1 },
+ { ...agentCard, card_id: "raw-card-data" },
+ { ...agentCard, provider: "unsupported" },
+ ])("rejects unsupported or out-of-bounds card specs %#", (spec) => {
+ expect(cardSpecSchema.safeParse(spec).success).toBeFalse();
+ });
+
+ test("rejects wallet/card mismatches, secret inputs, and extra item fields", () => {
+ for (const item of [
+ { type: "wallet", spec: linkCard },
+ { type: "card", spec: linkWallet },
+ {
+ type: "wallet",
+ spec: { ...linkWallet, oauth_code: "sensitive-value" },
+ },
+ {
+ type: "wallet",
+ spec: {
+ provider: "link",
+ authorization: {
+ method: "oauth",
+ client: {
+ type: "kernel_managed",
+ client_secret: "sensitive-value",
+ },
+ },
+ },
+ },
+ { type: "wallet", spec: linkWallet, new_key: "renamed" },
+ ])
+ expect(vaultItemInputSchema.safeParse(item).success).toBeFalse();
+ });
+});
+
+describe("manage_vaults", () => {
+ test("advertises one project-aware tool with payment safety and conservative annotations", async () => {
+ const { client, close } = await connectTestMcp(registerVaultTools, {});
+ try {
+ const { tools } = await client.listTools();
+ expect(tools.map(({ name }) => name)).toEqual(["manage_vaults"]);
+ const tool = tools[0];
+ expect(JSON.stringify(tool.inputSchema)).not.toContain('"$ref"');
+ expect(tool.annotations).toMatchObject({
+ readOnlyHint: false,
+ destructiveHint: true,
+ idempotentHint: false,
+ openWorldHint: true,
+ });
+ expect(tool.inputSchema.properties).toHaveProperty("project");
+ expect(tool.inputSchema.properties).toHaveProperty("project_id");
+ for (const phrase of [
+ "do not submit a merchant payment",
+ "project_id) cannot change",
+ "state.domains",
+ "no writable domains",
+ "available_operations",
+ "payment_methods",
+ "Sandbox/live",
+ "non-secret state.aliases",
+ "Never retry failed, timed-out, rejected, or indeterminate payments",
+ ]) {
+ expect(tool.description).toContain(phrase);
+ }
+ for (const action of ["rename", "authorize", "callback"]) {
+ const result = await client.callTool({
+ name: "manage_vaults",
+ arguments: { action },
+ });
+ expect(result.isError).toBeTrue();
+ }
+ } finally {
+ await close();
+ }
+ });
+
+ test("forwards vault lifecycle, pagination, and fixed-project scope without changing ownership", async () => {
+ const calls: unknown[] = [];
+ const scopes: unknown[] = [];
+ const { client, close } = await connectTestMcp(
+ (server, dependencies) =>
+ registerVaultTools(server, {
+ createKernelClient: (token, project) => {
+ scopes.push([token, project]);
+ return dependencies!.createKernelClient(token, project);
+ },
+ }),
+ {
+ vaults: {
+ upsert: async (...args: unknown[]) => {
+ calls.push(["upsert", ...args]);
+ return vault;
+ },
+ retrieve: async (...args: unknown[]) => {
+ calls.push(["get", ...args]);
+ return vault;
+ },
+ list: async (...args: unknown[]) => {
+ calls.push(["list", ...args]);
+ return {
+ getPaginatedItems: () => [vault],
+ has_more: true,
+ next_offset: 5,
+ };
+ },
+ delete: async (...args: unknown[]) => {
+ calls.push(["delete", ...args]);
+ },
+ },
+ },
+ );
+ try {
+ const created = toolResultJSON(
+ await client.callTool({
+ name: "manage_vaults",
+ arguments: {
+ action: "upsert",
+ name: "checkout",
+ project: "proj_test",
+ },
+ }),
+ );
+ expect(created).toEqual(vault);
+ await client.callTool({
+ name: "manage_vaults",
+ arguments: {
+ action: "get",
+ id_or_name: "checkout",
+ project_id: "proj_test",
+ },
+ });
+ const listed = toolResultJSON(
+ await client.callTool({
+ name: "manage_vaults",
+ arguments: { action: "list", limit: 2, offset: 3 },
+ }),
+ );
+ expect(listed).toEqual({
+ items: [vault],
+ has_more: true,
+ next_offset: 5,
+ });
+ expect(
+ text(
+ await client.callTool({
+ name: "manage_vaults",
+ arguments: { action: "delete", id_or_name: "vault_1" },
+ }),
+ ),
+ ).toContain("invalidated");
+ const requestOptions = expect.objectContaining({
+ maxRetries: 0,
+ timeout: 90000,
+ signal: expect.any(AbortSignal),
+ });
+ expect(calls).toEqual([
+ ["upsert", { name: "checkout" }, requestOptions],
+ ["get", "checkout", requestOptions],
+ ["list", { limit: 2, offset: 3 }, requestOptions],
+ ["delete", "vault_1", requestOptions],
+ ]);
+ expect(scopes).toEqual(Array(4).fill(["test-token", "proj_test"]));
+ const wrongProject = await client.callTool({
+ name: "manage_vaults",
+ arguments: { action: "list", project: "other_project" },
+ });
+ expect(wrongProject.isError).toBeTrue();
+ expect(calls).toHaveLength(4);
+ } finally {
+ await close();
+ }
+ });
+
+ test("handles empty collections with setup guidance", async () => {
+ const { client, close } = await connectTestMcp(registerVaultTools, {
+ vaults: {
+ list: async () => ({
+ getPaginatedItems: () => [],
+ has_more: false,
+ next_offset: null,
+ }),
+ items: { list: async () => [] },
+ },
+ });
+ try {
+ const listed = toolResultJSON(
+ await client.callTool({
+ name: "manage_vaults",
+ arguments: { action: "list" },
+ }),
+ );
+ expect(listed.items).toEqual([]);
+ expect(listed.note).toContain("upsert");
+ const items = toolResultJSON(
+ await client.callTool({
+ name: "manage_vaults",
+ arguments: { action: "list_items", id_or_name: "checkout" },
+ }),
+ );
+ expect(items.items).toEqual([]);
+ expect(items.note).toContain("wallet");
+ } finally {
+ await close();
+ }
+ });
+
+ test("forwards the Link wallet/card lifecycle through the real preview SDK", async () => {
+ const calls: Array<{ method: string; path: string; body: unknown }> = [];
+ const pendingWallet = {
+ ...walletItem(),
+ state: { provider: "link", status: "pending_authorization" },
+ action: {
+ name: "link_oauth",
+ url: "https://provider.example.test/connect",
+ },
+ };
+ const pendingCard = {
+ ...cardItem(),
+ state: { provider: "link", status: "pending_authorization" },
+ action: {
+ name: "spend_approval",
+ url: "https://provider.example.test/approve",
+ },
+ };
+ const readyCard = {
+ ...cardItem(),
+ state: {
+ provider: "link",
+ status: "ready",
+ aliases,
+ domains: ["shop.example.test"],
+ },
+ available_operations: [],
+ };
+ const expandedWallet = {
+ ...walletItem(),
+ expanded: {
+ payment_methods: [
+ {
+ id: "pm_1",
+ provider: "link",
+ type: "card",
+ display: { last4: "1234" },
+ is_default: true,
+ capabilities: {},
+ },
+ ],
+ },
+ };
+ const responses = [
+ pendingWallet,
+ expandedWallet,
+ cardItem(),
+ cardItem(),
+ cardItem(),
+ pendingCard,
+ readyCard,
+ [
+ {
+ id: "event_1",
+ name: "payment_unknown",
+ browser_id: "browser_1",
+ created_at: dates.created_at,
+ data: { raw_response: "sensitive-value" },
+ },
+ ],
+ null,
+ ];
+ const sdk = new Kernel({
+ apiKey: "test-key",
+ baseURL: "https://api.example.test",
+ fetch: async (input, init) => {
+ const request = new Request(input, init);
+ const url = new URL(request.url);
+ calls.push({
+ method: request.method,
+ path: url.pathname + url.search,
+ body: request.body ? await request.json() : undefined,
+ });
+ const response = responses.shift();
+ return response === null
+ ? new Response(null, { status: 204 })
+ : Response.json(response);
+ },
+ });
+ const { client, close } = await connectTestMcp(registerVaultTools, sdk);
+ try {
+ const call = (args: Record) =>
+ client.callTool({
+ name: "manage_vaults",
+ arguments: { id_or_name: "checkout", ...args },
+ });
+ expect(
+ toolResultJSON(
+ await call({
+ action: "upsert_item",
+ key: "wallet",
+ item: { type: "wallet", spec: linkWallet },
+ }),
+ ).action,
+ ).toEqual(pendingWallet.action);
+ const methods = toolResultJSON(
+ await call({
+ action: "get_item",
+ key: "wallet",
+ expand: ["payment_methods"],
+ wait: 0,
+ }),
+ );
+ expect(methods.expanded.payment_methods[0].capabilities).toEqual({});
+ const purchaseSpec = {
+ ...linkCard,
+ test: false,
+ expires_at: 1900000000,
+ metadata: { purpose: "purchase" },
+ line_items: [
+ {
+ name: "Item",
+ quantity: 1,
+ unit_amount: 1250,
+ description: "Purchase",
+ sku: "sku_1",
+ url: "https://shop.example.test/item",
+ image_url: "https://shop.example.test/image",
+ product_url: "https://shop.example.test/product",
+ totals: [
+ { type: "subtotal", display_text: "Subtotal", amount: 1250 },
+ ],
+ },
+ ],
+ totals: [{ type: "total", display_text: "Total", amount: 1250 }],
+ };
+ await call({
+ action: "upsert_item",
+ key: "card",
+ item: { type: "card", spec: purchaseSpec },
+ });
+ await call({
+ action: "update_item",
+ key: "card",
+ spec: { ...linkCard, amount: 2000 },
+ });
+ expect(
+ toolResultJSON(
+ await call({
+ action: "perform_item_operation",
+ key: "card",
+ operation: "authorize",
+ }),
+ ).action,
+ ).toEqual(pendingCard.action);
+ const ready = toolResultJSON(
+ await call({ action: "get_item", key: "card", wait: 60 }),
+ );
+ expect(ready.state.aliases).toEqual(aliases);
+ expect(ready.state.domains).toEqual(["shop.example.test"]);
+ const events = toolResultJSON(
+ await call({
+ action: "item_events",
+ key: "card",
+ after: "event_0",
+ wait: 5,
+ }),
+ );
+ expect(events.items[0]).toEqual({
+ id: "event_1",
+ name: "payment_unknown",
+ browser_id: "browser_1",
+ created_at: dates.created_at,
+ });
+ expect(events.note).toContain("does not establish payment success");
+ await call({ action: "delete_item", key: "card" });
+ expect(calls).toEqual([
+ {
+ method: "PUT",
+ path: "/vaults/checkout/items/wallet",
+ body: { type: "wallet", spec: linkWallet },
+ },
+ {
+ method: "GET",
+ path: "/vaults/checkout/items/wallet?expand=payment_methods&wait=0",
+ body: undefined,
+ },
+ {
+ method: "PUT",
+ path: "/vaults/checkout/items/card",
+ body: { type: "card", spec: purchaseSpec },
+ },
+ {
+ method: "PATCH",
+ path: "/vaults/checkout/items/card",
+ body: { spec: { ...linkCard, amount: 2000 } },
+ },
+ { method: "GET", path: "/vaults/checkout/items/card", body: undefined },
+ {
+ method: "POST",
+ path: "/vaults/checkout/items/card/operations",
+ body: { type: "authorize" },
+ },
+ {
+ method: "GET",
+ path: "/vaults/checkout/items/card?wait=60",
+ body: undefined,
+ },
+ {
+ method: "GET",
+ path: "/vaults/checkout/items/card/events?after=event_0&wait=5",
+ body: undefined,
+ },
+ {
+ method: "DELETE",
+ path: "/vaults/checkout/items/card",
+ body: undefined,
+ },
+ ]);
+ } finally {
+ await close();
+ }
+ });
+
+ test("supports AgentCard enrollment, configured cards, and approval/outcome inspection", async () => {
+ const calls: unknown[] = [];
+ const authorization = {
+ id: "auth_1",
+ status: "approved",
+ psp: "stripe",
+ merchant: "Test shop",
+ amount_cents: 1250,
+ currency: "usd",
+ created_at: dates.created_at,
+ approval_url: "https://provider.example.test/approve",
+ charged_kind: "none",
+ replay_attempted: true,
+ replay_delivered: false,
+ reason: "sensitive-value",
+ };
+ const card = {
+ ...cardItem(agentCard),
+ state: { provider: "agentcard", status: "ready", aliases, authorization },
+ };
+ const { client, close } = await connectTestMcp(registerVaultTools, {
+ vaults: {
+ items: {
+ upsert: async (
+ key: string,
+ params: { spec: WalletVaultItemSpec | CardVaultItemSpec },
+ ) => {
+ calls.push(["upsert", key, params]);
+ return key === "wallet"
+ ? {
+ ...walletItem({ provider: "agentcard" }),
+ action: {
+ name: "card_enrollment",
+ url: "https://provider.example.test/enroll",
+ },
+ }
+ : card;
+ },
+ update: async (...args: unknown[]) => {
+ calls.push(["update", ...args]);
+ return card;
+ },
+ retrieve: async () => card,
+ performOperation: async () => {
+ calls.push(["unexpected_authorization"]);
+ return card;
+ },
+ },
+ },
+ });
+ try {
+ const call = (args: Record) =>
+ client.callTool({
+ name: "manage_vaults",
+ arguments: { id_or_name: "checkout", ...args },
+ });
+ expect(
+ toolResultJSON(
+ await call({
+ action: "upsert_item",
+ key: "wallet",
+ item: {
+ type: "wallet",
+ spec: { provider: "agentcard", user_id: "usr_existing" },
+ },
+ }),
+ ).action.name,
+ ).toBe("card_enrollment");
+ await call({
+ action: "upsert_item",
+ key: "card",
+ item: { type: "card", spec: { ...agentCard, card_id: "vc_selected" } },
+ });
+ await call({
+ action: "update_item",
+ key: "card",
+ spec: { ...agentCard, amount: 1500 },
+ });
+ const result = await call({ action: "get_item", key: "card" });
+ expect(text(result)).not.toContain("sensitive-value");
+ expect(toolResultJSON(result).state.authorization).toMatchObject({
+ status: "approved",
+ charged_kind: "none",
+ replay_delivered: false,
+ });
+ expect(
+ (
+ await call({
+ action: "perform_item_operation",
+ key: "card",
+ operation: "authorize",
+ })
+ ).isError,
+ ).toBeTrue();
+ expect(calls[0]).toEqual([
+ "upsert",
+ "wallet",
+ {
+ id_or_name: "checkout",
+ type: "wallet",
+ spec: { provider: "agentcard", user_id: "usr_existing" },
+ },
+ ]);
+ expect(calls[1]).toEqual([
+ "upsert",
+ "card",
+ {
+ id_or_name: "checkout",
+ type: "card",
+ spec: { ...agentCard, card_id: "vc_selected" },
+ },
+ ]);
+ expect(calls).toHaveLength(3);
+ expect(calls[2]).toEqual([
+ "update",
+ "card",
+ { id_or_name: "checkout", spec: { ...agentCard, amount: 1500 } },
+ expect.objectContaining({ maxRetries: 0 }),
+ ]);
+ } finally {
+ await close();
+ }
+ });
+
+ test("strips unknown secrets while preserving actions, aliases, domains, and state", async () => {
+ const secret = "sensitive-value";
+ const unsafe = {
+ ...cardItem(),
+ secret_enc: secret,
+ provider_response: secret,
+ spec: { ...linkCard, metadata: { token: secret } },
+ state: {
+ provider: "link",
+ status: "ready",
+ aliases: { ...aliases, card_number: secret },
+ masks: { brand: "test", last4: "1234", token: secret },
+ domains: ["shop.example.test"],
+ oauth_token: secret,
+ },
+ action: { name: "collect", data: { card_number: secret } },
+ };
+ const { client, close } = await connectTestMcp(registerVaultTools, {
+ vaults: {
+ items: { retrieve: async () => unsafe, list: async () => [unsafe] },
+ },
+ });
+ try {
+ for (const action of ["get_item", "list_items"]) {
+ const result = await client.callTool({
+ name: "manage_vaults",
+ arguments: { action, id_or_name: "checkout", key: "card" },
+ });
+ expect(result.isError).not.toBeTrue();
+ expect(text(result)).not.toContain(secret);
+ const parsed = toolResultJSON(result);
+ const item = action === "get_item" ? parsed : parsed.items[0];
+ expect(item.state.aliases).toEqual(aliases);
+ expect(item.state.masks).toEqual({ brand: "test", last4: "1234" });
+ expect(item.action).toEqual({ name: "collect" });
+ }
+ } finally {
+ await close();
+ }
+ });
+
+ test.each([400, 401, 403, 404, 409, 429, 500])(
+ "withholds HTTP %i provider errors without retrying the real SDK request",
+ async (status) => {
+ let attempts = 0;
+ const sdk = new Kernel({
+ apiKey: "test-key",
+ baseURL: "https://api.example.test",
+ fetch: async () => {
+ attempts++;
+ return Response.json(
+ {
+ message: "sensitive-value",
+ code: "sensitive-value",
+ card_number: "sensitive-value",
+ oauth_token: "sensitive-value",
+ },
+ { status },
+ );
+ },
+ });
+ const { client, close } = await connectTestMcp(registerVaultTools, sdk);
+ try {
+ const result = await client.callTool({
+ name: "manage_vaults",
+ arguments: {
+ action: "upsert_item",
+ id_or_name: "checkout",
+ key: "card",
+ item: { type: "card", spec: linkCard },
+ },
+ });
+ expect(attempts).toBe(1);
+ expect(result.isError).toBeTrue();
+ expect(text(result)).toContain(`HTTP ${status}`);
+ expect(text(result)).not.toContain("sensitive-value");
+ expect(text(result)).toContain("Do not retry a payment");
+ } finally {
+ await close();
+ }
+ },
+ );
+
+ test.each([
+ new APIConnectionTimeoutError(),
+ new Error("sensitive-value"),
+ APIError.generate(
+ 409,
+ { message: "sensitive-value" },
+ undefined,
+ new Headers(),
+ ),
+ ])("withholds transport and unexpected errors %#", async (error) => {
+ let calls = 0;
+ const { client, close } = await connectTestMcp(registerVaultTools, {
+ vaults: {
+ items: {
+ retrieve: async () => {
+ calls++;
+ throw error;
+ },
+ },
+ },
+ });
+ try {
+ const result = await client.callTool({
+ name: "manage_vaults",
+ arguments: { action: "get_item", id_or_name: "checkout", key: "card" },
+ });
+ expect(result.isError).toBeTrue();
+ expect(text(result)).not.toContain("sensitive-value");
+ expect(text(result)).toContain("item_events");
+ expect(calls).toBe(1);
+ } finally {
+ await close();
+ }
+ });
+
+ test("waits beyond the requested long-poll budget without automatic retries", async () => {
+ const calls: unknown[] = [];
+ const { client, close } = await connectTestMcp(registerVaultTools, {
+ vaults: {
+ items: {
+ retrieve: async (...args: unknown[]) => {
+ calls.push(args);
+ return cardItem();
+ },
+ events: async (...args: unknown[]) => {
+ calls.push(args);
+ return [];
+ },
+ },
+ },
+ });
+ try {
+ for (const action of ["get_item", "item_events"]) {
+ await client.callTool({
+ name: "manage_vaults",
+ arguments: { action, id_or_name: "checkout", key: "card", wait: 60 },
+ });
+ }
+ expect(calls).toEqual(
+ Array(2).fill([
+ "card",
+ { id_or_name: "checkout", wait: 60 },
+ { timeout: 90000, maxRetries: 0, signal: expect.any(AbortSignal) },
+ ]),
+ );
+ } finally {
+ await close();
+ }
+ });
+
+ test("does not leak schema validation details from an unexpected provider response", async () => {
+ const { client, close } = await connectTestMcp(registerVaultTools, {
+ vaults: {
+ items: {
+ retrieve: async () => ({
+ ...cardItem(),
+ state: { provider: "sensitive-value", status: "sensitive-value" },
+ }),
+ },
+ },
+ });
+ try {
+ const result = await client.callTool({
+ name: "manage_vaults",
+ arguments: { action: "get_item", id_or_name: "checkout", key: "card" },
+ });
+ expect(result.isError).toBeTrue();
+ expect(text(result)).not.toContain("sensitive-value");
+ } finally {
+ await close();
+ }
+ });
+
+ test("validates required action fields, wait, and expansions before SDK calls", async () => {
+ const { client, close } = await connectTestMcp(registerVaultTools, {
+ vaults: { items: {} },
+ });
+ try {
+ for (const args of [
+ { action: "upsert" },
+ { action: "get" },
+ { action: "get_item", id_or_name: "checkout" },
+ { action: "upsert_item", id_or_name: "checkout", key: "card" },
+ { action: "update_item", id_or_name: "checkout", key: "card" },
+ {
+ action: "perform_item_operation",
+ id_or_name: "checkout",
+ key: "card",
+ },
+ ...[-1, 61, 0.5].map((wait) => ({
+ action: "get_item",
+ id_or_name: "checkout",
+ key: "card",
+ wait,
+ })),
+ {
+ action: "get_item",
+ id_or_name: "checkout",
+ key: "card",
+ expand: ["secrets"],
+ },
+ {
+ action: "get_item",
+ id_or_name: "checkout",
+ key: "card",
+ expand: ["payment_methods", "payment_methods"],
+ },
+ ]) {
+ const result = await client.callTool({
+ name: "manage_vaults",
+ arguments: args,
+ });
+ expect(result.isError).toBeTrue();
+ expect(text(result)).not.toContain("is not a function");
+ }
+ } finally {
+ await close();
+ }
+ });
+});
diff --git a/src/lib/mcp/tools/vaults.ts b/src/lib/mcp/tools/vaults.ts
new file mode 100644
index 0000000..80c29fd
--- /dev/null
+++ b/src/lib/mcp/tools/vaults.ts
@@ -0,0 +1,289 @@
+import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { z } from "zod";
+import {
+ defaultMcpDependencies,
+ type McpDependencies,
+} from "@/lib/mcp/dependencies";
+import {
+ projectForOperation,
+ projectSelectionInputSchema,
+} from "@/lib/mcp/project-selection";
+import { longOperationOptions } from "@/lib/mcp/request-options";
+import {
+ errorResponse,
+ itemsJsonResponse,
+ jsonResponse,
+ paginatedJsonResponse,
+ textResponse,
+ throwToolError,
+} from "@/lib/mcp/responses";
+import { paginationParams } from "@/lib/mcp/schemas";
+import {
+ cardSpecSchema,
+ vaultItemInputSchema,
+ vaultItemKeySchema,
+} from "@/lib/mcp/tools/vault-schemas";
+import {
+ vaultErrorMessage,
+ vaultEventOutputSchema,
+ vaultItemOutputSchema,
+ vaultOutputSchema,
+} from "@/lib/mcp/tools/vault-responses";
+
+export function registerVaultTools(
+ server: McpServer,
+ dependencies: McpDependencies = defaultMcpDependencies,
+) {
+ server.tool(
+ "manage_vaults",
+ 'Prepare and observe payment credentials in project-owned vaults; these API calls do not submit a merchant payment or grant permission to retry one. First inspect get_connection_context, then select a project and use "list", "get", or "upsert" to select/create a vault by immutable name. The owning project (project_id) cannot change. "delete" invalidates the vault and its items. Use "upsert_item" with a typed wallet/card request, "list_items" for discovery, "get_item" for current state, "update_item" with a complete card spec in an allowed state, and "delete_item" to invalidate an item (deleting a wallet also invalidates its dependent cards). Item keys, types, providers, and wallet specs are immutable; there is no rename or move operation.\n\nFor Link, create a kernel_managed OAuth wallet and follow its returned action URL until connected. Read the wallet first, then request expand=["payment_methods"] only when advertised in available_expansions; select its payment_method_id. Create a card request with the wallet key, merchant name/URL, amount in minor units, currency, purchase context, and explicit test=true/false intent. Inspect returned state.domains for permitted domains: this preview has no writable domains field. Upserting or updating a Link card does not authorize it. Read available_operations and their descriptions, then explicitly use "perform_item_operation" with operation="authorize" only when advertised. Link card specs can change only while requested.\n\nFor AgentCard, follow the wallet card_enrollment action; user_id can only reuse an already-enrolled user in this organization. Sandbox/live mode is deployment-configured, with no per-item test flag; confirm it before checkout. Configure the card merchant, amount, currency, wallet, and optional card_id. Cards are reusable and may be configured before or between authorizations when the API permits; checkout submission triggers a separate approval-gated authorization, not this tool.\n\nFollow returned action URLs or push/collection/MFA/embedded instructions on the provider-hosted surface, never by sending secrets to MCP. If no usable surface is supplied, stop for user assistance rather than inventing a callback. Attach the vault at browser creation via manage_browsers.vaults in the same project; bindings cannot change afterward. Use only the returned non-secret state.aliases in checkout, not real card data. Inspect get_item and item_events for outcomes; ready, consumed, or approved alone does not prove payment success. Never retry failed, timed-out, rejected, or indeterminate payments, including with a replacement item or browser. Never request or expose card data, OAuth tokens/codes, ciphertext, provider secrets, or raw provider responses. MCP outputs omit arbitrary metadata, event payloads, and unstructured authorization reasons.',
+ {
+ ...projectSelectionInputSchema({
+ project:
+ "Project name or ID owning the vault. Omit for the fixed connection project or the API default project (including list). Use the same project for vault items and browser creation; selecting a project never moves a vault.",
+ project_id:
+ "Deprecated: use project. Selects the owning project; project_id is immutable ownership, not an update field.",
+ }),
+ action: z
+ .enum([
+ "upsert",
+ "list",
+ "get",
+ "delete",
+ "upsert_item",
+ "update_item",
+ "list_items",
+ "get_item",
+ "delete_item",
+ "item_events",
+ "perform_item_operation",
+ ])
+ .describe(
+ "Operation to perform. Mutations are never automatically retried.",
+ ),
+ id_or_name: z
+ .string()
+ .min(1)
+ .describe(
+ "Vault ID or immutable name. Required except for list and upsert.",
+ )
+ .optional(),
+ name: z
+ .string()
+ .regex(/^[a-zA-Z0-9._-]{1,255}$/)
+ .describe(
+ "(upsert) Immutable vault name, unique in the project; cannot be a cuid-like ID. Creates or retrieves, never renames.",
+ )
+ .optional(),
+ key: vaultItemKeySchema()
+ .describe(
+ "Immutable item key within this vault. Required for item operations except list_items.",
+ )
+ .optional(),
+ item: vaultItemInputSchema()
+ .describe(
+ "(upsert_item) Exactly type and provider-discriminated spec. Identical PUTs may retrieve an existing item; changed specs conflict, and authorized Link cards cannot be replaced. Use get_item to inspect, not repeated writes.",
+ )
+ .optional(),
+ spec: cardSpecSchema()
+ .describe(
+ "(update_item) Complete replacement card spec, not a partial patch. The API enforces provider and lifecycle constraints. Never use this to repeat an uncertain payment.",
+ )
+ .optional(),
+ operation: z
+ .literal("authorize")
+ .describe(
+ "(perform_item_operation) Only invoke after reading this operation's available_operations description. Applies to eligible Link cards, not AgentCard checkout approvals.",
+ )
+ .optional(),
+ expand: z
+ .array(z.literal("payment_methods"))
+ .max(1)
+ .describe(
+ "(get_item) Request only an advertised available_expansions type. Live display-safe wallet funding methods are returned in expanded; unavailable expansions fail rather than returning partial data.",
+ )
+ .optional(),
+ wait: z
+ .number()
+ .int()
+ .min(0)
+ .max(60)
+ .describe(
+ "(get_item, item_events) Long-poll for up to this many seconds (default 0). Set the MCP client's timeout above wait + 30 seconds. A wait timeout is not permission to repeat checkout.",
+ )
+ .optional(),
+ after: z
+ .string()
+ .min(1)
+ .describe(
+ "(item_events) Return events after this event ID. Continue using the last returned ID; an empty list means no new events, not payment success.",
+ )
+ .optional(),
+ ...paginationParams,
+ },
+ {
+ title: "Manage Kernel vaults and payment items",
+ readOnlyHint: false,
+ destructiveHint: true,
+ idempotentHint: false,
+ openWorldHint: true,
+ },
+ async (params, extra) => {
+ if (!extra.authInfo) throw new Error("Authentication required");
+ const client = dependencies.createKernelClient(
+ extra.authInfo.token,
+ projectForOperation(extra.authInfo, params),
+ );
+ const requestOptions = {
+ ...longOperationOptions(params.wait ?? 60),
+ signal: extra.signal,
+ };
+
+ try {
+ if (params.action === "upsert") {
+ if (!params.name)
+ return errorResponse("Error: name is required for upsert.");
+ const vault = await client.vaults.upsert(
+ { name: params.name },
+ requestOptions,
+ );
+ return jsonResponse(vaultOutputSchema.parse(vault));
+ }
+ if (params.action === "list") {
+ const page = await client.vaults.list(
+ {
+ ...(params.limit !== undefined && { limit: params.limit }),
+ ...(params.offset !== undefined && { offset: params.offset }),
+ },
+ requestOptions,
+ );
+ return paginatedJsonResponse(page, {
+ mapItem: (vault) => vaultOutputSchema.parse(vault),
+ emptyText:
+ 'No vaults in this project. Use action "upsert" with an immutable name to create one.',
+ });
+ }
+ if (!params.id_or_name)
+ return errorResponse(
+ "Error: id_or_name is required for this action.",
+ );
+ const id_or_name = params.id_or_name;
+ if (params.action === "get") {
+ return jsonResponse(
+ vaultOutputSchema.parse(
+ await client.vaults.retrieve(id_or_name, requestOptions),
+ ),
+ );
+ }
+ if (params.action === "delete") {
+ await client.vaults.delete(id_or_name, requestOptions);
+ return textResponse("Vault deleted and its items invalidated.");
+ }
+ if (params.action === "list_items") {
+ return itemsJsonResponse(
+ await client.vaults.items.list(id_or_name, requestOptions),
+ {
+ mapItem: (item) => vaultItemOutputSchema.parse(item),
+ emptyText:
+ "No items in this vault. Create a provider wallet before preparing a card request.",
+ },
+ );
+ }
+ if (!params.key)
+ return errorResponse("Error: key is required for this item action.");
+ switch (params.action) {
+ case "upsert_item": {
+ if (!params.item)
+ return errorResponse("Error: item is required for upsert_item.");
+ const item = await client.vaults.items.upsert(
+ params.key,
+ { id_or_name, ...params.item },
+ requestOptions,
+ );
+ return jsonResponse(vaultItemOutputSchema.parse(item));
+ }
+ case "update_item": {
+ if (!params.spec)
+ return errorResponse("Error: spec is required for update_item.");
+ const item = await client.vaults.items.update(
+ params.key,
+ { id_or_name, spec: params.spec },
+ requestOptions,
+ );
+ return jsonResponse(vaultItemOutputSchema.parse(item));
+ }
+ case "get_item": {
+ const item = await client.vaults.items.retrieve(
+ params.key,
+ {
+ id_or_name,
+ ...(params.expand !== undefined && { expand: params.expand }),
+ ...(params.wait !== undefined && { wait: params.wait }),
+ },
+ requestOptions,
+ );
+ return jsonResponse(vaultItemOutputSchema.parse(item));
+ }
+ case "delete_item": {
+ await client.vaults.items.delete(
+ params.key,
+ { id_or_name },
+ requestOptions,
+ );
+ return textResponse(
+ "Vault item invalidated; dependent cards are also invalidated when deleting a wallet.",
+ );
+ }
+ case "item_events": {
+ const events = await client.vaults.items.events(
+ params.key,
+ {
+ id_or_name,
+ ...(params.after !== undefined && { after: params.after }),
+ ...(params.wait !== undefined && { wait: params.wait }),
+ },
+ requestOptions,
+ );
+ return itemsJsonResponse(events, {
+ mapItem: (event) => vaultEventOutputSchema.parse(event),
+ note: "Raw event data is withheld. Use get_item for typed state and authorization outcomes. Continue with after set to the last event ID; no new events does not establish payment success.",
+ });
+ }
+ case "perform_item_operation": {
+ if (!params.operation)
+ return errorResponse(
+ "Error: operation is required for perform_item_operation.",
+ );
+ const current = await client.vaults.items.retrieve(
+ params.key,
+ { id_or_name },
+ requestOptions,
+ );
+ if (
+ !current.available_operations.some(
+ ({ type }) => type === params.operation,
+ )
+ ) {
+ return errorResponse(
+ "Error: operation is not currently advertised. Read get_item and its available_operations; do not retry a payment.",
+ );
+ }
+ const item = await client.vaults.items.performOperation(
+ params.key,
+ { id_or_name, type: params.operation },
+ requestOptions,
+ );
+ return jsonResponse(vaultItemOutputSchema.parse(item));
+ }
+ }
+ } catch (error) {
+ throwToolError(
+ "manage_vaults",
+ params.action,
+ error,
+ vaultErrorMessage(error),
+ );
+ }
+ },
+ );
+}
diff --git a/vendor/README.md b/vendor/README.md
new file mode 100644
index 0000000..a018dc3
--- /dev/null
+++ b/vendor/README.md
@@ -0,0 +1,35 @@
+# Kernel SDK preview
+
+`kernel-sdk-768cea122220150aacc33074ea00a1c0afaf879e.tgz` contains the unmodified build output of:
+
+- Source: `kernel/kernel-node-sdk-staging`
+- Preview branch: `stlc/preview/pr-3698`
+- Pinned commit: `768cea122220150aacc33074ea00a1c0afaf879e`
+- SHA-256: `2261b802bfee25e86bad3b601129c9046beff323500075ffa201c35b70e8bba4`
+
+This prerelease provides the vault API. Its generated package version still reads `0.98.0`; the archive filename, commit, and digest identify the preview, not that version number. The source repository requires authentication and contains no built package. Vendoring the compiled archive lets public CI and contributors use `bun install --frozen-lockfile` without private GitHub access or SDK build scripts. The archive includes the SDK's Apache-2.0 license and source maps/sources, like its published package.
+
+Replace this dependency and remove the archive when a released SDK includes the same API. Do not substitute the current released `0.98.0` package: it lacks these vault resources.
+
+## Rebuild
+
+Use Bun 1.3.3, Node 22, GNU tar, and gzip. Rebuilding requires read access to the source repository; ordinary MCP installation does not. These commands only read the SDK repository and build in a temporary directory.
+
+```bash
+mcp_root="$PWD"
+sha=768cea122220150aacc33074ea00a1c0afaf879e
+work=$(mktemp -d)
+gh repo clone kernel/kernel-node-sdk-staging "$work/source" -- --depth=1 --no-checkout
+git -C "$work/source" fetch --depth=1 origin "$sha"
+mkdir "$work/build"
+git -C "$work/source" archive "$sha" | tar -x -C "$work/build"
+cd "$work/build"
+bun install --ignore-scripts
+bun run build
+tar --sort=name --mtime='UTC 1970-01-01' --owner=0 --group=0 \
+ --numeric-owner --format=gnu --transform='s,^dist,package,' -cf - dist \
+ | gzip -n > "$mcp_root/vendor/kernel-sdk-$sha.tgz"
+sha256sum "$mcp_root/vendor/kernel-sdk-$sha.tgz"
+```
+
+The SDK build uses its pinned TypeScript 5.8.3 and `tsc-multi` 1.1.11. Bun imports the SDK's existing dependency lock during the temporary build; that temporary lockfile is not part of the MCP repository. Fixed archive ordering, ownership, and timestamps make the archive deterministic. No SDK source changes are applied.
diff --git a/vendor/kernel-sdk-768cea122220150aacc33074ea00a1c0afaf879e.tgz b/vendor/kernel-sdk-768cea122220150aacc33074ea00a1c0afaf879e.tgz
new file mode 100644
index 0000000..daeb4c7
Binary files /dev/null and b/vendor/kernel-sdk-768cea122220150aacc33074ea00a1c0afaf879e.tgz differ