Skip to content
Open
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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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.

Expand All @@ -316,6 +316,12 @@ 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, list, get, and delete project-owned payment vaults.
- `manage_vault_wallets` - Connect Link or AgentCard wallets and inspect live payment methods.
- `manage_vault_cards` - Create card requests or replace their full specification; does not implicitly authorize Link cards.
- `manage_vault_items` - List, get, invoke advertised operations, observe events, and delete vault items. Provider approvals remain user actions; ready does not mean paid.

See [Vault payments](docs/vault-payments.md) for both provider flows, safety rules, and response shapes. `manage_browsers` accepts creation-only `vaults` references (max 20); existing sessions and pools cannot gain vault bindings. The four vault tools share the `vaults` toolset and prepare/observe credentials rather than submitting merchant payments. They are exposed only when `GET /org/entitlements` reports `features.vaults.enabled: true` for the current credential; missing or unavailable entitlements hide them. Toolset configuration cannot override this access check.

### Standalone tools

Expand Down
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

220 changes: 220 additions & 0 deletions docs/vault-payments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
# Vault payments

The vault tools prepare and observe payment credentials. They do **not** submit
merchant payments, expose real card values, or complete provider approval actions.
They use the same vault API as the Kernel CLI.

**These are live payment cards. Test-mode creation is unsupported.** Do not assume
that a development or staging MCP endpoint makes a card request a test transaction.

## Tools and scope

The four vault tools are exposed only when the current credential's
`GET /org/entitlements` response reports `features.vaults.enabled: true`.
Access is rechecked on every authenticated MCP request, including tool calls,
without caching grants across requests or connections. A missing field, malformed
response, or failed lookup hides the vault tools but leaves other toolsets usable.
The lookup has a five-second timeout, forwards cancellation, and is not retried.
The `vaults` toolset configuration can further restrict access, never grant it.

| Tool | Actions |
| ---------------------- | ------------------------------------------- |
| `manage_vaults` | `create`, `list`, `get`, `delete` |
| `manage_vault_wallets` | `create`, `payment_methods` |
| `manage_vault_cards` | `create`, `update` |
| `manage_vault_items` | `list`, `get`, `invoke`, `events`, `delete` |

Every tool accepts an optional `project` name or ID. Vaults are project-owned;
omitting `project` uses the API's effective default project, **not** all projects.
Project-scoped connections cannot switch projects. Use `get_connection_context`
to inspect the connection's scope.

`vault` accepts an ID or immutable name. `key` is an immutable item key within that
vault, not the item ID. Vault names, item keys, and project ownership cannot be renamed.

Wallet/card writes take a `provider` (`link` or `agentcard`) and a JSON `spec`
**object**, not a string or a `{type, spec}` envelope. The tool injects `provider`;
if present in `spec`, it must match. Tool schemas describe the provider-specific
fields and reject unknown fields, including nested ones. No defaults or currency
normalization are applied. Amounts are integer minor currency units. All integer
inputs, including `expires_at`, must fit JavaScript's safe integer range; unsafe
numbers are rejected, not silently rounded. The API enforces provider/state rules.

These capabilities use the existing MCP authentication and deployment. To expose
only payment tools on a self-hosted server, set:

```sh
KERNEL_MCP_ENABLED_TOOLSETS=vaults
```

For browser checkout automation too, use `vaults browsers playwright computer`.
To hide the payment tools, set `KERNEL_MCP_DISABLED_TOOLSETS=vaults`.
This filters discovery; API authorization still enforces resource access.

## Link flow

1. Create or retrieve a vault with `manage_vaults`:

```json
{ "action": "create", "name": "checkout" }
```

2. Connect a wallet with `manage_vault_wallets`:

```json
{
"action": "create",
"vault": "checkout",
"key": "wallet-1",
"provider": "link",
"spec": {
"authorization": {
"method": "oauth",
"client": { "type": "kernel_managed" }
}
}
}
```

Give the returned `item.action.url` to the user to complete with the provider.
Do not ask for card details or OAuth codes/tokens in chat. Observe the wallet
with `manage_vault_items`, `action: "get"`, the same vault/key, and `wait: 30`.

3. Once connected, call `manage_vault_wallets` with `action: "payment_methods"`
and the same vault/key. Explicitly select a returned method ID with the user;
do not automatically choose the default. Capabilities are advisory: absent
means unknown, not ineligible.

4. Create the purchase request with `manage_vault_cards`, replacing
`pm_selected` with the selected returned ID:

```json
{
"action": "create",
"vault": "checkout",
"key": "order-1",
"provider": "link",
"spec": {
"wallet": "wallet-1",
"payment_method_id": "pm_selected",
"amount": 1234,
"currency": "usd",
"merchant_name": "Example Shop",
"merchant_url": "https://shop.example",
"context": "Purchase the selected office supplies from Example Shop for the approved order, with a total spending limit of 1234 minor currency units."
}
}
```

Link also supports `line_items`, `totals`, `metadata`, and `expires_at`.
Creating or updating the card does **not** implicitly authorize it.

5. Read `available_operations` with `manage_vault_items`, `action: "get"`.
Read the operation description and obtain explicit user approval before
invoking an advertised operation:

```json
{
"action": "invoke",
"vault": "checkout",
"key": "order-1",
"operation": "authorize"
}
```

The tool fetches the item again and submits only a currently advertised
operation. The current API accepts only `{"type":"authorize"}`; there are no
operation parameters. New parameterless operation names can be forwarded when
the API advertises them. Follow any returned provider action and observe state.
OAuth, enrollment, MFA, and approval actions are for the user, not operation names.

6. When ready, create a new browser with `manage_browsers`:

```json
{
"action": "create",
"vaults": [{ "name": "checkout" }]
}
```

Use only returned `item.state.aliases` through the browser tools in **that
browser**, respecting returned permitted domains. Merchant checkout submission
is a separate browser action and requires the user's authorization.

## AgentCard flow

Use a separate vault or different immutable item keys. Create the vault as above,
then connect a wallet with `manage_vault_wallets`:

```json
{
"action": "create",
"vault": "checkout",
"key": "agentcard-wallet",
"provider": "agentcard",
"spec": {}
}
```

Complete the returned enrollment action. Alternatively, `spec.user_id` may refer
to a user already enrolled in this organization. Once connected, configure a card
with `manage_vault_cards`:

```json
{
"action": "create",
"vault": "checkout",
"key": "agentcard-order",
"provider": "agentcard",
"spec": {
"wallet": "agentcard-wallet",
"merchant": "Example Shop",
"amount": 1234,
"currency": "usd"
}
}
```

AgentCard uses `merchant`, not Link's `merchant_name`. Optionally inspect wallet
payment methods and provide a returned `card_id`; otherwise the cardholder selects
one at approval. AgentCard currently does not advertise `authorize`: authorization
happens at checkout. Attach the vault to a new browser and use returned aliases.
Observe the card for its checkout authorization and any approval URL for the user.
A reusable card remaining `ready` does not establish that the last payment succeeded.

## Observation, updates, and safety

- Single-item responses are JSON text containing `{item, guidance}`. They preserve
public state, non-secret aliases, masks, safe action/approval URLs, advertised
operations/expansions, and payment outcomes. Unknown provider fields, opaque
event data, free-form metadata, and URLs carrying OAuth codes/tokens are omitted.
API errors retain the HTTP status but use curated messages for recognized error
codes. Unknown codes use a generic fallback; upstream error text is never returned.
There is no raw-output or raw-card tool.
- Vault lists return `{items, has_more, next_offset}`. Item lists return `{items}`.
`get` with `expand: ["payment_methods"]` is equivalent to the wallet
`payment_methods` action. An unavailable expansion returns an API error.
- Only `get` and `events` accept `wait: 0..60`; other actions reject it.
`invoke` does not wait for authorization. Each observation is bounded,
not a background polling loop or readiness guarantee. The SDK timeout is the
wait plus 30 seconds; configure the MCP client's timeout accordingly, or use
shorter waits. Request cancellation is propagated to the SDK.
- `events` accepts `after` and returns `{events, next_after, guidance}`. Pass
`next_after` on the next call for the same vault/key. An empty result preserves
the input cursor (or returns `null` without one).
- **Ready does not mean paid.** Inspect state and immutable events for outcomes.
No vault request is automatically retried. After a failed, timed-out, rejected,
or indeterminate payment, inspect state/events; do not replay checkout, invoke
again, or reconfigure a card to retry it.
- Card `update` replaces the **entire spec**; omitted optional fields are removed.
The API decides when a card can be reconfigured.
- Browser attachments accept at most 20 references, each containing exactly one
`id` or `name`. They are creation-only and unavailable for browser pools. You
cannot add vaults to an existing browser. Vault-bound browser creation also
disables automatic SDK retries.
- Provider-assigned permitted domains are not configurable through these tools.
- Vault/item deletion invalidates the affected credentials. Confirm with the user
first. Any HTTP 404 returns `deleted_or_not_found`, including a missing project;
other errors fail. Non-delete 404s remain errors.
- The existing analytics filter omits tool inputs, outputs, and error messages;
do not add payment payloads or action URLs to application logs.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "^0.100.0",
"@posthog/mcp": "0.10.1",
"@types/jsonwebtoken": "^9.0.10",
"@types/redis": "^4.0.11",
Expand Down
104 changes: 104 additions & 0 deletions src/app/[transport]/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { Kernel } from "@onkernel/sdk";
import type { McpConnectionScopeFailureAnalytics } from "@/lib/mcp/analytics";
import { defaultMcpDependencies } from "@/lib/mcp/dependencies";

Expand Down Expand Up @@ -137,6 +138,109 @@ describe("connection scope failures through the handler", () => {
});
});

describe("vault entitlement routing", () => {
function installKernelResponses(entitlements: (token: string) => Response) {
const paths: string[] = [];
defaultMcpDependencies.createKernelClient = (token) =>
new Kernel({
apiKey: token,
baseURL: "https://api.example.test",
maxRetries: 0,
fetch: async (input) => {
const path = new URL(String(input)).pathname;
paths.push(path);
if (path === "/auth/context")
return Response.json({
authentication: {
method: "api_key",
source: "api_key",
credential_id: "key_test",
},
principal: { type: "api_key", id: "key_test" },
organization: { id: token === "sk_allowed" ? "org_a" : "org_b" },
authorization: {
credential_scope: { project_id: null },
effective_scope: { project_id: null },
},
});
if (path === "/org/entitlements") return entitlements(token);
throw new Error(`Unexpected API request: ${path}`);
},
});
return paths;
}

async function call(method: string, token = "sk_allowed", params?: object) {
const response = await POST(
new nextServer.NextRequest("https://mcp.example.test/mcp", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
}),
);
expect(response.status).toBe(200);
const text = await response.text();
const event = text.split("\n").find((line) => line.startsWith("data: "));
return JSON.parse(event ? event.slice(6) : text);
}

test("selects tools per credential and rechecks access after revocation", async () => {
let enabled = true;
const paths = installKernelResponses((token) =>
Response.json({
features: { vaults: { enabled: token === "sk_allowed" && enabled } },
}),
);
const allowed = await call("tools/list");
expect(
allowed.result.tools.map((tool: { name: string }) => tool.name),
).toContain("manage_vaults");
const denied = await call("tools/list", "sk_denied");
expect(
denied.result.tools.filter((tool: { name: string }) =>
tool.name.startsWith("manage_vault"),
),
).toHaveLength(0);
expect(
denied.result.tools.map((tool: { name: string }) => tool.name),
).toContain("manage_browsers");
enabled = false;
const revoked = await call("tools/call", "sk_allowed", {
name: "manage_vaults",
arguments: { action: "list" },
});
expect(JSON.stringify(revoked)).toContain("not found");
expect(paths).toEqual([
"/auth/context",
"/org/entitlements",
"/auth/context",
"/org/entitlements",
"/auth/context",
"/org/entitlements",
]);
});

test.each([200, 404, 503])(
"keeps other tools available when entitlements are absent or fail (HTTP %s)",
async (status) => {
installKernelResponses(() => Response.json({ features: {} }, { status }));
const result = await call("tools/list");
expect(
result.result.tools.filter((tool: { name: string }) =>
tool.name.startsWith("manage_vault"),
),
).toHaveLength(0);
expect(
result.result.tools.map((tool: { name: string }) => tool.name),
).toContain("manage_browsers");
},
);
});

describe("connectionScopeFailureResponse", () => {
test("names an inactive project instead of blaming the credential", async () => {
const response = connectionScopeFailureResponse({
Expand Down
Loading
Loading