@camelai/agent-runtime 0.4.0 → 0.5.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 +27 -0
- package/dist/clients/node.d.ts +8 -0
- package/dist/clients/node.js +34 -1
- package/dist/clients/server.d.ts +77 -0
- package/dist/clients/server.js +171 -0
- package/dist/clients/testing.d.ts +59 -0
- package/dist/clients/testing.js +53 -0
- package/dist/clients/typescript.d.ts +363 -8
- package/dist/clients/typescript.js +289 -44
- package/dist/shared/client-protocol.d.ts +7 -1
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -69,9 +69,36 @@ import { fromMcpServer } from "@camelai/agent-runtime/mcp";
|
|
|
69
69
|
const agent = await runtime.createAgent({ name: "Inventory planner", mcp: await fromMcpServer(server) });
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
+
Serving tools to many users' agents from one server? `serveTools` from
|
|
73
|
+
`@camelai/agent-runtime/server` serves the same `tools` over HTTP and verifies the
|
|
74
|
+
runtime's signed identity token on every call, so each tool knows who it is for:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { serveTools } from "@camelai/agent-runtime/server";
|
|
78
|
+
|
|
79
|
+
const tools = {
|
|
80
|
+
list_todos: tool({
|
|
81
|
+
description: "The current user's to-dos", input: schema.Object({}),
|
|
82
|
+
execute: (_args, { identity }) => db.todos(identity!.user, identity!.context.team),
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
export default { fetch: serveTools(tools, { runtime: "https://agents.camelai.dev" }) };
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Name it in a definition with `mcpServers: [{ name: "todos", url, auth: { type: "runtime" } }]`,
|
|
89
|
+
create agents with a `subject` and `context`, and prompt with `from` or `actor`.
|
|
90
|
+
`testRuntime()` from `@camelai/agent-runtime/testing` signs tokens for tests.
|
|
91
|
+
|
|
72
92
|
Switch models between turns with `await agent.configure({ model: "openai/gpt-5.2" })`;
|
|
73
93
|
the history carries over. Your tenant needs a key for that provider.
|
|
74
94
|
|
|
95
|
+
A turn can also wait for the user: a tool with `needsApproval: true` is approved
|
|
96
|
+
before each call, and `ctx.confirm`, `ctx.ask` and `ctx.requireUrl` ask from
|
|
97
|
+
inside a tool. `prompt()` then resolves with `stopped: "input_required"` and the
|
|
98
|
+
`inputs`; answer them with `onInput`, or later with `agent.answer(inputId, { action: "accept" })`,
|
|
99
|
+
and the turn resumes. An ask ends the call, and the tool runs again with the
|
|
100
|
+
answer, so everything before an ask runs twice: ask first, act after.
|
|
101
|
+
|
|
75
102
|
## Console and REST API
|
|
76
103
|
|
|
77
104
|
Sign in at https://agents.camelai.dev/console with GitHub (qaml-ai members) to add
|
package/dist/clients/node.d.ts
CHANGED
|
@@ -3,6 +3,14 @@ export * from "./typescript.ts";
|
|
|
3
3
|
export interface RuntimeOptions extends PortableRuntimeOptions {
|
|
4
4
|
stateDirectory?: string;
|
|
5
5
|
}
|
|
6
|
+
/**
|
|
7
|
+
* A fetch-style handler (e.g. `serveTools(...)`) as a `node:http` request listener:
|
|
8
|
+
* `createServer(nodeListener(serveTools(tools, { runtime })))`. `origin` is your public URL when the
|
|
9
|
+
* server is behind a proxy (tokens are checked against it); by default the request's Host.
|
|
10
|
+
*/
|
|
11
|
+
export declare function nodeListener(handler: (request: Request) => Promise<Response>, options?: {
|
|
12
|
+
origin?: string;
|
|
13
|
+
}): (req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse) => Promise<void>;
|
|
6
14
|
export declare function fileJournalStore(directory: string): JournalStore;
|
|
7
15
|
export declare class AgentRuntime extends PortableAgentRuntime {
|
|
8
16
|
constructor(options?: RuntimeOptions);
|
package/dist/clients/node.js
CHANGED
|
@@ -1,9 +1,42 @@
|
|
|
1
1
|
/** Node/Bun convenience entry. The portable SDK itself imports no Node modules. */
|
|
2
|
+
import { openAsBlob } from "node:fs";
|
|
2
3
|
import { readFile } from "node:fs/promises";
|
|
3
4
|
import { join, resolve } from "node:path";
|
|
4
5
|
import { writeDurableJson } from "../shared/durable-json.js";
|
|
5
6
|
import { AgentRuntime as PortableAgentRuntime } from "./typescript.js";
|
|
6
7
|
export * from "./typescript.js";
|
|
8
|
+
/**
|
|
9
|
+
* A fetch-style handler (e.g. `serveTools(...)`) as a `node:http` request listener:
|
|
10
|
+
* `createServer(nodeListener(serveTools(tools, { runtime })))`. `origin` is your public URL when the
|
|
11
|
+
* server is behind a proxy (tokens are checked against it); by default the request's Host.
|
|
12
|
+
*/
|
|
13
|
+
export function nodeListener(handler, options = {}) {
|
|
14
|
+
return async (req, res) => {
|
|
15
|
+
try {
|
|
16
|
+
const origin = options.origin ?? `http://${req.headers.host ?? "localhost"}`;
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
res.on("close", () => { if (!res.writableFinished)
|
|
19
|
+
controller.abort(); });
|
|
20
|
+
const headers = new Headers();
|
|
21
|
+
for (const [name, value] of Object.entries(req.headers))
|
|
22
|
+
if (value !== undefined)
|
|
23
|
+
headers.set(name, Array.isArray(value) ? value.join(", ") : value);
|
|
24
|
+
const chunks = [];
|
|
25
|
+
if (req.method !== "GET" && req.method !== "HEAD")
|
|
26
|
+
for await (const chunk of req)
|
|
27
|
+
chunks.push(chunk);
|
|
28
|
+
const body = chunks.length ? new Uint8Array(Buffer.concat(chunks)) : undefined;
|
|
29
|
+
const response = await handler(new Request(new URL(req.url ?? "/", origin), { method: req.method, headers, body, signal: controller.signal }));
|
|
30
|
+
res.writeHead(response.status, Object.fromEntries(response.headers));
|
|
31
|
+
res.end(response.body ? Buffer.from(await response.arrayBuffer()) : undefined);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (!res.headersSent)
|
|
35
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
36
|
+
res.end(JSON.stringify({ error: String(error).slice(0, 500) }));
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
7
40
|
export function fileJournalStore(directory) {
|
|
8
41
|
const root = resolve(directory);
|
|
9
42
|
const path = (id) => {
|
|
@@ -27,7 +60,7 @@ export function fileJournalStore(directory) {
|
|
|
27
60
|
}
|
|
28
61
|
export class AgentRuntime extends PortableAgentRuntime {
|
|
29
62
|
constructor(options = {}) {
|
|
30
|
-
super({ ...options, url: options.url ?? process.env.AGENT_URL,
|
|
63
|
+
super({ ...options, url: options.url ?? process.env.AGENT_URL, openFile: options.openFile ?? (path => openAsBlob(path)),
|
|
31
64
|
apiKey: options.apiKey ?? process.env.AGENT_RUNTIME_TOKEN,
|
|
32
65
|
journalStore: options.journalStore ?? fileJournalStore(options.stateDirectory ?? process.env.AGENT_CLIENT_STATE_DIR ?? ".agent-runtime/client-sdk"),
|
|
33
66
|
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serving tools to agents from your own server, for many users at once. The runtime calls a tool
|
|
3
|
+
* source with `auth: { type: "runtime" }` with a token it signs for each request, naming the agent,
|
|
4
|
+
* whom it acts for and who is acting; these helpers verify it and hand your tools the identity.
|
|
5
|
+
* Portable: fetch-style handlers and WebCrypto (Ed25519), so it runs on Workers, Node 22+, Bun and Deno.
|
|
6
|
+
*
|
|
7
|
+
* export default { fetch: serveTools(tools, { runtime: "https://agents.camelai.dev" }) };
|
|
8
|
+
*/
|
|
9
|
+
import { type RuntimeIdentity, type ToolServer, type Tools } from "./typescript.ts";
|
|
10
|
+
export type { RuntimeIdentity };
|
|
11
|
+
export interface VerifyOptions {
|
|
12
|
+
/** The runtime's URL (e.g. https://agents.camelai.dev): its keys are at /.well-known/jwks.json. */
|
|
13
|
+
runtime: string;
|
|
14
|
+
/** The issuer tokens must name; the runtime's URL by default. */
|
|
15
|
+
issuer?: string;
|
|
16
|
+
/** What tokens must be for: your server's URL as the runtime calls it (a definition's `url`, or its `audience`). */
|
|
17
|
+
audience: string | string[];
|
|
18
|
+
/** Fetches the runtime's keys; `testRuntime()` supplies one. */
|
|
19
|
+
fetch?: typeof globalThis.fetch;
|
|
20
|
+
/** Seconds of clock skew allowed (default 30). */
|
|
21
|
+
clockTolerance?: number;
|
|
22
|
+
}
|
|
23
|
+
/** A token that is missing, malformed, unsigned by the runtime, for another server, or expired. */
|
|
24
|
+
export declare class RuntimeTokenError extends Error {
|
|
25
|
+
constructor(message: string);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Verify an identity token and return who the call is for. Checks the signature against the runtime's
|
|
29
|
+
* published Ed25519 keys (EdDSA only), the issuer, that the audience is yours, and the times.
|
|
30
|
+
*/
|
|
31
|
+
export declare function verifyRuntimeToken(token: string, options: VerifyOptions): Promise<RuntimeIdentity & {
|
|
32
|
+
claims: Record<string, unknown>;
|
|
33
|
+
}>;
|
|
34
|
+
/** A request's bearer token, if it has one. */
|
|
35
|
+
export declare function bearerToken(request: Request): string | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* For servers built with the MCP SDK (or Cloudflare's `createMcpHandler`): verify the request's
|
|
38
|
+
* token and return MCP's `AuthInfo`, with the identity in `extra.identity` (the token's claims in `extra.claims`). Pass it as the request's
|
|
39
|
+
* `auth` (`transport.handleRequest(Object.assign(req, { auth }), ...)`, or `createMcpHandler(server,
|
|
40
|
+
* { authContext })`), and a tool handler reads `runtimeIdentity(extra)`.
|
|
41
|
+
*/
|
|
42
|
+
export declare function runtimeAuth(request: Request, options: Omit<VerifyOptions, "audience"> & {
|
|
43
|
+
audience?: string | string[];
|
|
44
|
+
}): Promise<{
|
|
45
|
+
token: string;
|
|
46
|
+
clientId: string;
|
|
47
|
+
scopes: string[];
|
|
48
|
+
expiresAt: number;
|
|
49
|
+
extra: {
|
|
50
|
+
identity: RuntimeIdentity;
|
|
51
|
+
claims: Record<string, unknown>;
|
|
52
|
+
};
|
|
53
|
+
}>;
|
|
54
|
+
/** The identity `runtimeAuth` put in an MCP SDK tool handler's `extra`. */
|
|
55
|
+
export declare function runtimeIdentity(extra: {
|
|
56
|
+
authInfo?: {
|
|
57
|
+
extra?: Record<string, unknown>;
|
|
58
|
+
};
|
|
59
|
+
} | undefined): RuntimeIdentity | undefined;
|
|
60
|
+
export interface ServeOptions extends Omit<VerifyOptions, "audience"> {
|
|
61
|
+
/** What tokens must be for; by default the request's URL (origin and path), which is what the runtime signs for unless your definition sets `audience`. */
|
|
62
|
+
audience?: string | string[];
|
|
63
|
+
/** Serve MCP's protected-resource metadata (RFC 9728) naming the runtime as the issuer; on by default. */
|
|
64
|
+
metadata?: boolean;
|
|
65
|
+
/** The name and version `initialize` reports. */
|
|
66
|
+
serverInfo?: {
|
|
67
|
+
name: string;
|
|
68
|
+
version: string;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Serve tools (`tool({...})` definitions, or any `ToolServer`) as a stateless MCP server over
|
|
73
|
+
* Streamable HTTP, for the runtime to call with its identity tokens: a fetch handler
|
|
74
|
+
* (`(Request) => Promise<Response>`). Every call's context carries the verified `identity`;
|
|
75
|
+
* requests without a valid token get a 401. The same tools can be attached to an agent instead.
|
|
76
|
+
*/
|
|
77
|
+
export declare function serveTools(tools: Tools | ToolServer, options: ServeOptions): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serving tools to agents from your own server, for many users at once. The runtime calls a tool
|
|
3
|
+
* source with `auth: { type: "runtime" }` with a token it signs for each request, naming the agent,
|
|
4
|
+
* whom it acts for and who is acting; these helpers verify it and hand your tools the identity.
|
|
5
|
+
* Portable: fetch-style handlers and WebCrypto (Ed25519), so it runs on Workers, Node 22+, Bun and Deno.
|
|
6
|
+
*
|
|
7
|
+
* export default { fetch: serveTools(tools, { runtime: "https://agents.camelai.dev" }) };
|
|
8
|
+
*/
|
|
9
|
+
import { answerMcp, identityFromClaims, toolContext, toolServer } from "./typescript.js";
|
|
10
|
+
/** A token that is missing, malformed, unsigned by the runtime, for another server, or expired. */
|
|
11
|
+
export class RuntimeTokenError extends Error {
|
|
12
|
+
constructor(message) { super(message); this.name = "RuntimeTokenError"; }
|
|
13
|
+
}
|
|
14
|
+
const trim = (url) => url.replace(/\/+$/, "");
|
|
15
|
+
const decoder = new TextDecoder();
|
|
16
|
+
function base64url(text) {
|
|
17
|
+
const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(text.length / 4) * 4, "="));
|
|
18
|
+
const bytes = new Uint8Array(binary.length);
|
|
19
|
+
for (let index = 0; index < binary.length; index++)
|
|
20
|
+
bytes[index] = binary.charCodeAt(index);
|
|
21
|
+
return bytes;
|
|
22
|
+
}
|
|
23
|
+
const part = (text) => { try {
|
|
24
|
+
return JSON.parse(decoder.decode(base64url(text)));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new RuntimeTokenError("Malformed token");
|
|
28
|
+
} };
|
|
29
|
+
const keySets = new WeakMap();
|
|
30
|
+
async function publicKey(url, kid, fetcher) {
|
|
31
|
+
let sets = keySets.get(fetcher);
|
|
32
|
+
if (!sets)
|
|
33
|
+
keySets.set(fetcher, sets = new Map());
|
|
34
|
+
let set = sets.get(url);
|
|
35
|
+
if (!set)
|
|
36
|
+
sets.set(url, set = { keys: new Map(), fetched: 0 });
|
|
37
|
+
const age = Date.now() - set.fetched;
|
|
38
|
+
if (age > 300_000 || (!set.keys.has(kid) && age > 10_000)) {
|
|
39
|
+
set.loading ??= (async () => {
|
|
40
|
+
const response = await fetcher(url, { headers: { Accept: "application/json" }, signal: AbortSignal.timeout(10_000) });
|
|
41
|
+
if (!response.ok)
|
|
42
|
+
throw new RuntimeTokenError(`Could not read the runtime's keys (${url}: HTTP ${response.status})`);
|
|
43
|
+
const body = await response.json();
|
|
44
|
+
const keys = new Map();
|
|
45
|
+
for (const jwk of body.keys ?? []) {
|
|
46
|
+
if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || typeof jwk.kid !== "string")
|
|
47
|
+
continue;
|
|
48
|
+
keys.set(jwk.kid, await crypto.subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: jwk.x }, { name: "Ed25519" }, false, ["verify"]));
|
|
49
|
+
}
|
|
50
|
+
set.keys = keys;
|
|
51
|
+
set.fetched = Date.now();
|
|
52
|
+
})().finally(() => { set.loading = undefined; });
|
|
53
|
+
await set.loading;
|
|
54
|
+
}
|
|
55
|
+
const key = set.keys.get(kid);
|
|
56
|
+
if (!key)
|
|
57
|
+
throw new RuntimeTokenError("Token signed with a key the runtime does not publish");
|
|
58
|
+
return key;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Verify an identity token and return who the call is for. Checks the signature against the runtime's
|
|
62
|
+
* published Ed25519 keys (EdDSA only), the issuer, that the audience is yours, and the times.
|
|
63
|
+
*/
|
|
64
|
+
export async function verifyRuntimeToken(token, options) {
|
|
65
|
+
const pieces = token.split(".");
|
|
66
|
+
if (pieces.length !== 3)
|
|
67
|
+
throw new RuntimeTokenError("Malformed token");
|
|
68
|
+
const header = part(pieces[0]);
|
|
69
|
+
if (header.alg !== "EdDSA" || typeof header.kid !== "string")
|
|
70
|
+
throw new RuntimeTokenError("Token is not an EdDSA token with a key id");
|
|
71
|
+
const runtime = trim(options.runtime);
|
|
72
|
+
const key = await publicKey(`${runtime}/.well-known/jwks.json`, header.kid, options.fetch ?? globalThis.fetch);
|
|
73
|
+
const valid = await crypto.subtle.verify({ name: "Ed25519" }, key, base64url(pieces[2]), new TextEncoder().encode(`${pieces[0]}.${pieces[1]}`));
|
|
74
|
+
if (!valid)
|
|
75
|
+
throw new RuntimeTokenError("Token signature does not verify");
|
|
76
|
+
const claims = part(pieces[1]);
|
|
77
|
+
const now = Math.floor(Date.now() / 1000), skew = options.clockTolerance ?? 30;
|
|
78
|
+
if (claims.iss !== trim(options.issuer ?? runtime))
|
|
79
|
+
throw new RuntimeTokenError("Token is from another issuer");
|
|
80
|
+
const audiences = new Set((Array.isArray(options.audience) ? options.audience : [options.audience]).map(trim));
|
|
81
|
+
if (![claims.aud].flat().some(audience => typeof audience === "string" && audiences.has(trim(audience))))
|
|
82
|
+
throw new RuntimeTokenError("Token is for another server");
|
|
83
|
+
if (typeof claims.exp !== "number" || claims.exp + skew < now)
|
|
84
|
+
throw new RuntimeTokenError("Token has expired");
|
|
85
|
+
if (typeof claims.nbf === "number" && claims.nbf - skew > now)
|
|
86
|
+
throw new RuntimeTokenError("Token is not valid yet");
|
|
87
|
+
if (typeof claims.iat === "number" && claims.iat - skew > now)
|
|
88
|
+
throw new RuntimeTokenError("Token is issued in the future");
|
|
89
|
+
return { ...identityFromClaims(claims), claims };
|
|
90
|
+
}
|
|
91
|
+
/** A request's bearer token, if it has one. */
|
|
92
|
+
export function bearerToken(request) {
|
|
93
|
+
const match = /^Bearer\s+(\S+)$/i.exec(request.headers.get("authorization") ?? "");
|
|
94
|
+
return match?.[1];
|
|
95
|
+
}
|
|
96
|
+
/** Your server's URL as the runtime calls it, from a request: its origin and path, without query. */
|
|
97
|
+
const requestAudience = (request) => { const url = new URL(request.url); return `${url.origin}${url.pathname}`; };
|
|
98
|
+
/**
|
|
99
|
+
* For servers built with the MCP SDK (or Cloudflare's `createMcpHandler`): verify the request's
|
|
100
|
+
* token and return MCP's `AuthInfo`, with the identity in `extra.identity` (the token's claims in `extra.claims`). Pass it as the request's
|
|
101
|
+
* `auth` (`transport.handleRequest(Object.assign(req, { auth }), ...)`, or `createMcpHandler(server,
|
|
102
|
+
* { authContext })`), and a tool handler reads `runtimeIdentity(extra)`.
|
|
103
|
+
*/
|
|
104
|
+
export async function runtimeAuth(request, options) {
|
|
105
|
+
const token = bearerToken(request);
|
|
106
|
+
if (!token)
|
|
107
|
+
throw new RuntimeTokenError("No bearer token");
|
|
108
|
+
const { claims, ...identity } = await verifyRuntimeToken(token, { ...options, audience: options.audience ?? requestAudience(request) });
|
|
109
|
+
return { token, clientId: identity.agent, scopes: [], expiresAt: claims.exp, extra: { identity: identity, claims } };
|
|
110
|
+
}
|
|
111
|
+
/** The identity `runtimeAuth` put in an MCP SDK tool handler's `extra`. */
|
|
112
|
+
export function runtimeIdentity(extra) {
|
|
113
|
+
return extra?.authInfo?.extra?.identity;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Serve tools (`tool({...})` definitions, or any `ToolServer`) as a stateless MCP server over
|
|
117
|
+
* Streamable HTTP, for the runtime to call with its identity tokens: a fetch handler
|
|
118
|
+
* (`(Request) => Promise<Response>`). Every call's context carries the verified `identity`;
|
|
119
|
+
* requests without a valid token get a 401. The same tools can be attached to an agent instead.
|
|
120
|
+
*/
|
|
121
|
+
export function serveTools(tools, options) {
|
|
122
|
+
const server = typeof tools.listTools === "function" && typeof tools.callTool === "function" ? tools : toolServer(tools);
|
|
123
|
+
const issuer = trim(options.issuer ?? options.runtime);
|
|
124
|
+
const json = (status, body, headers = {}) => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", ...headers } });
|
|
125
|
+
const WELL_KNOWN = "/.well-known/oauth-protected-resource";
|
|
126
|
+
return async (request) => {
|
|
127
|
+
const url = new URL(request.url);
|
|
128
|
+
// RFC 9728: the metadata for https://host/mcp is at https://host/.well-known/oauth-protected-resource/mcp.
|
|
129
|
+
if (options.metadata !== false && request.method === "GET" && url.pathname.startsWith(WELL_KNOWN)) {
|
|
130
|
+
const resource = `${url.origin}${url.pathname.slice(WELL_KNOWN.length) || "/"}`;
|
|
131
|
+
return json(200, { resource, authorization_servers: [issuer], bearer_methods_supported: ["header"], resource_name: options.serverInfo?.name ?? "agent-runtime tools" });
|
|
132
|
+
}
|
|
133
|
+
if (request.method !== "POST")
|
|
134
|
+
return json(405, { error: "Use POST: this MCP server is stateless and has no event stream" }, { Allow: "POST" });
|
|
135
|
+
let identity;
|
|
136
|
+
try {
|
|
137
|
+
const token = bearerToken(request);
|
|
138
|
+
if (!token)
|
|
139
|
+
throw new RuntimeTokenError("No bearer token");
|
|
140
|
+
const { claims: _claims, ...verified } = await verifyRuntimeToken(token, { ...options, audience: options.audience ?? requestAudience(request) });
|
|
141
|
+
identity = verified;
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (!(error instanceof RuntimeTokenError))
|
|
145
|
+
throw error;
|
|
146
|
+
const metadata = options.metadata !== false ? `, resource_metadata="${url.origin}${WELL_KNOWN}${url.pathname === "/" ? "" : url.pathname}"` : "";
|
|
147
|
+
return json(401, { error: error.message }, { "WWW-Authenticate": `Bearer error="invalid_token"${metadata}` });
|
|
148
|
+
}
|
|
149
|
+
let body;
|
|
150
|
+
try {
|
|
151
|
+
body = await request.json();
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return json(400, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
|
|
155
|
+
}
|
|
156
|
+
const messages = Array.isArray(body) ? body : [body];
|
|
157
|
+
const answers = await Promise.all(messages.map(async (message) => {
|
|
158
|
+
if (!message || typeof message.method !== "string")
|
|
159
|
+
return message && "id" in message ? { jsonrpc: "2.0", id: message.id ?? null, error: { code: -32600, message: "Invalid request" } } : undefined;
|
|
160
|
+
if (message.id === undefined)
|
|
161
|
+
return undefined;
|
|
162
|
+
const answer = await answerMcp(message, server, params => toolContext(params, String(message.id), request.signal, identity), options.serverInfo);
|
|
163
|
+
return { jsonrpc: "2.0", id: message.id, ...answer };
|
|
164
|
+
}));
|
|
165
|
+
const replies = answers.filter(answer => answer !== undefined);
|
|
166
|
+
// Only notifications: accepted, nothing to answer.
|
|
167
|
+
if (!replies.length)
|
|
168
|
+
return new Response(null, { status: 202 });
|
|
169
|
+
return json(200, Array.isArray(body) ? replies : replies[0]);
|
|
170
|
+
};
|
|
171
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test your tool server's authorization without a runtime: `testRuntime()` signs identity tokens
|
|
3
|
+
* with a key of its own and serves its keys to `serveTools` / `verifyRuntimeToken` through `fetch`.
|
|
4
|
+
*
|
|
5
|
+
* const runtime = await testRuntime();
|
|
6
|
+
* const handler = serveTools(tools, runtime.options);
|
|
7
|
+
* const result = await runtime.callTool(handler, "https://app.test/mcp", "list_todos", {}, { subject: "alice" });
|
|
8
|
+
*/
|
|
9
|
+
export interface TestIdentity {
|
|
10
|
+
subject?: string;
|
|
11
|
+
actor?: string;
|
|
12
|
+
tenant?: string;
|
|
13
|
+
agent?: string;
|
|
14
|
+
definition?: string;
|
|
15
|
+
context?: Record<string, unknown>;
|
|
16
|
+
origin?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
export declare function testRuntime(options?: {
|
|
19
|
+
url?: string;
|
|
20
|
+
}): Promise<{
|
|
21
|
+
url: string;
|
|
22
|
+
jwk: {
|
|
23
|
+
crv?: string;
|
|
24
|
+
d?: string;
|
|
25
|
+
dp?: string;
|
|
26
|
+
dq?: string;
|
|
27
|
+
e?: string;
|
|
28
|
+
ext?: boolean;
|
|
29
|
+
k?: string;
|
|
30
|
+
key_ops?: string[];
|
|
31
|
+
kty?: string;
|
|
32
|
+
n?: string;
|
|
33
|
+
oth?: RsaOtherPrimesInfo[];
|
|
34
|
+
p?: string;
|
|
35
|
+
q?: string;
|
|
36
|
+
qi?: string;
|
|
37
|
+
x?: string;
|
|
38
|
+
y?: string;
|
|
39
|
+
kid: `${string}-${string}-${string}-${string}-${string}`;
|
|
40
|
+
alg: string;
|
|
41
|
+
use: string;
|
|
42
|
+
};
|
|
43
|
+
token: (identity: TestIdentity, audience: string, overrides?: {
|
|
44
|
+
claims?: Record<string, unknown>;
|
|
45
|
+
header?: Record<string, unknown>;
|
|
46
|
+
expiresIn?: number;
|
|
47
|
+
}) => Promise<string>;
|
|
48
|
+
request: (serverUrl: string, message: unknown, identity?: TestIdentity | null) => Promise<Request>;
|
|
49
|
+
callTool: (handler: (request: Request) => Promise<Response>, serverUrl: string, name: string, args: Record<string, unknown>, identity: TestIdentity) => Promise<{
|
|
50
|
+
content: Array<Record<string, unknown>>;
|
|
51
|
+
structuredContent?: Record<string, unknown>;
|
|
52
|
+
isError?: boolean;
|
|
53
|
+
}>;
|
|
54
|
+
fetch: typeof fetch;
|
|
55
|
+
options: {
|
|
56
|
+
runtime: string;
|
|
57
|
+
fetch: typeof fetch;
|
|
58
|
+
};
|
|
59
|
+
}>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const encode = (value) => base64url(new TextEncoder().encode(typeof value === "string" ? value : JSON.stringify(value)));
|
|
2
|
+
function base64url(bytes) {
|
|
3
|
+
let binary = "";
|
|
4
|
+
for (const byte of bytes)
|
|
5
|
+
binary += String.fromCharCode(byte);
|
|
6
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
7
|
+
}
|
|
8
|
+
export async function testRuntime(options = {}) {
|
|
9
|
+
const url = (options.url ?? "https://runtime.test").replace(/\/+$/, "");
|
|
10
|
+
const { privateKey, publicKey } = await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
|
|
11
|
+
const kid = crypto.randomUUID();
|
|
12
|
+
const jwk = { ...await crypto.subtle.exportKey("jwk", publicKey), kid, alg: "EdDSA", use: "sig" };
|
|
13
|
+
const jwksUrl = `${url}/.well-known/jwks.json`;
|
|
14
|
+
/** Serves this runtime's keys; any other URL goes to the real fetch. */
|
|
15
|
+
const fetcher = async (input, init) => {
|
|
16
|
+
const target = input instanceof Request ? input.url : String(input);
|
|
17
|
+
if (target === jwksUrl)
|
|
18
|
+
return new Response(JSON.stringify({ keys: [jwk] }), { headers: { "Content-Type": "application/json" } });
|
|
19
|
+
return globalThis.fetch(input, init);
|
|
20
|
+
};
|
|
21
|
+
/** A token for `audience` as the runtime would sign it; `overrides` change claims or the header, to test rejections. */
|
|
22
|
+
async function token(identity, audience, overrides = {}) {
|
|
23
|
+
const now = Math.floor(Date.now() / 1000);
|
|
24
|
+
const agent = identity.agent ?? "client_test";
|
|
25
|
+
const claims = {
|
|
26
|
+
iss: url, aud: audience, sub: identity.subject ?? agent, tenant: identity.tenant ?? "test", agent,
|
|
27
|
+
...(identity.definition ? { definition: identity.definition } : {}), ...(identity.context ? { ctx: identity.context } : {}),
|
|
28
|
+
...(identity.actor ? { act: identity.actor } : {}), ...(identity.origin ? { origin: identity.origin } : {}),
|
|
29
|
+
iat: now, exp: now + (overrides.expiresIn ?? 120), jti: crypto.randomUUID(), ...overrides.claims,
|
|
30
|
+
};
|
|
31
|
+
const signed = `${encode({ alg: "EdDSA", kid, typ: "JWT", ...overrides.header })}.${encode(claims)}`;
|
|
32
|
+
const signature = new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, privateKey, new TextEncoder().encode(signed)));
|
|
33
|
+
return `${signed}.${base64url(signature)}`;
|
|
34
|
+
}
|
|
35
|
+
/** A JSON-RPC POST to `serverUrl`, carrying a token for `identity` (none if `identity` is null). */
|
|
36
|
+
async function request(serverUrl, message, identity = {}) {
|
|
37
|
+
const headers = { "Content-Type": "application/json", Accept: "application/json, text/event-stream" };
|
|
38
|
+
if (identity)
|
|
39
|
+
headers.Authorization = `Bearer ${await token(identity, serverUrl)}`;
|
|
40
|
+
return new Request(serverUrl, { method: "POST", headers, body: JSON.stringify(message) });
|
|
41
|
+
}
|
|
42
|
+
/** Call one tool through a fetch handler as `identity`: its CallToolResult, or the JSON-RPC error thrown. */
|
|
43
|
+
async function callTool(handler, serverUrl, name, args, identity) {
|
|
44
|
+
const response = await handler(await request(serverUrl, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }, identity));
|
|
45
|
+
const body = await response.json();
|
|
46
|
+
if (!response.ok)
|
|
47
|
+
throw new Error(`HTTP ${response.status}: ${body.error ?? JSON.stringify(body)}`);
|
|
48
|
+
if (body.error)
|
|
49
|
+
throw new Error(body.error.message);
|
|
50
|
+
return body.result;
|
|
51
|
+
}
|
|
52
|
+
return { url, jwk, token, request, callTool, fetch: fetcher, options: { runtime: url, fetch: fetcher } };
|
|
53
|
+
}
|