toolnexus 0.24.0 → 0.26.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 +151 -1
- package/dist/acp.d.ts +37 -6
- package/dist/acp.js +239 -52
- package/dist/classifier.d.ts +189 -31
- package/dist/classifier.js +589 -30
- package/dist/client.d.ts +38 -8
- package/dist/client.js +102 -23
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/judge.d.ts +6 -3
- package/dist/judge.js +13 -1
- package/dist/mcp.d.ts +32 -7
- package/dist/mcp.js +125 -36
- package/dist/mcp2026/auth.d.ts +137 -0
- package/dist/mcp2026/auth.js +446 -0
- package/dist/mcp2026/client.d.ts +48 -0
- package/dist/mcp2026/client.js +160 -0
- package/dist/mcp2026/protocol.d.ts +54 -0
- package/dist/mcp2026/protocol.js +113 -0
- package/dist/mcp2026/skills.d.ts +5 -0
- package/dist/mcp2026/skills.js +25 -0
- package/dist/mcp2026/source.d.ts +86 -0
- package/dist/mcp2026/source.js +372 -0
- package/dist/mcp2026/stdio.d.ts +24 -0
- package/dist/mcp2026/stdio.js +99 -0
- package/dist/mcp2026/toolsearch.d.ts +5 -0
- package/dist/mcp2026/toolsearch.js +58 -0
- package/dist/mcphandler.d.ts +169 -0
- package/dist/mcphandler.js +1145 -0
- package/dist/mcpheader.d.ts +5 -0
- package/dist/mcpheader.js +18 -0
- package/dist/mcpserve.d.ts +24 -15
- package/dist/mcpserve.js +105 -2
- package/dist/mcpserverauth.d.ts +48 -0
- package/dist/mcpserverauth.js +203 -0
- package/dist/serve.js +7 -0
- package/dist/skill.d.ts +21 -0
- package/dist/skill.js +44 -0
- package/dist/toolkit.d.ts +20 -1
- package/dist/toolkit.js +25 -3
- package/dist/types.d.ts +22 -0
- package/package.json +4 -3
- package/src/acp.ts +258 -55
- package/src/classifier.ts +677 -46
- package/src/client.ts +130 -27
- package/src/index.ts +2 -0
- package/src/judge.ts +19 -4
- package/src/mcp.ts +162 -43
- package/src/mcp2026/auth.ts +473 -0
- package/src/mcp2026/client.ts +161 -0
- package/src/mcp2026/protocol.ts +135 -0
- package/src/mcp2026/skills.ts +22 -0
- package/src/mcp2026/source.ts +407 -0
- package/src/mcp2026/stdio.ts +102 -0
- package/src/mcp2026/toolsearch.ts +56 -0
- package/src/mcphandler.ts +1252 -0
- package/src/mcpheader.ts +19 -0
- package/src/mcpserve.ts +115 -2
- package/src/mcpserverauth.ts +216 -0
- package/src/serve.ts +7 -0
- package/src/skill.ts +50 -0
- package/src/toolkit.ts +31 -4
- package/src/types.ts +23 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Internal (not exported from index): MCP 2026-07-28 header helpers shared by the handler and McpServerAuth. */
|
|
2
|
+
/** Decodes the `=?base64?<std-base64>?=` sentinel (SPEC §2.3); an absent header is not ok. */
|
|
3
|
+
export declare function decodeHeaderValue(v: string): [string, boolean];
|
|
4
|
+
/** GET a JSON document (200 only). */
|
|
5
|
+
export declare function getJson(f: typeof fetch, url: string): Promise<any>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Internal (not exported from index): MCP 2026-07-28 header helpers shared by the handler and McpServerAuth. */
|
|
2
|
+
/** Decodes the `=?base64?<std-base64>?=` sentinel (SPEC §2.3); an absent header is not ok. */
|
|
3
|
+
export function decodeHeaderValue(v) {
|
|
4
|
+
if (v.startsWith("=?base64?") && v.endsWith("?=") && v.length >= 11) {
|
|
5
|
+
const b = v.slice(9, -2);
|
|
6
|
+
if (b.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(b))
|
|
7
|
+
return ["", false];
|
|
8
|
+
return [Buffer.from(b, "base64").toString("utf8"), true];
|
|
9
|
+
}
|
|
10
|
+
return [v, v !== ""];
|
|
11
|
+
}
|
|
12
|
+
/** GET a JSON document (200 only). */
|
|
13
|
+
export async function getJson(f, url) {
|
|
14
|
+
const r = await f(url, { headers: { Accept: "application/json" } });
|
|
15
|
+
if (r.status !== 200)
|
|
16
|
+
throw new Error(`GET ${url}: HTTP ${r.status}`);
|
|
17
|
+
return r.json();
|
|
18
|
+
}
|
package/dist/mcpserve.d.ts
CHANGED
|
@@ -1,19 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MCP — INBOUND: serve a toolkit as an MCP server (SPEC §7C).
|
|
3
|
-
*
|
|
4
|
-
* The inbound mirror of the A2A `serve` (§7B). Where A2A advertises the toolkit's
|
|
5
|
-
* **skills** and fulfils a Task through the whole client loop, MCP advertises the
|
|
6
|
-
* toolkit's **unified tools** (every source — mcp · skill · native · http ·
|
|
7
|
-
* builtin · a2a) and dispatches each `tools/call` straight to `Tool.execute`. The
|
|
8
|
-
* calling MCP client *is* the LLM host, so there is **no client, no Task, and no
|
|
9
|
-
* TaskStore** — just `tools/list` + `tools/call` over the toolkit registry.
|
|
10
|
-
*
|
|
11
|
-
* Transport: **streamable-HTTP**, mounted at `POST /mcp` on the `serve(addr, …)`
|
|
12
|
-
* server alongside the A2A routes (see serve.ts), built on the same
|
|
13
|
-
* `@modelcontextprotocol/sdk` the client side already uses.
|
|
14
|
-
*/
|
|
15
1
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
16
|
-
import type { Tool, ToolSource } from "./types.js";
|
|
2
|
+
import type { Tool, ToolSource, ToolResult } from "./types.js";
|
|
17
3
|
/** Opt-in MCP serve profile — the toolkit is exposed as an MCP server. */
|
|
18
4
|
export interface MCPServeConfig {
|
|
19
5
|
/** Advertised server name (initialize `serverInfo.name`). Default "toolnexus". */
|
|
@@ -45,3 +31,26 @@ export declare function exposedMcpTools(tools: Tool[], cfg?: MCPServeConfig): To
|
|
|
45
31
|
* request (stateless).
|
|
46
32
|
*/
|
|
47
33
|
export declare function buildMcpServer(tools: Tool[], cfg?: MCPServeConfig, onCall?: OnCall): Server;
|
|
34
|
+
/**
|
|
35
|
+
* §7C / §1. Map a ToolResult to an MCP `CallToolResult`: `output` → the text block (or, when
|
|
36
|
+
* `output` is empty and `structured` is set, the JSON text of `structured`), `parts` → extra
|
|
37
|
+
* blocks, `structured` → `structuredContent`, `meta` → `_meta`. With neither set the shape is
|
|
38
|
+
* byte-identical to the pre-§7C.1 result. `omitFalseIsError` drops `isError:false` (the
|
|
39
|
+
* hand-written §7C.1 handler wire, like Go's omitempty); the SDK serve path keeps it.
|
|
40
|
+
*/
|
|
41
|
+
export declare function toolResultToMcp(result: ToolResult, omitFalseIsError?: boolean): Record<string, unknown>;
|
|
42
|
+
/** A loopback MCP endpoint for a local agent (SPEC §8C, {@link Toolkit.asMcpServer}). */
|
|
43
|
+
export interface LocalMcpServer {
|
|
44
|
+
/** `http://127.0.0.1:<port>/mcp`. */
|
|
45
|
+
url: string;
|
|
46
|
+
/** 64 lowercase hex chars; required as `Authorization: Bearer <token>`. Never logged. */
|
|
47
|
+
token: string;
|
|
48
|
+
/** Stops the listener. Idempotent. */
|
|
49
|
+
close(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Serves `tools` (filtered by `cfg.tools`) as the stateless §7C streamable-HTTP MCP endpoint on
|
|
53
|
+
* `127.0.0.1:<ephemeral>/mcp` behind a fresh bearer token. Other paths ⇒ 404; missing/wrong
|
|
54
|
+
* token ⇒ 401 + `WWW-Authenticate: Bearer` (constant-time compare); a non-POST ⇒ 405.
|
|
55
|
+
*/
|
|
56
|
+
export declare function serveLocalMcp(tools: Tool[], cfg?: MCPServeConfig): Promise<LocalMcpServer>;
|
package/dist/mcpserve.js
CHANGED
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
* server alongside the A2A routes (see serve.ts), built on the same
|
|
13
13
|
* `@modelcontextprotocol/sdk` the client side already uses.
|
|
14
14
|
*/
|
|
15
|
+
import http from "node:http";
|
|
16
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
15
17
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
18
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
16
19
|
import { ListToolsRequestSchema, CallToolRequestSchema, McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
17
20
|
const DEFAULT_NAME = "toolnexus";
|
|
18
21
|
const DEFAULT_VERSION = "0.1.0";
|
|
@@ -38,7 +41,9 @@ export function buildMcpServer(tools, cfg, onCall) {
|
|
|
38
41
|
const byName = new Map(tools.map((t) => [t.name, t]));
|
|
39
42
|
const server = new Server({ name: cfg?.name ?? DEFAULT_NAME, version: cfg?.version ?? DEFAULT_VERSION }, { capabilities: { tools: {} } });
|
|
40
43
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
41
|
-
tools: tools.map((t) =>
|
|
44
|
+
tools: tools.map((t) => t.outputSchema
|
|
45
|
+
? { name: t.name, description: t.description, inputSchema: t.inputSchema, outputSchema: t.outputSchema }
|
|
46
|
+
: { name: t.name, description: t.description, inputSchema: t.inputSchema }),
|
|
42
47
|
}));
|
|
43
48
|
server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
|
|
44
49
|
const tool = byName.get(req.params.name);
|
|
@@ -63,10 +68,38 @@ export function buildMcpServer(tools, cfg, onCall) {
|
|
|
63
68
|
// Host callback errors are isolated.
|
|
64
69
|
}
|
|
65
70
|
}
|
|
66
|
-
return
|
|
71
|
+
return toolResultToMcp(result);
|
|
67
72
|
});
|
|
68
73
|
return server;
|
|
69
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* §7C / §1. Map a ToolResult to an MCP `CallToolResult`: `output` → the text block (or, when
|
|
77
|
+
* `output` is empty and `structured` is set, the JSON text of `structured`), `parts` → extra
|
|
78
|
+
* blocks, `structured` → `structuredContent`, `meta` → `_meta`. With neither set the shape is
|
|
79
|
+
* byte-identical to the pre-§7C.1 result. `omitFalseIsError` drops `isError:false` (the
|
|
80
|
+
* hand-written §7C.1 handler wire, like Go's omitempty); the SDK serve path keeps it.
|
|
81
|
+
*/
|
|
82
|
+
export function toolResultToMcp(result, omitFalseIsError = false) {
|
|
83
|
+
let text = result.output ?? "";
|
|
84
|
+
if (result.structured !== undefined && result.structured !== null && text === "") {
|
|
85
|
+
try {
|
|
86
|
+
text = JSON.stringify(result.structured);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// leave the text empty when structured is not serialisable
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const out = { content: [{ type: "text", text }, ...partBlocks(result.parts)] };
|
|
93
|
+
if (!omitFalseIsError)
|
|
94
|
+
out.isError = result.isError;
|
|
95
|
+
else if (result.isError)
|
|
96
|
+
out.isError = true;
|
|
97
|
+
if (result.structured !== undefined && result.structured !== null)
|
|
98
|
+
out.structuredContent = result.structured;
|
|
99
|
+
if (result.meta)
|
|
100
|
+
out._meta = result.meta;
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
70
103
|
/**
|
|
71
104
|
* §7C. A served tool's `ToolResult.parts` become MCP content blocks appended after the
|
|
72
105
|
* text part, in order — image ⇒ image content, audio ⇒ audio content, file ⇒ an embedded
|
|
@@ -93,3 +126,73 @@ function partBlocks(parts) {
|
|
|
93
126
|
}
|
|
94
127
|
return out;
|
|
95
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Serves `tools` (filtered by `cfg.tools`) as the stateless §7C streamable-HTTP MCP endpoint on
|
|
131
|
+
* `127.0.0.1:<ephemeral>/mcp` behind a fresh bearer token. Other paths ⇒ 404; missing/wrong
|
|
132
|
+
* token ⇒ 401 + `WWW-Authenticate: Bearer` (constant-time compare); a non-POST ⇒ 405.
|
|
133
|
+
*/
|
|
134
|
+
export async function serveLocalMcp(tools, cfg) {
|
|
135
|
+
const exposed = exposedMcpTools(tools, cfg);
|
|
136
|
+
const token = randomBytes(32).toString("hex");
|
|
137
|
+
const want = Buffer.from("Bearer " + token);
|
|
138
|
+
const server = http.createServer((req, res) => {
|
|
139
|
+
const u = req.url ?? "";
|
|
140
|
+
if (u !== "/mcp" && !u.startsWith("/mcp?")) {
|
|
141
|
+
res.writeHead(404).end("not found");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const got = Buffer.from(String(req.headers.authorization ?? ""));
|
|
145
|
+
if (got.length !== want.length || !timingSafeEqual(got, want)) {
|
|
146
|
+
res.writeHead(401, { "WWW-Authenticate": "Bearer" }).end("unauthorized");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (req.method !== "POST") {
|
|
150
|
+
res.writeHead(405, { Allow: "POST" }).end("method not allowed");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
void (async () => {
|
|
154
|
+
const body = await readBody(req);
|
|
155
|
+
let parsed;
|
|
156
|
+
try {
|
|
157
|
+
parsed = body ? JSON.parse(body) : undefined;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
parsed = undefined;
|
|
161
|
+
}
|
|
162
|
+
const mcp = buildMcpServer(exposed, cfg);
|
|
163
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
164
|
+
res.on("close", () => {
|
|
165
|
+
void transport.close();
|
|
166
|
+
void mcp.close();
|
|
167
|
+
});
|
|
168
|
+
await mcp.connect(transport);
|
|
169
|
+
await transport.handleRequest(req, res, parsed);
|
|
170
|
+
})().catch(() => {
|
|
171
|
+
if (!res.headersSent)
|
|
172
|
+
res.writeHead(500).end("mcp error");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
await new Promise((resolve, reject) => {
|
|
176
|
+
server.once("error", reject);
|
|
177
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
178
|
+
});
|
|
179
|
+
const port = server.address().port;
|
|
180
|
+
let closing;
|
|
181
|
+
return {
|
|
182
|
+
url: `http://127.0.0.1:${port}/mcp`,
|
|
183
|
+
token,
|
|
184
|
+
close: () => (closing ??= new Promise((resolve) => {
|
|
185
|
+
server.close(() => resolve());
|
|
186
|
+
server.closeAllConnections();
|
|
187
|
+
})),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function readBody(req) {
|
|
191
|
+
return new Promise((resolve, reject) => {
|
|
192
|
+
let data = "";
|
|
193
|
+
req.setEncoding("utf8");
|
|
194
|
+
req.on("data", (c) => (data += c));
|
|
195
|
+
req.on("end", () => resolve(data));
|
|
196
|
+
req.on("error", reject);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
/** The verified caller; under McpServerAuth, `identityFrom(ctx)` returns it. */
|
|
3
|
+
export interface McpPrincipal {
|
|
4
|
+
subject: string;
|
|
5
|
+
scopes: string[];
|
|
6
|
+
claims?: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
export interface McpServerAuthOptions {
|
|
9
|
+
/** The authorization server's issuer (advertised in the PRM, and the JWT `iss` required). */
|
|
10
|
+
issuer: string;
|
|
11
|
+
/** Canonical resource URI (PRM `resource`, JWT `aud`). Omit ⇒ `<scheme>://<Host><resourcePath>` per request. */
|
|
12
|
+
audience?: string;
|
|
13
|
+
/** Where the handler is mounted. Default "/mcp". */
|
|
14
|
+
resourcePath?: string;
|
|
15
|
+
/** "*" is required on every request (and advertised); a tool name adds scopes for that tools/call. */
|
|
16
|
+
scopes?: Record<string, string[]>;
|
|
17
|
+
/** Replaces the default RS256/JWKS verifier (opaque tokens, introspection). Throw ⇒ invalid_token. */
|
|
18
|
+
verify?: (req: IncomingMessage, token: string, audience: string) => Promise<McpPrincipal> | McpPrincipal;
|
|
19
|
+
/** Fetches AS metadata and JWKS. Default: global fetch with a 10 s timeout. */
|
|
20
|
+
fetch?: typeof fetch;
|
|
21
|
+
}
|
|
22
|
+
/** Protects an `mcpHandler` with bearer tokens (SPEC §2A.2). */
|
|
23
|
+
export declare class McpServerAuth {
|
|
24
|
+
readonly issuer: string;
|
|
25
|
+
readonly audience?: string;
|
|
26
|
+
readonly resourcePath: string;
|
|
27
|
+
readonly scopes: Record<string, string[]>;
|
|
28
|
+
private readonly verifyHook?;
|
|
29
|
+
private readonly fetchImpl;
|
|
30
|
+
private keys?;
|
|
31
|
+
private fetched;
|
|
32
|
+
constructor(opts: McpServerAuthOptions);
|
|
33
|
+
private audienceFor;
|
|
34
|
+
/** The RFC 9728 metadata URL the challenge names. */
|
|
35
|
+
metadataUrl(req: IncomingMessage): string;
|
|
36
|
+
/** Request listener serving the protected resource metadata at the two PRM paths; anything else 404. */
|
|
37
|
+
metadata(): (req: IncomingMessage, res: ServerResponse) => void;
|
|
38
|
+
private challenge;
|
|
39
|
+
/**
|
|
40
|
+
* The `mcpHandler` `authenticate` hook (bound). Missing token ⇒ 401; invalid ⇒ 401
|
|
41
|
+
* error="invalid_token"; a missing scope ⇒ 403 error="insufficient_scope" naming the full needed
|
|
42
|
+
* set. Per-tool scopes come from the Mcp-Method / Mcp-Name headers (the 2026-07-28 wire).
|
|
43
|
+
*/
|
|
44
|
+
readonly authenticate: (req: IncomingMessage) => Promise<McpPrincipal>;
|
|
45
|
+
private verifyJwt;
|
|
46
|
+
/** The JWKS key for kid; an unknown kid refetches at most every 30 s. */
|
|
47
|
+
private key;
|
|
48
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server authorization (SPEC §2A.2, §7C.2): an `mcpHandler` as an OAuth 2.1 protected
|
|
3
|
+
* resource. `McpServerAuth` is a plain helper producing the handler's `authenticate` hook and the
|
|
4
|
+
* RFC 9728 metadata listener; the host mounts both. Identity, tenants and token storage stay the
|
|
5
|
+
* host's.
|
|
6
|
+
*
|
|
7
|
+
* const auth = new McpServerAuth({ issuer: "https://login.example", scopes: { "*": ["mcp"] } })
|
|
8
|
+
* const mcp = toolkit.mcpHandler({ protocolVersions: ["2026-07-28"], authenticate: auth.authenticate })
|
|
9
|
+
* const meta = auth.metadata() // mount at /.well-known/oauth-protected-resource[/mcp]
|
|
10
|
+
*/
|
|
11
|
+
import { createPublicKey, verify as cryptoVerify } from "node:crypto";
|
|
12
|
+
import { McpAuthError } from "./mcphandler.js";
|
|
13
|
+
import { decodeHeaderValue, getJson } from "./mcpheader.js";
|
|
14
|
+
const PRM_PATH = "/.well-known/oauth-protected-resource";
|
|
15
|
+
function header(req, name) {
|
|
16
|
+
const v = req.headers[name.toLowerCase()];
|
|
17
|
+
return Array.isArray(v) ? (v[0] ?? "") : (v ?? "");
|
|
18
|
+
}
|
|
19
|
+
function origin(req) {
|
|
20
|
+
const scheme = req.socket.encrypted ? "https" : "http";
|
|
21
|
+
return `${scheme}://${header(req, "Host")}`;
|
|
22
|
+
}
|
|
23
|
+
/** Protects an `mcpHandler` with bearer tokens (SPEC §2A.2). */
|
|
24
|
+
export class McpServerAuth {
|
|
25
|
+
issuer;
|
|
26
|
+
audience;
|
|
27
|
+
resourcePath;
|
|
28
|
+
scopes;
|
|
29
|
+
verifyHook;
|
|
30
|
+
fetchImpl;
|
|
31
|
+
keys;
|
|
32
|
+
fetched = 0;
|
|
33
|
+
constructor(opts) {
|
|
34
|
+
this.issuer = opts.issuer;
|
|
35
|
+
this.audience = opts.audience || undefined;
|
|
36
|
+
this.resourcePath = opts.resourcePath || "/mcp";
|
|
37
|
+
this.scopes = opts.scopes ?? {};
|
|
38
|
+
this.verifyHook = opts.verify;
|
|
39
|
+
this.fetchImpl = opts.fetch ?? ((u, init) => fetch(u, { ...init, signal: AbortSignal.timeout(10_000) }));
|
|
40
|
+
}
|
|
41
|
+
audienceFor(req) {
|
|
42
|
+
return this.audience ?? origin(req) + this.resourcePath;
|
|
43
|
+
}
|
|
44
|
+
/** The RFC 9728 metadata URL the challenge names. */
|
|
45
|
+
metadataUrl(req) {
|
|
46
|
+
if (this.audience) {
|
|
47
|
+
try {
|
|
48
|
+
const u = new URL(this.audience);
|
|
49
|
+
if (u.host) {
|
|
50
|
+
const path = /^[^:]+:\/\/[^/?#]*\//.test(this.audience) ? u.pathname : "";
|
|
51
|
+
return `${u.protocol}//${u.host}${PRM_PATH}${path}`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// fall through to the request-derived URL
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return origin(req) + PRM_PATH + this.resourcePath;
|
|
59
|
+
}
|
|
60
|
+
/** Request listener serving the protected resource metadata at the two PRM paths; anything else 404. */
|
|
61
|
+
metadata() {
|
|
62
|
+
return (req, res) => {
|
|
63
|
+
const path = (req.url ?? "/").split("?")[0];
|
|
64
|
+
if (req.method !== "GET" || (path !== PRM_PATH && path !== PRM_PATH + this.resourcePath)) {
|
|
65
|
+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }).end("404 page not found\n");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
69
|
+
res.end(JSON.stringify({
|
|
70
|
+
resource: this.audienceFor(req),
|
|
71
|
+
authorization_servers: [this.issuer],
|
|
72
|
+
scopes_supported: this.scopes["*"] ?? [],
|
|
73
|
+
bearer_methods_supported: ["header"],
|
|
74
|
+
}) + "\n");
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
challenge(req, status, scopes, err) {
|
|
78
|
+
let v = `Bearer resource_metadata="${this.metadataUrl(req)}"`;
|
|
79
|
+
if (scopes && scopes.length > 0)
|
|
80
|
+
v += `, scope="${scopes.join(" ")}"`;
|
|
81
|
+
if (err)
|
|
82
|
+
v += `, error="${err}"`;
|
|
83
|
+
return new McpAuthError(status, v, err);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The `mcpHandler` `authenticate` hook (bound). Missing token ⇒ 401; invalid ⇒ 401
|
|
87
|
+
* error="invalid_token"; a missing scope ⇒ 403 error="insufficient_scope" naming the full needed
|
|
88
|
+
* set. Per-tool scopes come from the Mcp-Method / Mcp-Name headers (the 2026-07-28 wire).
|
|
89
|
+
*/
|
|
90
|
+
authenticate = async (req) => {
|
|
91
|
+
const authz = header(req, "Authorization");
|
|
92
|
+
const tok = authz.startsWith("Bearer ") ? authz.slice(7) : "";
|
|
93
|
+
if (tok.trim() === "")
|
|
94
|
+
throw this.challenge(req, 401, this.scopes["*"], "");
|
|
95
|
+
let p;
|
|
96
|
+
try {
|
|
97
|
+
const aud = this.audienceFor(req);
|
|
98
|
+
p = this.verifyHook ? await this.verifyHook(req, tok, aud) : await this.verifyJwt(tok, aud);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
throw this.challenge(req, 401, this.scopes["*"], "invalid_token");
|
|
102
|
+
}
|
|
103
|
+
const need = [...(this.scopes["*"] ?? [])];
|
|
104
|
+
if (header(req, "Mcp-Method") === "tools/call") {
|
|
105
|
+
const [name, ok] = decodeHeaderValue(header(req, "Mcp-Name"));
|
|
106
|
+
if (ok)
|
|
107
|
+
for (const s of this.scopes[name] ?? [])
|
|
108
|
+
if (!need.includes(s))
|
|
109
|
+
need.push(s);
|
|
110
|
+
}
|
|
111
|
+
const have = p.scopes ?? [];
|
|
112
|
+
if (need.some((s) => !have.includes(s)))
|
|
113
|
+
throw this.challenge(req, 403, need, "insufficient_scope");
|
|
114
|
+
return p;
|
|
115
|
+
};
|
|
116
|
+
async verifyJwt(tok, aud) {
|
|
117
|
+
const parts = tok.split(".");
|
|
118
|
+
if (parts.length !== 3)
|
|
119
|
+
throw new Error("malformed jwt");
|
|
120
|
+
const hdr = decodeSeg(parts[0]);
|
|
121
|
+
const claims = decodeSeg(parts[1]);
|
|
122
|
+
if (!hdr || !claims || hdr.alg !== "RS256")
|
|
123
|
+
throw new Error("bad jwt header or claims");
|
|
124
|
+
const key = await this.key(typeof hdr.kid === "string" ? hdr.kid : "");
|
|
125
|
+
if (!/^[A-Za-z0-9_-]*$/.test(parts[2]))
|
|
126
|
+
throw new Error("bad signature encoding");
|
|
127
|
+
const sig = Buffer.from(parts[2], "base64url");
|
|
128
|
+
if (!cryptoVerify("RSA-SHA256", Buffer.from(parts[0] + "." + parts[1]), key, sig))
|
|
129
|
+
throw new Error("bad signature");
|
|
130
|
+
if (claims.iss !== this.issuer)
|
|
131
|
+
throw new Error("wrong issuer");
|
|
132
|
+
const a = claims.aud;
|
|
133
|
+
if (!(typeof a === "string" ? a === aud : Array.isArray(a) && a.includes(aud)))
|
|
134
|
+
throw new Error("wrong audience");
|
|
135
|
+
const exp = typeof claims.exp === "number" ? Math.trunc(claims.exp) : 0;
|
|
136
|
+
if (exp <= Math.floor(Date.now() / 1000))
|
|
137
|
+
throw new Error("expired");
|
|
138
|
+
const scope = typeof claims.scope === "string" ? claims.scope : "";
|
|
139
|
+
return {
|
|
140
|
+
subject: typeof claims.sub === "string" ? claims.sub : "",
|
|
141
|
+
scopes: scope.split(/\s+/).filter(Boolean),
|
|
142
|
+
claims,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** The JWKS key for kid; an unknown kid refetches at most every 30 s. */
|
|
146
|
+
async key(kid) {
|
|
147
|
+
const k = this.keys?.get(kid);
|
|
148
|
+
if (k)
|
|
149
|
+
return k;
|
|
150
|
+
if (this.keys && Date.now() - this.fetched < 30_000)
|
|
151
|
+
throw new Error("unknown kid");
|
|
152
|
+
const md = await discoverAs(this.fetchImpl, this.issuer);
|
|
153
|
+
const jwks = await getJson(this.fetchImpl, String(md.jwks_uri ?? ""));
|
|
154
|
+
const keys = new Map();
|
|
155
|
+
for (const j of Array.isArray(jwks?.keys) ? jwks.keys : []) {
|
|
156
|
+
if (j?.kty !== "RSA" || typeof j.n !== "string" || typeof j.e !== "string")
|
|
157
|
+
continue;
|
|
158
|
+
try {
|
|
159
|
+
keys.set(String(j.kid ?? ""), createPublicKey({ key: { kty: "RSA", n: j.n, e: j.e }, format: "jwk" }));
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// skip a malformed key
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
this.keys = keys;
|
|
166
|
+
this.fetched = Date.now();
|
|
167
|
+
const got = keys.get(kid);
|
|
168
|
+
if (!got)
|
|
169
|
+
throw new Error("unknown kid");
|
|
170
|
+
return got;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function decodeSeg(s) {
|
|
174
|
+
if (!/^[A-Za-z0-9_-]+$/.test(s))
|
|
175
|
+
return undefined;
|
|
176
|
+
try {
|
|
177
|
+
const v = JSON.parse(Buffer.from(s, "base64url").toString("utf8"));
|
|
178
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** AS metadata in SPEC §2A.1 step 3 order; the metadata `issuer` must equal the issuer. */
|
|
185
|
+
async function discoverAs(f, issuer) {
|
|
186
|
+
const u = new URL(issuer);
|
|
187
|
+
const org = `${u.protocol}//${u.host}`;
|
|
188
|
+
const path = (/^[^:]+:\/\/[^/?#]*\//.test(issuer) ? u.pathname : "").replace(/\/$/, "");
|
|
189
|
+
const cands = [`${org}/.well-known/oauth-authorization-server${path}`, `${org}/.well-known/openid-configuration${path}`];
|
|
190
|
+
if (path)
|
|
191
|
+
cands.push(`${org}${path}/.well-known/openid-configuration`);
|
|
192
|
+
for (const c of cands) {
|
|
193
|
+
try {
|
|
194
|
+
const md = await getJson(f, c);
|
|
195
|
+
if (md && md.issuer === issuer)
|
|
196
|
+
return md;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// next candidate
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
throw new Error("authorization server metadata not found for " + issuer);
|
|
203
|
+
}
|
package/dist/serve.js
CHANGED
|
@@ -153,6 +153,13 @@ export async function startA2AServer(opts) {
|
|
|
153
153
|
// per request (stateless — sessionIdGenerator undefined). Checked first so
|
|
154
154
|
// `/mcp` is never shadowed by the A2A POST handler.
|
|
155
155
|
if (mcp && req.url && (req.url === "/mcp" || req.url.startsWith("/mcp?"))) {
|
|
156
|
+
// Stateless profile: no server-initiated SSE stream exists, so only POST is
|
|
157
|
+
// served. Refuse anything else at once (§7C) instead of holding a GET open.
|
|
158
|
+
if (req.method !== "POST") {
|
|
159
|
+
res.writeHead(405, { Allow: "POST" });
|
|
160
|
+
res.end();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
156
163
|
void (async () => {
|
|
157
164
|
const body = req.method === "POST" ? await readBody(req) : "";
|
|
158
165
|
const parsed = body ? safeJson(body) : undefined;
|
package/dist/skill.d.ts
CHANGED
|
@@ -74,3 +74,24 @@ export interface SkillSource {
|
|
|
74
74
|
export declare function listSkills(input: string | string[] | LoadSkillsOptions): SkillInventory;
|
|
75
75
|
/** Discover skills (dirs and/or data) and build the `skill` loader tool. */
|
|
76
76
|
export declare function loadSkills(input: string | string[] | LoadSkillsOptions): SkillSource;
|
|
77
|
+
/**
|
|
78
|
+
* §7C.2: one skill published through the MCP 2026-07-28 skills extension (SEP-2640) by
|
|
79
|
+
* `mcpHandler({listSkills})`. `files` are paths relative to the skill directory
|
|
80
|
+
* ("/"-separated) and MUST include "SKILL.md". Frontmatter defaults to `{name}`.
|
|
81
|
+
*/
|
|
82
|
+
export interface McpSkill {
|
|
83
|
+
name: string;
|
|
84
|
+
frontmatter?: Record<string, unknown>;
|
|
85
|
+
files: {
|
|
86
|
+
path: string;
|
|
87
|
+
text: string;
|
|
88
|
+
}[];
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* §7C.2: every skill under `dir` (any depth, `**∕SKILL.md`, symlinks NOT followed) for
|
|
92
|
+
* `mcpHandler({listSkills})`. Frontmatter is `{name, description}` parsed as §3 (trimmed); a
|
|
93
|
+
* skill whose frontmatter has no `name` is skipped. Files are every regular file under the
|
|
94
|
+
* skill directory that is valid UTF-8 (binary files are skipped — the wire carries text),
|
|
95
|
+
* sorted by path; skills sorted by name. A missing `dir` throws.
|
|
96
|
+
*/
|
|
97
|
+
export declare function loadMcpSkills(dir: string): McpSkill[];
|
package/dist/skill.js
CHANGED
|
@@ -445,3 +445,47 @@ export function loadSkills(input) {
|
|
|
445
445
|
},
|
|
446
446
|
};
|
|
447
447
|
}
|
|
448
|
+
/**
|
|
449
|
+
* §7C.2: every skill under `dir` (any depth, `**∕SKILL.md`, symlinks NOT followed) for
|
|
450
|
+
* `mcpHandler({listSkills})`. Frontmatter is `{name, description}` parsed as §3 (trimmed); a
|
|
451
|
+
* skill whose frontmatter has no `name` is skipped. Files are every regular file under the
|
|
452
|
+
* skill directory that is valid UTF-8 (binary files are skipped — the wire carries text),
|
|
453
|
+
* sorted by path; skills sorted by name. A missing `dir` throws.
|
|
454
|
+
*/
|
|
455
|
+
export function loadMcpSkills(dir) {
|
|
456
|
+
const utf8 = new TextDecoder("utf-8", { fatal: true });
|
|
457
|
+
const out = [];
|
|
458
|
+
const walk = (d, visit) => {
|
|
459
|
+
for (const e of sortEntriesByName(readdirSync(d, { withFileTypes: true }))) {
|
|
460
|
+
const full = path.join(d, e.name);
|
|
461
|
+
if (e.isDirectory())
|
|
462
|
+
walk(full, visit);
|
|
463
|
+
else if (e.isFile())
|
|
464
|
+
visit(full);
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
walk(dir, (p) => {
|
|
468
|
+
if (path.basename(p) !== "SKILL.md")
|
|
469
|
+
return;
|
|
470
|
+
const fm = parseFrontmatter(readFileSync(p, "utf8"));
|
|
471
|
+
if (fm.malformed || !fm.data.name)
|
|
472
|
+
return;
|
|
473
|
+
const root = path.dirname(p);
|
|
474
|
+
const files = [];
|
|
475
|
+
walk(root, (fp) => {
|
|
476
|
+
let text;
|
|
477
|
+
try {
|
|
478
|
+
text = utf8.decode(readFileSync(fp));
|
|
479
|
+
}
|
|
480
|
+
catch (e) {
|
|
481
|
+
if (e instanceof TypeError)
|
|
482
|
+
return; // not UTF-8
|
|
483
|
+
throw e;
|
|
484
|
+
}
|
|
485
|
+
files.push({ path: path.relative(root, fp).split(path.sep).join("/"), text });
|
|
486
|
+
});
|
|
487
|
+
files.sort((a, b) => compareCodePoints(a.path, b.path));
|
|
488
|
+
out.push({ name: fm.data.name, frontmatter: { name: fm.data.name, description: fm.data.description ?? "" }, files });
|
|
489
|
+
});
|
|
490
|
+
return out.sort((a, b) => compareCodePoints(a.name, b.name));
|
|
491
|
+
}
|
package/dist/toolkit.d.ts
CHANGED
|
@@ -7,7 +7,8 @@ import { type SkillDef } from "./skill.js";
|
|
|
7
7
|
import { type BuiltinsConfig } from "./builtin.js";
|
|
8
8
|
import { type Agent } from "./a2a.js";
|
|
9
9
|
import { type A2AConfig, type OnTask, type ServeHandle } from "./serve.js";
|
|
10
|
-
import { type MCPServeConfig, type OnCall } from "./mcpserve.js";
|
|
10
|
+
import { type LocalMcpServer, type MCPServeConfig, type OnCall } from "./mcpserve.js";
|
|
11
|
+
import { type McpHandler, type McpHandlerConfig } from "./mcphandler.js";
|
|
11
12
|
import type { Client } from "./client.js";
|
|
12
13
|
import type { Media } from "./media.js";
|
|
13
14
|
export interface ToolkitOptions {
|
|
@@ -45,6 +46,10 @@ export interface ToolkitOptions {
|
|
|
45
46
|
/** §2 Gap 3. Bounds the MCP load: each connect/list is capped by the server timeout and the
|
|
46
47
|
* whole load aborts promptly if this signal fires. Omit ⇒ unbounded as before. */
|
|
47
48
|
signal?: AbortSignal;
|
|
49
|
+
/** §2A.1a where MCP OAuth tokens live (default: in memory, per load). */
|
|
50
|
+
tokenStore?: import("./mcp2026/auth.js").TokenStore;
|
|
51
|
+
/** §2A.1a how `auto` MCP OAuth opens the authorization URL (default: the OS opener). */
|
|
52
|
+
openUrl?: (url: string) => unknown | Promise<unknown>;
|
|
48
53
|
}
|
|
49
54
|
export declare class Toolkit {
|
|
50
55
|
private readonly mcp;
|
|
@@ -81,6 +86,20 @@ export declare class Toolkit {
|
|
|
81
86
|
mcp?: MCPServeConfig;
|
|
82
87
|
onCall?: OnCall;
|
|
83
88
|
}): Promise<ServeHandle>;
|
|
89
|
+
/**
|
|
90
|
+
* §7C.1 (ADR 0039). An embeddable streamable-HTTP MCP server as a Node request listener
|
|
91
|
+
* `(req, res) => void` (+ `notifyToolsChanged()`), to mount on the host's own server/router
|
|
92
|
+
* behind its own TLS, CORS and OAuth. Every policy is a hook in `cfg`; `serve()` is unchanged.
|
|
93
|
+
* Without `cfg.listTools` it exposes this toolkit's tools.
|
|
94
|
+
*/
|
|
95
|
+
mcpHandler(cfg?: McpHandlerConfig): McpHandler;
|
|
96
|
+
/**
|
|
97
|
+
* SPEC §8C. Serves this toolkit for a local agent to call: the §7C MCP mapping (tool names
|
|
98
|
+
* verbatim, `skill` stays `skill`, optional `config` filter) on `127.0.0.1:<ephemeral>/mcp`
|
|
99
|
+
* behind a fresh 64-hex bearer token. The caller builds whatever `mcpServers` entry its agent
|
|
100
|
+
* accepts from `{url, token}`; `close()` stops it (idempotent).
|
|
101
|
+
*/
|
|
102
|
+
asMcpServer(config?: MCPServeConfig): Promise<LocalMcpServer>;
|
|
84
103
|
execute(name: string, args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult>;
|
|
85
104
|
/** Markdown skill catalog for the system prompt. */
|
|
86
105
|
skillsPrompt(): string;
|
package/dist/toolkit.js
CHANGED
|
@@ -3,6 +3,8 @@ import { loadSkills } from "./skill.js";
|
|
|
3
3
|
import { selectBuiltins } from "./builtin.js";
|
|
4
4
|
import { agentTools, parseAgentsConfig } from "./a2a.js";
|
|
5
5
|
import { startA2AServer } from "./serve.js";
|
|
6
|
+
import { serveLocalMcp } from "./mcpserve.js";
|
|
7
|
+
import { createMcpHandler } from "./mcphandler.js";
|
|
6
8
|
import { toAnthropic, toGemini, toOpenAI } from "./adapters.js";
|
|
7
9
|
export class Toolkit {
|
|
8
10
|
mcp;
|
|
@@ -39,12 +41,13 @@ export class Toolkit {
|
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
43
|
static async create(opts = {}) {
|
|
42
|
-
const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor, signal: opts.signal }) : undefined;
|
|
44
|
+
const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor, signal: opts.signal, tokenStore: opts.tokenStore, openUrl: opts.openUrl, disableTools: opts.disableTools }) : undefined;
|
|
43
45
|
// Skills come from directories, in-memory data, and/or a lazy provider (§3,
|
|
44
46
|
// S1). The provider is resolved here (create() is async) and merged with the
|
|
45
47
|
// data list; a provider failure is isolated so other sources still load.
|
|
46
48
|
let skill;
|
|
47
|
-
|
|
49
|
+
const mcpSkills = mcp?.skills ?? [];
|
|
50
|
+
if (opts.skillsDir !== undefined || opts.skills !== undefined || opts.skillProvider !== undefined || mcpSkills.length > 0) {
|
|
48
51
|
let providerDefs = [];
|
|
49
52
|
if (opts.skillProvider) {
|
|
50
53
|
try {
|
|
@@ -55,7 +58,8 @@ export class Toolkit {
|
|
|
55
58
|
providerDefs = [];
|
|
56
59
|
}
|
|
57
60
|
}
|
|
58
|
-
|
|
61
|
+
// §2.7 remote skills (MCP skills extension) join the same inventory, after local ones.
|
|
62
|
+
const dataDefs = [...(opts.skills ?? []), ...providerDefs, ...mcpSkills];
|
|
59
63
|
// disableSkills is sugar over a skillsFilter drop-list: fold each name in as false
|
|
60
64
|
// WITHOUT overriding an explicit skillsFilter entry (explicit entries win).
|
|
61
65
|
const filter = opts.disableSkills && opts.disableSkills.length > 0
|
|
@@ -157,6 +161,24 @@ export class Toolkit {
|
|
|
157
161
|
onCall: opts.onCall,
|
|
158
162
|
});
|
|
159
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* §7C.1 (ADR 0039). An embeddable streamable-HTTP MCP server as a Node request listener
|
|
166
|
+
* `(req, res) => void` (+ `notifyToolsChanged()`), to mount on the host's own server/router
|
|
167
|
+
* behind its own TLS, CORS and OAuth. Every policy is a hook in `cfg`; `serve()` is unchanged.
|
|
168
|
+
* Without `cfg.listTools` it exposes this toolkit's tools.
|
|
169
|
+
*/
|
|
170
|
+
mcpHandler(cfg = {}) {
|
|
171
|
+
return createMcpHandler(() => this.tools(), cfg);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* SPEC §8C. Serves this toolkit for a local agent to call: the §7C MCP mapping (tool names
|
|
175
|
+
* verbatim, `skill` stays `skill`, optional `config` filter) on `127.0.0.1:<ephemeral>/mcp`
|
|
176
|
+
* behind a fresh 64-hex bearer token. The caller builds whatever `mcpServers` entry its agent
|
|
177
|
+
* accepts from `{url, token}`; `close()` stops it (idempotent).
|
|
178
|
+
*/
|
|
179
|
+
asMcpServer(config) {
|
|
180
|
+
return serveLocalMcp(this.tools(), config);
|
|
181
|
+
}
|
|
160
182
|
async execute(name, args, ctx) {
|
|
161
183
|
const tool = this.byName.get(name);
|
|
162
184
|
if (!tool) {
|