ccp-core 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +286 -2
- package/dist/adapters/express.d.ts +16 -0
- package/dist/adapters/express.js +49 -0
- package/dist/adapters/mcp.d.ts +57 -0
- package/dist/adapters/mcp.js +145 -0
- package/dist/adapters/next.d.ts +24 -0
- package/dist/adapters/next.js +65 -0
- package/dist/adapters/react.d.ts +25 -0
- package/dist/adapters/react.js +70 -0
- package/dist/adapters/status.d.ts +6 -0
- package/dist/adapters/status.js +18 -0
- package/dist/agent.d.ts +22 -0
- package/dist/agent.js +61 -0
- package/dist/consumer.d.ts +34 -0
- package/dist/consumer.js +78 -0
- package/dist/crypto.d.ts +10 -0
- package/dist/crypto.js +50 -0
- package/dist/decision.d.ts +17 -0
- package/dist/decision.js +67 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +30 -0
- package/dist/grants.d.ts +51 -0
- package/dist/grants.js +233 -0
- package/dist/ids.d.ts +2 -0
- package/dist/ids.js +7 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/json.d.ts +7 -0
- package/dist/json.js +96 -0
- package/dist/node/sqlite.d.ts +87 -0
- package/dist/node/sqlite.js +351 -0
- package/dist/parse.d.ts +8 -0
- package/dist/parse.js +194 -0
- package/dist/requester.d.ts +48 -0
- package/dist/requester.js +46 -0
- package/dist/runtime.d.ts +69 -0
- package/dist/runtime.js +309 -0
- package/dist/schema.d.ts +6 -0
- package/dist/schema.js +157 -0
- package/dist/signing.d.ts +7 -0
- package/dist/signing.js +21 -0
- package/dist/state-machine.d.ts +2 -0
- package/dist/state-machine.js +21 -0
- package/dist/stores.d.ts +153 -0
- package/dist/stores.js +135 -0
- package/dist/time.d.ts +2 -0
- package/dist/time.js +4 -0
- package/package.json +73 -4
package/README.md
CHANGED
|
@@ -1,3 +1,287 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ccp-core — CCP TypeScript SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The TypeScript implementation of the Capability Contacts Protocol (draft
|
|
4
|
+
v0.4), shaped by role: **Requester**, **Consumer**, **Runtime**, **Agent**.
|
|
5
|
+
It reproduces all 51 frozen vectors in `../../test-vectors/`.
|
|
6
|
+
|
|
7
|
+
Runs in browsers and Node (no Node-only APIs in `src/`).
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install ccp-core
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Requester — sign a manifest and a signal
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { Requester, publicFromSeed } from "ccp-core";
|
|
19
|
+
|
|
20
|
+
const seed = crypto.getRandomValues(new Uint8Array(32));
|
|
21
|
+
const requester = new Requester(seed);
|
|
22
|
+
|
|
23
|
+
const manifest = requester.manifest({
|
|
24
|
+
provider: { id: requester.keyId, name: "Example Service" },
|
|
25
|
+
version: 1,
|
|
26
|
+
intents: {
|
|
27
|
+
"example.lookup": {
|
|
28
|
+
params_schema: "schema:example.lookup.params.v1",
|
|
29
|
+
result_schema: "schema:example.lookup.result.v1",
|
|
30
|
+
required_capabilities: ["store.read"],
|
|
31
|
+
placement: "local",
|
|
32
|
+
data_egress: { network: false, fields: [] },
|
|
33
|
+
confirmation: "grant-policy",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const signal = requester.signal({
|
|
39
|
+
requestId: "req-1",
|
|
40
|
+
grantDigest: "sha256:…", // the grant's manifest_digest
|
|
41
|
+
intent: "example.lookup",
|
|
42
|
+
params: { item_id: "item-123" },
|
|
43
|
+
issuedAt: "2026-10-22T12:00:00Z",
|
|
44
|
+
expiresAt: "2026-10-22T12:01:00Z",
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Consumer — verify and render a permission card
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { verifyManifest, verifySignal, permissionCard } from "ccp-core";
|
|
52
|
+
|
|
53
|
+
const verified = verifyManifest(manifest); // throws CcpError on failure
|
|
54
|
+
const card = permissionCard(verified.manifest, "example.lookup", {
|
|
55
|
+
providerName: "Example Service",
|
|
56
|
+
});
|
|
57
|
+
// card.who / card.what / card.dataRead / card.where / card.egress /
|
|
58
|
+
// card.effects / card.recurrence / card.revoke
|
|
59
|
+
|
|
60
|
+
const inbox = verifySignal(signal, senderPublicKey);
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Runtime — enforce a grant
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import {
|
|
67
|
+
HostRegistry,
|
|
68
|
+
MemoryAuditLog,
|
|
69
|
+
MemoryGrantStore,
|
|
70
|
+
MemoryManifestStore,
|
|
71
|
+
Runtime,
|
|
72
|
+
SchemaRegistry,
|
|
73
|
+
createGrant,
|
|
74
|
+
verifyManifest,
|
|
75
|
+
} from "ccp-core";
|
|
76
|
+
|
|
77
|
+
const manifest = verifyManifest(manifestWire);
|
|
78
|
+
|
|
79
|
+
const grant = createGrant({
|
|
80
|
+
requesterKey: requester.keyId,
|
|
81
|
+
contactId: "c-1",
|
|
82
|
+
manifest,
|
|
83
|
+
intents: ["example.lookup"],
|
|
84
|
+
// Defaults to the manifest's suggested_limits. Values above the
|
|
85
|
+
// suggestion are rejected (§9.2).
|
|
86
|
+
limits: { calls_per_hour: 5 },
|
|
87
|
+
// Defaults to the manifest policy. Confirmation can be tightened but
|
|
88
|
+
// never relaxed to "none".
|
|
89
|
+
expiresAt: "2026-10-22T23:00:00Z",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const hosts = new HostRegistry();
|
|
93
|
+
hosts.registerHost("store.read", (id: string) => ({ item_id: id }));
|
|
94
|
+
hosts.registerIntent("example.lookup", (params, access) =>
|
|
95
|
+
access.call("store.read", params.item_id)
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const runtime = new Runtime({
|
|
99
|
+
grants: new MemoryGrantStore([grant]),
|
|
100
|
+
manifests: new MemoryManifestStore([manifest]),
|
|
101
|
+
schemas: new SchemaRegistry({
|
|
102
|
+
"schema:example.lookup.params.v1": {
|
|
103
|
+
type: "object",
|
|
104
|
+
properties: { item_id: { type: "string" } },
|
|
105
|
+
required: ["item_id"],
|
|
106
|
+
additionalProperties: false,
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
hosts,
|
|
110
|
+
audit: new MemoryAuditLog(),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const decision = await runtime.submit(signal, requesterPublicKey);
|
|
114
|
+
if (decision.outcome === "confirmation_required") {
|
|
115
|
+
await runtime.confirm(decision.pending_id!, true); // trusted UI decision
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`decision.outcome` is `authorized`, `confirmation_required`, or a §10.4
|
|
120
|
+
rejection code. `createGrant` only narrows what the manifest declares:
|
|
121
|
+
limits never exceed `suggested_limits`, confirmation is never relaxed,
|
|
122
|
+
capabilities come from the enabled intents, and placement/egress are
|
|
123
|
+
derived from them.
|
|
124
|
+
|
|
125
|
+
## Durable stores
|
|
126
|
+
|
|
127
|
+
`Runtime` accepts pluggable stores. The interfaces are `GrantStore`,
|
|
128
|
+
`ManifestStore`, `ReplayStore`, `LimitStore`, `PendingStore`, and
|
|
129
|
+
`AuditStore`; the in-memory implementations are prefixed with `Memory`.
|
|
130
|
+
Authorization-state methods may be synchronous (the defaults, or a
|
|
131
|
+
synchronous SQLite driver) or asynchronous (Postgres, a remote store);
|
|
132
|
+
the runtime awaits them. `ManifestStore` stays synchronous because
|
|
133
|
+
verified manifests are immutable and can be loaded at startup.
|
|
134
|
+
|
|
135
|
+
Three methods carry atomicity requirements:
|
|
136
|
+
|
|
137
|
+
- `LimitStore.tryReserve` reserves one unit against every limit or none,
|
|
138
|
+
in one transaction.
|
|
139
|
+
- `ReplayStore.markIfNew` inserts atomically and returns `false` when the
|
|
140
|
+
request id already exists.
|
|
141
|
+
- `PendingStore.take` removes and returns a confirmation atomically, so it
|
|
142
|
+
can be resolved at most once across processes.
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
const runtime = new Runtime({
|
|
146
|
+
grants: postgresGrants,
|
|
147
|
+
manifests: new MemoryManifestStore([manifest]), // immutable; load at startup
|
|
148
|
+
replay: postgresReplay,
|
|
149
|
+
limits: postgresLimits,
|
|
150
|
+
pending: postgresPending,
|
|
151
|
+
audit: postgresAudit,
|
|
152
|
+
schemas,
|
|
153
|
+
hosts,
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
See `docs/guides/persistence.md` for the SQL shape and the full contract.
|
|
158
|
+
|
|
159
|
+
### Node (SQLite)
|
|
160
|
+
|
|
161
|
+
The package ships a durable implementation on the built-in `node:sqlite`
|
|
162
|
+
module, with no extra dependency. It is Node-only and never imported by the
|
|
163
|
+
browser-safe core:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
import { Runtime } from "ccp-core";
|
|
167
|
+
import { SqliteStores } from "ccp-core/node";
|
|
168
|
+
|
|
169
|
+
const stores = new SqliteStores("ccp.db"); // or ":memory:"
|
|
170
|
+
const runtime = new Runtime({ ...stores, schemas, hosts });
|
|
171
|
+
// ...
|
|
172
|
+
stores.close();
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`node:sqlite` requires Node 22.5+ (available without a flag in Node 24, where
|
|
176
|
+
it is still marked experimental).
|
|
177
|
+
|
|
178
|
+
## Agent — wrap a tool call
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
import { Agent, attenuates } from "ccp-core";
|
|
182
|
+
|
|
183
|
+
const agent = new Agent(agentSeed);
|
|
184
|
+
const wire = agent.signalFor(
|
|
185
|
+
{ intent: "example.lookup", params: { item_id: "item-123" } },
|
|
186
|
+
grant,
|
|
187
|
+
{ requestId: "req-2", issuedAt: "…", expiresAt: "…" }
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// Delegation must be no broader than the parent grant (§15.1).
|
|
191
|
+
attenuates(parentGrant, childGrant);
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Adapters — meet each framework in place
|
|
195
|
+
|
|
196
|
+
Adapters are subpath exports, so the core bundle stays free of Express and
|
|
197
|
+
React. They never decide authorization themselves; they parse the request,
|
|
198
|
+
resolve the sender key, and call the runtime.
|
|
199
|
+
|
|
200
|
+
### Express provider
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
import express from "express";
|
|
204
|
+
import { providerMiddleware } from "ccp-core/express";
|
|
205
|
+
|
|
206
|
+
const app = express();
|
|
207
|
+
app.use(express.json());
|
|
208
|
+
app.post(
|
|
209
|
+
"/ccp",
|
|
210
|
+
providerMiddleware({
|
|
211
|
+
runtime,
|
|
212
|
+
// Resolve from your authenticated channel, not from the signal.
|
|
213
|
+
keyResolver: (req) => publicKeys.get(req.header("x-contact")),
|
|
214
|
+
})
|
|
215
|
+
);
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The middleware reads `{ "signal": "<canonical json>" }` and forwards the
|
|
219
|
+
string untouched. It answers 200 for `authorized`/`confirmation_required`,
|
|
220
|
+
400 for `invalid_request`, 401 when the sender is unknown, 403 for
|
|
221
|
+
`unauthenticated`/`no_matching_grant`, and 409 for other refusals.
|
|
222
|
+
|
|
223
|
+
### Next.js / Web routes
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
import { createManifestHandler, createSignalHandler } from "ccp-core/next";
|
|
227
|
+
|
|
228
|
+
export const GET = createManifestHandler({ manifest: manifestWire });
|
|
229
|
+
export const POST = createSignalHandler({ runtime, keyResolver });
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
The signal handler takes the raw canonical JSON as the body and returns a
|
|
233
|
+
standard `Response`; it has no Next.js dependency and also works in any
|
|
234
|
+
`Request`/`Response` runtime. The manifest handler serves the signed wire
|
|
235
|
+
verbatim.
|
|
236
|
+
|
|
237
|
+
### React permission card
|
|
238
|
+
|
|
239
|
+
```tsx
|
|
240
|
+
import { usePermissionCard } from "ccp-core/react";
|
|
241
|
+
|
|
242
|
+
const { status, card, decision, approve, deny } = usePermissionCard({
|
|
243
|
+
runtime,
|
|
244
|
+
signal,
|
|
245
|
+
publicKey,
|
|
246
|
+
card: permissionCardModel,
|
|
247
|
+
});
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Headless: render `card` however you like. `status` is `loading`,
|
|
251
|
+
`authorized`, `confirmation_required`, `rejected`, or `cancelled`; calling
|
|
252
|
+
`approve()` / `deny()` resolves a pending confirmation. The hook submits once
|
|
253
|
+
per signal.
|
|
254
|
+
|
|
255
|
+
### MCP agent bridge
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
import { createMcpServer } from "ccp-core/mcp";
|
|
259
|
+
|
|
260
|
+
const bridge = createMcpServer({
|
|
261
|
+
runtime,
|
|
262
|
+
agent, // Agent key the grant is bound to
|
|
263
|
+
grant,
|
|
264
|
+
// Trusted host decision. Omit to surface confirmation_required instead.
|
|
265
|
+
confirm: async ({ params, card }) => askUser(card, params),
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
registerToolsOnYourMcpServer(bridge.tools);
|
|
269
|
+
const result = await bridge.callTool("example.lookup", {
|
|
270
|
+
item_id: "item-123",
|
|
271
|
+
});
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
One bridge serves one grant; tool names are the granted intent names. The
|
|
275
|
+
adapter signs every call and lets the runtime decide. It never auto-approves:
|
|
276
|
+
without `confirm`, a `confirmation_required` outcome is returned with the
|
|
277
|
+
pending id and the §11.4 card. Tool results are data, not instructions.
|
|
278
|
+
|
|
279
|
+
Runnable examples: `examples/provider-node/` (HTTP provider) and
|
|
280
|
+
`examples/agent-mcp/` (MCP; `npm run mcp:demo`).
|
|
281
|
+
|
|
282
|
+
## Tests
|
|
283
|
+
|
|
284
|
+
```sh
|
|
285
|
+
npx vitest run # 51 frozen vectors + role unit tests
|
|
286
|
+
npx tsc --noEmit # typecheck
|
|
287
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Request, RequestHandler } from "express";
|
|
2
|
+
import type { Runtime } from "../runtime.js";
|
|
3
|
+
/** Resolve the requester's Ed25519 public key, or `undefined` if unknown. */
|
|
4
|
+
export type ExpressKeyResolver = (request: Request) => Uint8Array | undefined | Promise<Uint8Array | undefined>;
|
|
5
|
+
export interface ProviderMiddlewareOptions {
|
|
6
|
+
/** Runtime that owns grants, replay protection, limits, and executors. */
|
|
7
|
+
runtime: Runtime;
|
|
8
|
+
/** Key for the sender of this request; `undefined` rejects with 401. */
|
|
9
|
+
keyResolver: ExpressKeyResolver;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Provider-side Express middleware. It reads `{ "signal": "<json>" }` (or a
|
|
13
|
+
* raw text body), resolves the sender key, and lets the runtime decide.
|
|
14
|
+
* Authorization never happens here.
|
|
15
|
+
*/
|
|
16
|
+
export declare function providerMiddleware(options: ProviderMiddlewareOptions): RequestHandler;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { httpStatusFor } from "./status.js";
|
|
2
|
+
/**
|
|
3
|
+
* Provider-side Express middleware. It reads `{ "signal": "<json>" }` (or a
|
|
4
|
+
* raw text body), resolves the sender key, and lets the runtime decide.
|
|
5
|
+
* Authorization never happens here.
|
|
6
|
+
*/
|
|
7
|
+
export function providerMiddleware(options) {
|
|
8
|
+
const { runtime, keyResolver } = options;
|
|
9
|
+
return async (request, response) => {
|
|
10
|
+
const signal = extractSignal(request.body);
|
|
11
|
+
if (signal === undefined) {
|
|
12
|
+
response
|
|
13
|
+
.status(400)
|
|
14
|
+
.json({ outcome: "invalid_request", error: "invalid_request" });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
let publicKey;
|
|
18
|
+
try {
|
|
19
|
+
publicKey = await keyResolver(request);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
publicKey = undefined;
|
|
23
|
+
}
|
|
24
|
+
if (publicKey === undefined) {
|
|
25
|
+
response
|
|
26
|
+
.status(401)
|
|
27
|
+
.json({ outcome: "unauthenticated", error: "unauthenticated" });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const decision = await runtime.submit(signal, publicKey);
|
|
32
|
+
response.status(httpStatusFor(decision)).json(decision);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
response
|
|
36
|
+
.status(500)
|
|
37
|
+
.json({ outcome: "outcome_unknown", error: "outcome_unknown" });
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function extractSignal(body) {
|
|
42
|
+
if (typeof body === "string")
|
|
43
|
+
return body;
|
|
44
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const value = body.signal;
|
|
48
|
+
return typeof value === "string" ? value : undefined;
|
|
49
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Agent } from "../agent.js";
|
|
2
|
+
import type { PermissionCard, VerifiedManifest } from "../consumer.js";
|
|
3
|
+
import type { JsonValue } from "../json.js";
|
|
4
|
+
import type { Runtime } from "../runtime.js";
|
|
5
|
+
import type { Grant } from "../stores.js";
|
|
6
|
+
export interface McpTool {
|
|
7
|
+
/** Intent name, used verbatim as the MCP tool name. */
|
|
8
|
+
name: string;
|
|
9
|
+
description: string;
|
|
10
|
+
inputSchema: Record<string, unknown>;
|
|
11
|
+
outputSchema?: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
export interface McpTextContent {
|
|
14
|
+
type: "text";
|
|
15
|
+
text: string;
|
|
16
|
+
}
|
|
17
|
+
export interface McpToolResult {
|
|
18
|
+
content: McpTextContent[];
|
|
19
|
+
structuredContent?: Record<string, unknown>;
|
|
20
|
+
isError?: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** The exact action a trusted host is asked to approve (§11.4). */
|
|
23
|
+
export interface ConfirmationRequest {
|
|
24
|
+
pendingId: string;
|
|
25
|
+
requestId: string;
|
|
26
|
+
intent: string;
|
|
27
|
+
params: Record<string, JsonValue>;
|
|
28
|
+
card: PermissionCard;
|
|
29
|
+
}
|
|
30
|
+
export interface McpServerOptions {
|
|
31
|
+
/** Runtime that owns grants, limits, replay protection, and executors. */
|
|
32
|
+
runtime: Runtime;
|
|
33
|
+
/** Agent key that signs each tool call and that the grant is bound to. */
|
|
34
|
+
agent: Agent;
|
|
35
|
+
/** The agent's whole authority. Create one bridge per grant. */
|
|
36
|
+
grant: Grant;
|
|
37
|
+
/** Defaults to the runtime's verified manifest for this grant. */
|
|
38
|
+
manifest?: VerifiedManifest;
|
|
39
|
+
/**
|
|
40
|
+
* Trusted host decision. Omit to surface `confirmation_required` to the
|
|
41
|
+
* caller instead. The adapter never approves on its own.
|
|
42
|
+
*/
|
|
43
|
+
confirm?: (request: ConfirmationRequest) => boolean | Promise<boolean>;
|
|
44
|
+
/** Lifetime of each signed signal, in seconds. Default 60. */
|
|
45
|
+
requestTtlSeconds?: number;
|
|
46
|
+
/** Defaults to `runtime.clock`. */
|
|
47
|
+
now?: () => string;
|
|
48
|
+
}
|
|
49
|
+
export interface McpServer {
|
|
50
|
+
readonly tools: readonly McpTool[];
|
|
51
|
+
callTool(name: string, args: unknown): Promise<McpToolResult>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Transport-neutral MCP bridge. Expose `tools` through an MCP host and route
|
|
55
|
+
* `tools/call` to `callTool`; the runtime makes every authorization decision.
|
|
56
|
+
*/
|
|
57
|
+
export declare function createMcpServer(options: McpServerOptions): McpServer;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { permissionCard } from "../consumer.js";
|
|
2
|
+
import { confirmationPolicy } from "../decision.js";
|
|
3
|
+
import { AUTHORIZED, CONFIRMATION_REQUIRED, ERROR_CODES } from "../errors.js";
|
|
4
|
+
import { randomId } from "../ids.js";
|
|
5
|
+
/**
|
|
6
|
+
* Transport-neutral MCP bridge. Expose `tools` through an MCP host and route
|
|
7
|
+
* `tools/call` to `callTool`; the runtime makes every authorization decision.
|
|
8
|
+
*/
|
|
9
|
+
export function createMcpServer(options) {
|
|
10
|
+
const { runtime, agent, grant } = options;
|
|
11
|
+
const manifest = options.manifest ?? runtime.manifests.get(grant.manifest_digest);
|
|
12
|
+
if (manifest === undefined) {
|
|
13
|
+
throw new Error(`manifest not found for grant ${grant.id}`);
|
|
14
|
+
}
|
|
15
|
+
const verified = manifest;
|
|
16
|
+
if (grant.requester_key !== agent.keyId) {
|
|
17
|
+
throw new Error(`grant ${grant.id} is not bound to this agent key`);
|
|
18
|
+
}
|
|
19
|
+
const now = options.now ?? runtime.clock;
|
|
20
|
+
const ttl = options.requestTtlSeconds ?? 60;
|
|
21
|
+
const tools = [];
|
|
22
|
+
for (const intent of grant.intents) {
|
|
23
|
+
const definition = verified.manifest.intents?.[intent];
|
|
24
|
+
if (definition === undefined) {
|
|
25
|
+
throw new Error(`intent ${intent} is not declared in the manifest`);
|
|
26
|
+
}
|
|
27
|
+
const inputSchema = runtime.schemas.get(definition.params_schema);
|
|
28
|
+
if (inputSchema === undefined) {
|
|
29
|
+
throw new Error(`params schema not registered: ${definition.params_schema}`);
|
|
30
|
+
}
|
|
31
|
+
const outputSchema = runtime.schemas.get(definition.result_schema);
|
|
32
|
+
tools.push({
|
|
33
|
+
name: intent,
|
|
34
|
+
description: describe(intent, definition, grant),
|
|
35
|
+
inputSchema,
|
|
36
|
+
...(outputSchema === undefined ? {} : { outputSchema }),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
const byName = new Map(tools.map((tool) => [tool.name, tool]));
|
|
40
|
+
async function settle(decision, tool, intent, params) {
|
|
41
|
+
if (decision.outcome === AUTHORIZED)
|
|
42
|
+
return success(tool, decision.result);
|
|
43
|
+
if (decision.outcome !== CONFIRMATION_REQUIRED) {
|
|
44
|
+
return failure(decision.outcome, {
|
|
45
|
+
request_id: decision.request_id,
|
|
46
|
+
detail: decision.detail,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const pendingId = decision.pending_id;
|
|
50
|
+
if (pendingId === undefined)
|
|
51
|
+
return failure("invalid_request");
|
|
52
|
+
if (options.confirm === undefined) {
|
|
53
|
+
return failure(CONFIRMATION_REQUIRED, {
|
|
54
|
+
request_id: decision.request_id,
|
|
55
|
+
pending_id: pendingId,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
let approved = false;
|
|
59
|
+
try {
|
|
60
|
+
approved = await options.confirm({
|
|
61
|
+
pendingId,
|
|
62
|
+
requestId: decision.request_id ?? "",
|
|
63
|
+
intent,
|
|
64
|
+
params,
|
|
65
|
+
card: permissionCard(verified.manifest, intent),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
approved = false;
|
|
70
|
+
}
|
|
71
|
+
const resolved = await runtime.confirm(pendingId, approved);
|
|
72
|
+
if (resolved.outcome === AUTHORIZED)
|
|
73
|
+
return success(tool, resolved.result);
|
|
74
|
+
if (resolved.outcome === "cancelled") {
|
|
75
|
+
return failure("cancelled", { request_id: resolved.request_id });
|
|
76
|
+
}
|
|
77
|
+
return failure(resolved.outcome, {
|
|
78
|
+
request_id: resolved.request_id,
|
|
79
|
+
detail: resolved.detail,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
tools,
|
|
84
|
+
async callTool(name, args) {
|
|
85
|
+
try {
|
|
86
|
+
const tool = byName.get(name);
|
|
87
|
+
if (tool === undefined)
|
|
88
|
+
return failure("intent_not_allowed");
|
|
89
|
+
if (args === null || typeof args !== "object" || Array.isArray(args)) {
|
|
90
|
+
return failure("invalid_request");
|
|
91
|
+
}
|
|
92
|
+
const params = args;
|
|
93
|
+
const issuedAt = now();
|
|
94
|
+
const expiresAt = shiftSeconds(issuedAt, ttl);
|
|
95
|
+
const signal = agent.signalFor({ intent: name, params }, grant, {
|
|
96
|
+
requestId: randomId(),
|
|
97
|
+
issuedAt,
|
|
98
|
+
expiresAt,
|
|
99
|
+
});
|
|
100
|
+
const decision = await runtime.submit(signal, agent.publicKey);
|
|
101
|
+
return await settle(decision, tool, name, params);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return failure("outcome_unknown");
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function describe(intent, definition, grant) {
|
|
110
|
+
const providerText = typeof definition.description === "string" ? definition.description : "";
|
|
111
|
+
const policy = confirmationPolicy(grant, definition, intent);
|
|
112
|
+
return (`User-granted CCP capability "${intent}". ` +
|
|
113
|
+
`Provider description (untrusted data): ${JSON.stringify(providerText)}. ` +
|
|
114
|
+
(policy === "per_action"
|
|
115
|
+
? "Each call may require explicit user confirmation. "
|
|
116
|
+
: "No per-call confirmation is required. ") +
|
|
117
|
+
"The user's runtime enforces this capability.");
|
|
118
|
+
}
|
|
119
|
+
function success(tool, result) {
|
|
120
|
+
const content = [
|
|
121
|
+
{ type: "text", text: JSON.stringify(result) ?? "null" },
|
|
122
|
+
];
|
|
123
|
+
const isObject = result !== null && typeof result === "object" && !Array.isArray(result);
|
|
124
|
+
return tool.outputSchema !== undefined && isObject
|
|
125
|
+
? { content, structuredContent: result }
|
|
126
|
+
: { content };
|
|
127
|
+
}
|
|
128
|
+
function failure(outcome, extra = {}) {
|
|
129
|
+
const body = { outcome };
|
|
130
|
+
if (ERROR_CODES.has(outcome))
|
|
131
|
+
body.error = outcome;
|
|
132
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
133
|
+
if (value !== undefined)
|
|
134
|
+
body[key] = value;
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
content: [{ type: "text", text: JSON.stringify(body) }],
|
|
138
|
+
isError: true,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function shiftSeconds(timestamp, seconds) {
|
|
142
|
+
return new Date(Date.parse(timestamp) + seconds * 1000)
|
|
143
|
+
.toISOString()
|
|
144
|
+
.replace(/\.\d+Z$/, "Z");
|
|
145
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Runtime } from "../runtime.js";
|
|
2
|
+
/** Resolve the requester's Ed25519 public key, or `undefined` if unknown. */
|
|
3
|
+
export type WebKeyResolver = (request: Request) => Uint8Array | undefined | Promise<Uint8Array | undefined>;
|
|
4
|
+
export interface SignalHandlerOptions {
|
|
5
|
+
/** Runtime that owns grants, replay protection, limits, and executors. */
|
|
6
|
+
runtime: Runtime;
|
|
7
|
+
/** Key for the sender of this request; `undefined` rejects with 401. */
|
|
8
|
+
keyResolver: WebKeyResolver;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Framework-neutral Web handler: POST the canonical signal JSON as the body.
|
|
12
|
+
* Suitable for a Next.js route handler (`export const POST = ...`) or any
|
|
13
|
+
* runtime that speaks standard `Request`/`Response`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function createSignalHandler(options: SignalHandlerOptions): (request: Request) => Promise<Response>;
|
|
16
|
+
export interface ManifestHandlerOptions {
|
|
17
|
+
/** Signed manifest wire, exactly as produced by `signManifest`. */
|
|
18
|
+
manifest: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Serve the signed manifest verbatim. Consumers verify it themselves; the
|
|
22
|
+
* adapter does not re-serialize it.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createManifestHandler(options: ManifestHandlerOptions): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { httpStatusFor } from "./status.js";
|
|
2
|
+
/**
|
|
3
|
+
* Framework-neutral Web handler: POST the canonical signal JSON as the body.
|
|
4
|
+
* Suitable for a Next.js route handler (`export const POST = ...`) or any
|
|
5
|
+
* runtime that speaks standard `Request`/`Response`.
|
|
6
|
+
*/
|
|
7
|
+
export function createSignalHandler(options) {
|
|
8
|
+
const { runtime, keyResolver } = options;
|
|
9
|
+
return async (request) => {
|
|
10
|
+
if (request.method !== "POST") {
|
|
11
|
+
return json(405, { outcome: "invalid_request", error: "invalid_request" });
|
|
12
|
+
}
|
|
13
|
+
let document;
|
|
14
|
+
try {
|
|
15
|
+
document = await request.text();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return json(400, {
|
|
19
|
+
outcome: "invalid_request",
|
|
20
|
+
error: "invalid_request",
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
if (document.trim() === "") {
|
|
24
|
+
return json(400, { outcome: "invalid_request", error: "invalid_request" });
|
|
25
|
+
}
|
|
26
|
+
let publicKey;
|
|
27
|
+
try {
|
|
28
|
+
publicKey = await keyResolver(request);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
publicKey = undefined;
|
|
32
|
+
}
|
|
33
|
+
if (publicKey === undefined) {
|
|
34
|
+
return json(401, { outcome: "unauthenticated", error: "unauthenticated" });
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const decision = await runtime.submit(document, publicKey);
|
|
38
|
+
return json(httpStatusFor(decision), decision);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return json(500, { outcome: "outcome_unknown", error: "outcome_unknown" });
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Serve the signed manifest verbatim. Consumers verify it themselves; the
|
|
47
|
+
* adapter does not re-serialize it.
|
|
48
|
+
*/
|
|
49
|
+
export function createManifestHandler(options) {
|
|
50
|
+
return async (request) => {
|
|
51
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
52
|
+
return json(405, { outcome: "invalid_request", error: "invalid_request" });
|
|
53
|
+
}
|
|
54
|
+
return new Response(request.method === "HEAD" ? null : options.manifest, {
|
|
55
|
+
status: 200,
|
|
56
|
+
headers: { "content-type": "application/json" },
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function json(status, body) {
|
|
61
|
+
return new Response(JSON.stringify(body), {
|
|
62
|
+
status,
|
|
63
|
+
headers: { "content-type": "application/json" },
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Decision, Runtime } from "../runtime.js";
|
|
2
|
+
export type PermissionStatus = "loading" | "authorized" | "confirmation_required" | "rejected" | "cancelled";
|
|
3
|
+
export interface PermissionCardOptions<T = unknown> {
|
|
4
|
+
/** Runtime that owns grants, replay protection, limits, and executors. */
|
|
5
|
+
runtime: Runtime;
|
|
6
|
+
/** Canonical signed signal to submit. */
|
|
7
|
+
signal: string;
|
|
8
|
+
/** Requester public key that the runtime will verify the signal against. */
|
|
9
|
+
publicKey: Uint8Array;
|
|
10
|
+
/** Anything the caller wants to render; handed back untouched. */
|
|
11
|
+
card?: T;
|
|
12
|
+
}
|
|
13
|
+
export interface PermissionCardResult<T = unknown> {
|
|
14
|
+
status: PermissionStatus;
|
|
15
|
+
card: T | undefined;
|
|
16
|
+
decision: Decision | undefined;
|
|
17
|
+
approve: () => void;
|
|
18
|
+
deny: () => void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Headless permission card for the consuming side: submit a signed signal,
|
|
22
|
+
* surface whether it needs confirmation, and let the user approve or deny.
|
|
23
|
+
* Presentation is entirely the caller's.
|
|
24
|
+
*/
|
|
25
|
+
export declare function usePermissionCard<T = unknown>(options: PermissionCardOptions<T>): PermissionCardResult<T>;
|