toolnexus 0.23.0 → 0.25.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 +139 -1
- package/dist/acp.d.ts +37 -6
- package/dist/acp.js +239 -52
- package/dist/agents/loop.js +4 -1
- package/dist/agents/runtime.js +4 -1
- package/dist/client.d.ts +55 -9
- package/dist/client.js +154 -41
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- 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/media.d.ts +126 -0
- package/dist/media.js +540 -0
- package/dist/skill.d.ts +21 -0
- package/dist/skill.js +44 -0
- package/dist/toolconcurrency.d.ts +16 -0
- package/dist/toolconcurrency.js +59 -0
- package/dist/toolkit.d.ts +23 -1
- package/dist/toolkit.js +26 -4
- package/dist/types.d.ts +26 -1
- package/package.json +5 -3
- package/src/acp.ts +258 -55
- package/src/agents/loop.ts +4 -1
- package/src/agents/runtime.ts +4 -1
- package/src/client.ts +196 -48
- package/src/index.ts +4 -0
- 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/media.ts +604 -0
- package/src/skill.ts +50 -0
- package/src/toolconcurrency.ts +67 -0
- package/src/toolkit.ts +35 -5
- package/src/types.ts +27 -1
|
@@ -0,0 +1,1145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP — INBOUND, embeddable: a plain streamable-HTTP MCP server as a Node request
|
|
3
|
+
* listener `(req, res) => void` with generic per-request hooks (SPEC §7C.1, ADR 0039).
|
|
4
|
+
*
|
|
5
|
+
* `serve(addr, {mcp})` (mcpserve.ts) owns a listener and exposes one static tool list.
|
|
6
|
+
* `toolkit.mcpHandler(cfg)` is what a host mounts on its own server / router, behind its
|
|
7
|
+
* own TLS, CORS and OAuth. Every policy is a hook; toolnexus has no notion of tenant,
|
|
8
|
+
* user, role or storage — the host decides those inside the hooks.
|
|
9
|
+
*
|
|
10
|
+
* The transport is hand-written JSON-RPC (mirrors golang/mcphandler.go): tools resolve per
|
|
11
|
+
* request, and a confirm elicitation rides the SAME POST's SSE response. No SDK server
|
|
12
|
+
* types appear in this public API.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
15
|
+
import { pendingOf } from "./types.js";
|
|
16
|
+
import { toolResultToMcp } from "./mcpserve.js";
|
|
17
|
+
import { compareCodePoints } from "./order.js";
|
|
18
|
+
import { decodeHeaderValue } from "./mcpheader.js";
|
|
19
|
+
/** The MCP 2026-07-28 protocol version: an opt-in stateless wire (SPEC §7C.2), never a default. */
|
|
20
|
+
export const MCP_PROTOCOL_2026 = "2026-07-28";
|
|
21
|
+
/** The SEP-2640 skills extension id advertised by `server/discover` when `listSkills` is set. */
|
|
22
|
+
export const MCP_SKILLS_EXTENSION = "io.modelcontextprotocol/skills";
|
|
23
|
+
const META_VERSION = "io.modelcontextprotocol/protocolVersion";
|
|
24
|
+
const META_CLIENT_CAPS = "io.modelcontextprotocol/clientCapabilities";
|
|
25
|
+
const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo";
|
|
26
|
+
/**
|
|
27
|
+
* The MCP protocol versions whose wire this handler implements (ADR 0039 §versions).
|
|
28
|
+
* 2026-07-28 is opt-in only (list it in `protocolVersions`): the stateless wire of SPEC §7C.2.
|
|
29
|
+
*/
|
|
30
|
+
export const MCP_HANDLER_IMPLEMENTED_VERSIONS = [MCP_PROTOCOL_2026, "2025-11-25", "2025-06-18"];
|
|
31
|
+
const DEFAULT_VERSIONS = ["2025-06-18"];
|
|
32
|
+
const DEFAULT_NAME = "toolnexus";
|
|
33
|
+
const DEFAULT_VERSION = "0.1.0";
|
|
34
|
+
/**
|
|
35
|
+
* An `authenticate` failure with an explicit HTTP status and challenge (`McpServerAuth` throws
|
|
36
|
+
* it; any hook may). On any wire the handler answers `status` (default 401) with
|
|
37
|
+
* `WWW-Authenticate` = `challenge` (a non-empty `wwwAuthenticate` result wins) and the body
|
|
38
|
+
* `{"error":"unauthorized"}`. `reason` is never sent on the wire.
|
|
39
|
+
*/
|
|
40
|
+
export class McpAuthError extends Error {
|
|
41
|
+
status;
|
|
42
|
+
challenge;
|
|
43
|
+
reason;
|
|
44
|
+
constructor(status, challenge, reason = "") {
|
|
45
|
+
super(reason || "unauthorized");
|
|
46
|
+
this.name = "McpAuthError";
|
|
47
|
+
this.status = status;
|
|
48
|
+
this.challenge = challenge;
|
|
49
|
+
this.reason = reason;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function mcpOf(ctx) {
|
|
53
|
+
if (!ctx)
|
|
54
|
+
return undefined;
|
|
55
|
+
if (ctx.mcp)
|
|
56
|
+
return ctx.mcp;
|
|
57
|
+
if (ctx.request && "sessionId" in ctx)
|
|
58
|
+
return ctx;
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
/** The value `authenticate` produced for this request (hook ctx or tool ctx). */
|
|
62
|
+
export const identityFrom = (ctx) => mcpOf(ctx)?.identity;
|
|
63
|
+
/** The inbound IncomingMessage being served. */
|
|
64
|
+
export const requestFrom = (ctx) => mcpOf(ctx)?.request;
|
|
65
|
+
/** The request's Mcp-Session-Id ("" if none). */
|
|
66
|
+
export const mcpSessionIdFrom = (ctx) => mcpOf(ctx)?.sessionId ?? "";
|
|
67
|
+
/** The JSON-RPC request's `params._meta` (undefined when absent). */
|
|
68
|
+
export const mcpRequestMetaFrom = (ctx) => mcpOf(ctx)?.meta;
|
|
69
|
+
const rpcErr = (code, message) => ({ error: { code, message } });
|
|
70
|
+
const errMsg = (e) => (e instanceof Error ? e.message : String(e));
|
|
71
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
72
|
+
function omitEmpty(m) {
|
|
73
|
+
for (const k of Object.keys(m))
|
|
74
|
+
if (m[k] === "" || m[k] === undefined)
|
|
75
|
+
delete m[k];
|
|
76
|
+
return m;
|
|
77
|
+
}
|
|
78
|
+
function httpError(res, status, text, headers = {}) {
|
|
79
|
+
res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8", "X-Content-Type-Options": "nosniff", ...headers });
|
|
80
|
+
res.end(text + "\n");
|
|
81
|
+
}
|
|
82
|
+
function writeAuthFailure(cfg, req, res, err) {
|
|
83
|
+
let challenge = cfg.wwwAuthenticate?.(req, err) ?? "";
|
|
84
|
+
if (err instanceof McpAuthError) {
|
|
85
|
+
if (!challenge)
|
|
86
|
+
challenge = err.challenge;
|
|
87
|
+
const h = { "Content-Type": "application/json" };
|
|
88
|
+
if (challenge)
|
|
89
|
+
h["WWW-Authenticate"] = challenge;
|
|
90
|
+
res.writeHead(err.status || 401, h);
|
|
91
|
+
res.end('{"error":"unauthorized"}');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
httpError(res, 401, "Unauthorized", challenge ? { "WWW-Authenticate": challenge } : {});
|
|
95
|
+
}
|
|
96
|
+
function writeRpcError(res, id, code, message) {
|
|
97
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
98
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", id: id ?? null, error: { code, message } }) + "\n");
|
|
99
|
+
}
|
|
100
|
+
/** The tools/list entry: name, description, inputSchema, outputSchema?, annotations?. */
|
|
101
|
+
export function mcpWireTool(t) {
|
|
102
|
+
const m = {
|
|
103
|
+
name: t.name,
|
|
104
|
+
description: t.description,
|
|
105
|
+
inputSchema: t.inputSchema ?? { type: "object" },
|
|
106
|
+
};
|
|
107
|
+
if (t.outputSchema)
|
|
108
|
+
m.outputSchema = t.outputSchema;
|
|
109
|
+
const ann = {};
|
|
110
|
+
const a = t.annotations;
|
|
111
|
+
if (a) {
|
|
112
|
+
if (a.title)
|
|
113
|
+
ann.title = a.title;
|
|
114
|
+
for (const k of ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"]) {
|
|
115
|
+
if (typeof a[k] === "boolean")
|
|
116
|
+
ann[k] = a[k];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else if (t.hints) {
|
|
120
|
+
ann.readOnlyHint = t.hints.readOnly;
|
|
121
|
+
ann.destructiveHint = t.hints.destructive;
|
|
122
|
+
ann.openWorldHint = t.hints.openWorld;
|
|
123
|
+
}
|
|
124
|
+
if (Object.keys(ann).length > 0)
|
|
125
|
+
m.annotations = ann;
|
|
126
|
+
return m;
|
|
127
|
+
}
|
|
128
|
+
function memorySessionIds() {
|
|
129
|
+
const live = new Set();
|
|
130
|
+
return {
|
|
131
|
+
generate() {
|
|
132
|
+
const id = randomBytes(16).toString("hex");
|
|
133
|
+
live.add(id);
|
|
134
|
+
return id;
|
|
135
|
+
},
|
|
136
|
+
validate: (_ctx, id) => live.has(id),
|
|
137
|
+
terminate(_ctx, id) {
|
|
138
|
+
live.delete(id);
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** Answers one POST: plain JSON, or SSE once a server→client request was sent first. */
|
|
143
|
+
class RpcWriter {
|
|
144
|
+
res;
|
|
145
|
+
sse = false;
|
|
146
|
+
constructor(res) {
|
|
147
|
+
this.res = res;
|
|
148
|
+
}
|
|
149
|
+
event(v) {
|
|
150
|
+
if (!this.sse) {
|
|
151
|
+
this.res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
|
|
152
|
+
this.sse = true;
|
|
153
|
+
}
|
|
154
|
+
this.res.write(`event: message\ndata: ${JSON.stringify(v)}\n\n`);
|
|
155
|
+
}
|
|
156
|
+
respond(id, out) {
|
|
157
|
+
const msg = { jsonrpc: "2.0", id };
|
|
158
|
+
if (out.error)
|
|
159
|
+
msg.error = out.error;
|
|
160
|
+
else
|
|
161
|
+
msg.result = out.result;
|
|
162
|
+
if (this.sse) {
|
|
163
|
+
this.event(msg);
|
|
164
|
+
this.res.end();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
this.res.writeHead(200, { "Content-Type": "application/json" });
|
|
168
|
+
this.res.end(JSON.stringify(msg) + "\n");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Builds the handler (`toolkit.mcpHandler(cfg)` calls this with the toolkit's tools). */
|
|
172
|
+
export function createMcpHandler(toolkitTools, cfg = {}) {
|
|
173
|
+
const versions = [];
|
|
174
|
+
for (const v of cfg.protocolVersions ?? []) {
|
|
175
|
+
if (MCP_HANDLER_IMPLEMENTED_VERSIONS.includes(v) && !versions.includes(v))
|
|
176
|
+
versions.push(v);
|
|
177
|
+
}
|
|
178
|
+
if (versions.length === 0)
|
|
179
|
+
versions.push(...DEFAULT_VERSIONS);
|
|
180
|
+
/** The initialize/session wire: every accepted version but 2026-07-28. */
|
|
181
|
+
const legacy = versions.filter((v) => v !== MCP_PROTOCOL_2026);
|
|
182
|
+
const has2026 = versions.includes(MCP_PROTOCOL_2026);
|
|
183
|
+
const only2026 = has2026 && legacy.length === 0;
|
|
184
|
+
const ids = cfg.sessionIds ?? memorySessionIds();
|
|
185
|
+
const confirmTimeout = cfg.confirmTimeout && cfg.confirmTimeout > 0 ? cfg.confirmTimeout : 5 * 60_000;
|
|
186
|
+
const maxBody = cfg.maxBodyBytes && cfg.maxBodyBytes > 0 ? cfg.maxBodyBytes : 4 << 20;
|
|
187
|
+
const caps = new Map();
|
|
188
|
+
const streams = new Set();
|
|
189
|
+
const pending = new Map();
|
|
190
|
+
let seq = 0;
|
|
191
|
+
const header = (req, name) => {
|
|
192
|
+
const v = req.headers[name.toLowerCase()];
|
|
193
|
+
return Array.isArray(v) ? (v[0] ?? "") : (v ?? "");
|
|
194
|
+
};
|
|
195
|
+
const tools = async (ctx) => (cfg.listTools ? await cfg.listTools(ctx) : toolkitTools());
|
|
196
|
+
/** Validates protocol header + session (stateful). Writes the HTTP error itself on failure. */
|
|
197
|
+
async function checkSession(ctx, req, res) {
|
|
198
|
+
const v = header(req, "MCP-Protocol-Version");
|
|
199
|
+
if (v !== "" && !legacy.includes(v)) {
|
|
200
|
+
httpError(res, 400, "Bad Request: unsupported MCP-Protocol-Version");
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
if (cfg.stateless)
|
|
204
|
+
return true;
|
|
205
|
+
const sid = header(req, "Mcp-Session-Id");
|
|
206
|
+
if (sid === "") {
|
|
207
|
+
httpError(res, 400, "Bad Request: missing Mcp-Session-Id");
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
let ok = false;
|
|
211
|
+
try {
|
|
212
|
+
ok = await ids.validate(ctx, sid);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
ok = false;
|
|
216
|
+
}
|
|
217
|
+
if (!ok) {
|
|
218
|
+
httpError(res, 404, "Session not found");
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
ctx.sessionId = sid;
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
function readBody(req) {
|
|
225
|
+
return new Promise((resolve) => {
|
|
226
|
+
const chunks = [];
|
|
227
|
+
let size = 0;
|
|
228
|
+
let tooLarge = false;
|
|
229
|
+
req.on("data", (c) => {
|
|
230
|
+
size += c.length;
|
|
231
|
+
if (size > maxBody)
|
|
232
|
+
tooLarge = true;
|
|
233
|
+
else
|
|
234
|
+
chunks.push(c);
|
|
235
|
+
});
|
|
236
|
+
req.on("end", () => resolve(tooLarge ? { tooLarge } : { body: Buffer.concat(chunks).toString("utf8") }));
|
|
237
|
+
req.on("error", () => resolve({ error: true }));
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
const isJsonPost = (req) => header(req, "Content-Type").split(";")[0].trim().toLowerCase() === "application/json";
|
|
241
|
+
async function handlePost(ctx, req, res, pre) {
|
|
242
|
+
if (!isJsonPost(req)) {
|
|
243
|
+
httpError(res, 415, "Unsupported Media Type: must be application/json");
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const rb = pre ?? (await readBody(req));
|
|
247
|
+
if (rb.error)
|
|
248
|
+
return writeRpcError(res, null, -32700, "read error");
|
|
249
|
+
if (rb.tooLarge)
|
|
250
|
+
return httpError(res, 413, "Request Entity Too Large");
|
|
251
|
+
const body = rb.body;
|
|
252
|
+
if (body.trim().startsWith("[")) {
|
|
253
|
+
// JSON-RPC batching was removed in protocol 2025-06-18.
|
|
254
|
+
return writeRpcError(res, null, -32600, "batch requests are not supported");
|
|
255
|
+
}
|
|
256
|
+
let msg;
|
|
257
|
+
try {
|
|
258
|
+
msg = JSON.parse(body);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return writeRpcError(res, null, -32700, "parse error");
|
|
262
|
+
}
|
|
263
|
+
if (!isObj(msg) || msg.jsonrpc !== "2.0" || (msg.method !== undefined && typeof msg.method !== "string")) {
|
|
264
|
+
return writeRpcError(res, null, -32700, "parse error");
|
|
265
|
+
}
|
|
266
|
+
const method = msg.method ?? "";
|
|
267
|
+
const hasId = "id" in msg;
|
|
268
|
+
if (method === "initialize")
|
|
269
|
+
return handleInitialize(ctx, res, msg);
|
|
270
|
+
if (!(await checkSession(ctx, req, res)))
|
|
271
|
+
return;
|
|
272
|
+
if (method === "") {
|
|
273
|
+
// A client response (the answer to our elicitation).
|
|
274
|
+
if (hasId && typeof msg.id === "string")
|
|
275
|
+
pending.get(ctx.sessionId + "\0" + msg.id)?.(msg);
|
|
276
|
+
res.writeHead(202).end();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (!hasId || msg.id === null) {
|
|
280
|
+
res.writeHead(202).end(); // a notification
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (isObj(msg.params) && isObj(msg.params._meta))
|
|
284
|
+
ctx.meta = msg.params._meta;
|
|
285
|
+
const rw = new RpcWriter(res);
|
|
286
|
+
let out;
|
|
287
|
+
try {
|
|
288
|
+
out = await dispatch(ctx, rw, method, msg.params);
|
|
289
|
+
}
|
|
290
|
+
catch (e) {
|
|
291
|
+
out = rpcErr(-32603, errMsg(e));
|
|
292
|
+
}
|
|
293
|
+
rw.respond(msg.id, out);
|
|
294
|
+
}
|
|
295
|
+
async function handleInitialize(ctx, res, msg) {
|
|
296
|
+
const p = isObj(msg.params) ? msg.params : {};
|
|
297
|
+
const version = typeof p.protocolVersion === "string" && legacy.includes(p.protocolVersion) ? p.protocolVersion : legacy[0];
|
|
298
|
+
let sid = "";
|
|
299
|
+
if (cfg.stateless) {
|
|
300
|
+
sid = cfg.statelessSessionId ?? "";
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
try {
|
|
304
|
+
sid = await ids.generate(ctx);
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return writeRpcError(res, msg.id, -32603, "cannot create session");
|
|
308
|
+
}
|
|
309
|
+
ctx.sessionId = sid;
|
|
310
|
+
const el = isObj(p.capabilities) ? p.capabilities.elicitation : undefined;
|
|
311
|
+
caps.set(sid, el !== undefined && el !== null);
|
|
312
|
+
}
|
|
313
|
+
const info = cfg.serverInfo
|
|
314
|
+
? await cfg.serverInfo(ctx)
|
|
315
|
+
: { name: cfg.name, title: cfg.title, version: cfg.version, instructions: cfg.instructions };
|
|
316
|
+
const capabilities = { tools: { listChanged: true } };
|
|
317
|
+
if (cfg.listResources)
|
|
318
|
+
capabilities.resources = {};
|
|
319
|
+
if (cfg.listPrompts)
|
|
320
|
+
capabilities.prompts = {};
|
|
321
|
+
const result = {
|
|
322
|
+
protocolVersion: version,
|
|
323
|
+
capabilities,
|
|
324
|
+
serverInfo: omitEmpty({ name: info.name || DEFAULT_NAME, title: info.title, version: info.version || DEFAULT_VERSION }),
|
|
325
|
+
};
|
|
326
|
+
if (info.instructions)
|
|
327
|
+
result.instructions = info.instructions;
|
|
328
|
+
if (sid !== "")
|
|
329
|
+
res.setHeader("Mcp-Session-Id", sid);
|
|
330
|
+
new RpcWriter(res).respond(msg.id, { result });
|
|
331
|
+
}
|
|
332
|
+
async function handleGet(ctx, req, res) {
|
|
333
|
+
if (!header(req, "Accept").includes("text/event-stream"))
|
|
334
|
+
return httpError(res, 405, "Method Not Allowed");
|
|
335
|
+
if (!(await checkSession(ctx, req, res)))
|
|
336
|
+
return;
|
|
337
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
|
|
338
|
+
res.flushHeaders();
|
|
339
|
+
streams.add(res);
|
|
340
|
+
res.on("close", () => streams.delete(res));
|
|
341
|
+
}
|
|
342
|
+
async function handleDelete(ctx, req, res) {
|
|
343
|
+
if (cfg.stateless)
|
|
344
|
+
return httpError(res, 405, "Method Not Allowed");
|
|
345
|
+
if (!(await checkSession(ctx, req, res)))
|
|
346
|
+
return;
|
|
347
|
+
try {
|
|
348
|
+
await ids.terminate(ctx, ctx.sessionId);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return httpError(res, 500, "cannot terminate session");
|
|
352
|
+
}
|
|
353
|
+
caps.delete(ctx.sessionId);
|
|
354
|
+
res.writeHead(200).end();
|
|
355
|
+
}
|
|
356
|
+
async function dispatch(ctx, rw, method, params) {
|
|
357
|
+
switch (method) {
|
|
358
|
+
case "ping":
|
|
359
|
+
return { result: {} };
|
|
360
|
+
case "tools/list": {
|
|
361
|
+
let ts;
|
|
362
|
+
try {
|
|
363
|
+
ts = await tools(ctx);
|
|
364
|
+
}
|
|
365
|
+
catch (e) {
|
|
366
|
+
return rpcErr(-32603, errMsg(e));
|
|
367
|
+
}
|
|
368
|
+
return { result: { tools: (ts ?? []).map(mcpWireTool) } };
|
|
369
|
+
}
|
|
370
|
+
case "tools/call":
|
|
371
|
+
return callTool(ctx, rw, params);
|
|
372
|
+
case "resources/list": {
|
|
373
|
+
if (!cfg.listResources)
|
|
374
|
+
break;
|
|
375
|
+
let rs;
|
|
376
|
+
try {
|
|
377
|
+
rs = await cfg.listResources(ctx);
|
|
378
|
+
}
|
|
379
|
+
catch (e) {
|
|
380
|
+
return rpcErr(-32603, errMsg(e));
|
|
381
|
+
}
|
|
382
|
+
return {
|
|
383
|
+
result: {
|
|
384
|
+
resources: (rs ?? []).map((r) => omitEmpty({ uri: r.uri, name: r.name, title: r.title, description: r.description, mimeType: r.mimeType })),
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
case "resources/read": {
|
|
389
|
+
if (!cfg.listResources || !cfg.readResource)
|
|
390
|
+
break;
|
|
391
|
+
const uri = isObj(params) && typeof params.uri === "string" ? params.uri : "";
|
|
392
|
+
let cs;
|
|
393
|
+
try {
|
|
394
|
+
cs = await cfg.readResource(ctx, uri);
|
|
395
|
+
}
|
|
396
|
+
catch (e) {
|
|
397
|
+
return rpcErr(-32002, errMsg(e));
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
result: {
|
|
401
|
+
contents: (cs ?? []).map((c) => {
|
|
402
|
+
const m = omitEmpty({ uri: c.uri, mimeType: c.mimeType, blob: c.blob });
|
|
403
|
+
if (!c.blob)
|
|
404
|
+
m.text = c.text ?? "";
|
|
405
|
+
return m;
|
|
406
|
+
}),
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
case "prompts/list": {
|
|
411
|
+
if (!cfg.listPrompts)
|
|
412
|
+
break;
|
|
413
|
+
let ps;
|
|
414
|
+
try {
|
|
415
|
+
ps = await cfg.listPrompts(ctx);
|
|
416
|
+
}
|
|
417
|
+
catch (e) {
|
|
418
|
+
return rpcErr(-32603, errMsg(e));
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
result: {
|
|
422
|
+
prompts: (ps ?? []).map((p) => {
|
|
423
|
+
const m = omitEmpty({ name: p.name, title: p.title, description: p.description });
|
|
424
|
+
if (p.arguments?.length) {
|
|
425
|
+
m.arguments = p.arguments.map((a) => {
|
|
426
|
+
const am = omitEmpty({ name: a.name, description: a.description });
|
|
427
|
+
if (a.required)
|
|
428
|
+
am.required = true;
|
|
429
|
+
return am;
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
return m;
|
|
433
|
+
}),
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
case "prompts/get": {
|
|
438
|
+
if (!cfg.listPrompts || !cfg.getPrompt)
|
|
439
|
+
break;
|
|
440
|
+
if (!isObj(params))
|
|
441
|
+
return rpcErr(-32602, "invalid params");
|
|
442
|
+
const { name, arguments: args } = params;
|
|
443
|
+
const badArgs = args !== undefined && args !== null && (!isObj(args) || Object.values(args).some((v) => typeof v !== "string"));
|
|
444
|
+
if ((name !== undefined && name !== null && typeof name !== "string") || badArgs) {
|
|
445
|
+
return rpcErr(-32602, "invalid params");
|
|
446
|
+
}
|
|
447
|
+
let pr;
|
|
448
|
+
try {
|
|
449
|
+
pr = await cfg.getPrompt(ctx, name ?? "", args ?? {});
|
|
450
|
+
}
|
|
451
|
+
catch (e) {
|
|
452
|
+
return rpcErr(-32602, errMsg(e));
|
|
453
|
+
}
|
|
454
|
+
return {
|
|
455
|
+
result: omitEmpty({
|
|
456
|
+
description: pr.description,
|
|
457
|
+
messages: (pr.messages ?? []).map((m) => ({ role: m.role, content: { type: "text", text: m.text } })),
|
|
458
|
+
}),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return rpcErr(-32601, "method not found: " + method);
|
|
463
|
+
}
|
|
464
|
+
async function callTool(ctx, rw, params) {
|
|
465
|
+
if (!isObj(params) || (params.name !== undefined && params.name !== null && typeof params.name !== "string")) {
|
|
466
|
+
return rpcErr(-32602, "invalid params");
|
|
467
|
+
}
|
|
468
|
+
const name = params.name ?? "";
|
|
469
|
+
let ts;
|
|
470
|
+
try {
|
|
471
|
+
ts = await tools(ctx);
|
|
472
|
+
}
|
|
473
|
+
catch (e) {
|
|
474
|
+
return rpcErr(-32603, errMsg(e));
|
|
475
|
+
}
|
|
476
|
+
const tool = (ts ?? []).find((t) => t.name === name);
|
|
477
|
+
if (!tool || typeof tool.execute !== "function")
|
|
478
|
+
return rpcErr(-32602, "tool not found: " + name);
|
|
479
|
+
let args = {};
|
|
480
|
+
const raw = params.arguments;
|
|
481
|
+
if (raw !== undefined && raw !== null) {
|
|
482
|
+
if (!isObj(raw)) {
|
|
483
|
+
if (header(ctx.request, "MCP-Protocol-Version") === "2025-11-25") {
|
|
484
|
+
// 2025-11-25: input validation errors are tool execution errors.
|
|
485
|
+
return { result: toolResultToMcp({ output: "invalid arguments: must be an object", isError: true }, true) };
|
|
486
|
+
}
|
|
487
|
+
return rpcErr(-32602, "arguments must be an object");
|
|
488
|
+
}
|
|
489
|
+
args = raw;
|
|
490
|
+
}
|
|
491
|
+
const started = Date.now();
|
|
492
|
+
const ev = {
|
|
493
|
+
name: tool.name,
|
|
494
|
+
source: tool.source,
|
|
495
|
+
ms: 0,
|
|
496
|
+
isError: false,
|
|
497
|
+
resultBytes: 0,
|
|
498
|
+
decision: "allow",
|
|
499
|
+
identity: ctx.identity,
|
|
500
|
+
sessionId: ctx.sessionId,
|
|
501
|
+
};
|
|
502
|
+
const finish = (res, execErr) => {
|
|
503
|
+
const wire = toolResultToMcp(res, true);
|
|
504
|
+
ev.ms = Date.now() - started;
|
|
505
|
+
ev.isError = !!res.isError;
|
|
506
|
+
if (execErr !== undefined)
|
|
507
|
+
ev.err = execErr;
|
|
508
|
+
ev.resultBytes = Buffer.byteLength(JSON.stringify(wire));
|
|
509
|
+
if (cfg.onCall) {
|
|
510
|
+
try {
|
|
511
|
+
cfg.onCall(ev);
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
// host callback throws are isolated
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return { result: wire };
|
|
518
|
+
};
|
|
519
|
+
if (cfg.beforeCall) {
|
|
520
|
+
let d;
|
|
521
|
+
try {
|
|
522
|
+
d = (await cfg.beforeCall(ctx, tool, args)) ?? {};
|
|
523
|
+
}
|
|
524
|
+
catch (e) {
|
|
525
|
+
d = { kind: "deny", message: errMsg(e) };
|
|
526
|
+
}
|
|
527
|
+
const kind = d.kind ?? "allow";
|
|
528
|
+
ev.decision = kind;
|
|
529
|
+
if (kind === "deny") {
|
|
530
|
+
if (d.result)
|
|
531
|
+
return finish(d.result);
|
|
532
|
+
return finish({ output: d.message || "denied", isError: true });
|
|
533
|
+
}
|
|
534
|
+
if (kind === "confirm") {
|
|
535
|
+
if (!(ctx.sessionId !== "" && caps.get(ctx.sessionId))) {
|
|
536
|
+
if (d.result)
|
|
537
|
+
return finish(d.result);
|
|
538
|
+
return finish({ output: "confirmation required: " + (d.message ?? ""), isError: true });
|
|
539
|
+
}
|
|
540
|
+
let accepted;
|
|
541
|
+
try {
|
|
542
|
+
accepted = await elicitConfirm(ctx, rw, d.message ?? "");
|
|
543
|
+
}
|
|
544
|
+
catch (e) {
|
|
545
|
+
return finish({ output: "confirmation failed: " + errMsg(e), isError: true });
|
|
546
|
+
}
|
|
547
|
+
if (!accepted)
|
|
548
|
+
return finish({ output: "declined", isError: true });
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
let res;
|
|
552
|
+
let execErr;
|
|
553
|
+
try {
|
|
554
|
+
res = await tool.execute(args, { signal: ctx.signal, mcp: ctx });
|
|
555
|
+
}
|
|
556
|
+
catch (e) {
|
|
557
|
+
execErr = e;
|
|
558
|
+
res = { output: errMsg(e), isError: true };
|
|
559
|
+
}
|
|
560
|
+
return finish(res, execErr);
|
|
561
|
+
}
|
|
562
|
+
/** Sends elicitation/create on this POST's SSE stream; the answer arrives via a later POST. */
|
|
563
|
+
function elicitConfirm(ctx, rw, message) {
|
|
564
|
+
const id = `toolnexus-confirm-${++seq}`;
|
|
565
|
+
const key = ctx.sessionId + "\0" + id;
|
|
566
|
+
if (!message)
|
|
567
|
+
message = "Confirm this action?";
|
|
568
|
+
return new Promise((resolve, reject) => {
|
|
569
|
+
const cleanup = () => {
|
|
570
|
+
pending.delete(key);
|
|
571
|
+
clearTimeout(timer);
|
|
572
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
573
|
+
};
|
|
574
|
+
const onAbort = () => {
|
|
575
|
+
cleanup();
|
|
576
|
+
reject(new Error("context canceled"));
|
|
577
|
+
};
|
|
578
|
+
const timer = setTimeout(() => {
|
|
579
|
+
cleanup();
|
|
580
|
+
reject(new Error("context deadline exceeded"));
|
|
581
|
+
}, confirmTimeout);
|
|
582
|
+
ctx.signal.addEventListener("abort", onAbort);
|
|
583
|
+
pending.set(key, (m) => {
|
|
584
|
+
cleanup();
|
|
585
|
+
if (m.error !== undefined && m.error !== null)
|
|
586
|
+
return reject(new Error("client returned an error"));
|
|
587
|
+
if (m.result === undefined)
|
|
588
|
+
return reject(new Error("unexpected end of JSON input"));
|
|
589
|
+
const r = isObj(m.result) ? m.result : {};
|
|
590
|
+
if (r.action !== "accept")
|
|
591
|
+
return resolve(false);
|
|
592
|
+
if (isObj(r.content) && r.content.confirm === false)
|
|
593
|
+
return resolve(false);
|
|
594
|
+
resolve(true);
|
|
595
|
+
});
|
|
596
|
+
try {
|
|
597
|
+
rw.event({
|
|
598
|
+
jsonrpc: "2.0",
|
|
599
|
+
id,
|
|
600
|
+
method: "elicitation/create",
|
|
601
|
+
params: {
|
|
602
|
+
message,
|
|
603
|
+
requestedSchema: {
|
|
604
|
+
type: "object",
|
|
605
|
+
properties: { confirm: { type: "boolean", title: "Confirm", description: message } },
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
catch (e) {
|
|
611
|
+
cleanup();
|
|
612
|
+
reject(e);
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
// ---- MCP 2026-07-28 (SPEC §7C.2, ADR 0040): the stateless wire, opt-in ----
|
|
617
|
+
/** Routes and answers a POST on the 2026 profile; false ⇒ the legacy wire serves it (body pre-read). */
|
|
618
|
+
async function tryPost2026(ctx, req, res, rb) {
|
|
619
|
+
if (rb.error || rb.tooLarge)
|
|
620
|
+
return false; // the legacy path answers identically
|
|
621
|
+
const body = rb.body;
|
|
622
|
+
const trimmed = body.trim();
|
|
623
|
+
let msg;
|
|
624
|
+
let bad = trimmed.startsWith("[");
|
|
625
|
+
if (!bad) {
|
|
626
|
+
try {
|
|
627
|
+
msg = JSON.parse(body);
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
bad = true;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (!bad && (!isObj(msg) || msg.jsonrpc !== "2.0" || (msg.method !== undefined && msg.method !== null && typeof msg.method !== "string")))
|
|
634
|
+
bad = true;
|
|
635
|
+
if (bad) {
|
|
636
|
+
if (!only2026)
|
|
637
|
+
return false;
|
|
638
|
+
if (trimmed.startsWith("["))
|
|
639
|
+
write26(res, 400, null, undefined, { code: -32600, message: "batch requests are not supported" });
|
|
640
|
+
else
|
|
641
|
+
write26(res, 400, null, undefined, { code: -32700, message: "Parse error" });
|
|
642
|
+
return true;
|
|
643
|
+
}
|
|
644
|
+
const method = msg.method ?? "";
|
|
645
|
+
const params = isObj(msg.params) ? msg.params : undefined;
|
|
646
|
+
const meta = params && isObj(params._meta) ? params._meta : undefined;
|
|
647
|
+
if (!route2026(req, method, meta))
|
|
648
|
+
return false;
|
|
649
|
+
await serve2026(ctx, req, res, msg, method, params ?? {}, meta);
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
652
|
+
function route2026(req, method, meta) {
|
|
653
|
+
if (only2026)
|
|
654
|
+
return true;
|
|
655
|
+
if (method === "initialize")
|
|
656
|
+
return false;
|
|
657
|
+
const hv = header(req, "MCP-Protocol-Version");
|
|
658
|
+
if (hv === MCP_PROTOCOL_2026)
|
|
659
|
+
return true;
|
|
660
|
+
if (hv !== "" && versions.includes(hv))
|
|
661
|
+
return false;
|
|
662
|
+
return method === "server/discover" || header(req, "Mcp-Method") !== "" || (meta !== undefined && META_VERSION in meta);
|
|
663
|
+
}
|
|
664
|
+
async function serve2026(ctx, req, res, msg, method, params, meta) {
|
|
665
|
+
if (method === "" || !("id" in msg) || msg.id === null) {
|
|
666
|
+
res.writeHead(202).end(); // a notification (or a stray response): acknowledged
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
const v = validate2026(req, method, params, meta);
|
|
670
|
+
if (v)
|
|
671
|
+
return write26(res, 400, msg.id, undefined, v);
|
|
672
|
+
if (cfg.authenticate) {
|
|
673
|
+
try {
|
|
674
|
+
ctx.identity = await cfg.authenticate(req);
|
|
675
|
+
}
|
|
676
|
+
catch (err) {
|
|
677
|
+
return writeAuthFailure(cfg, req, res, err);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (meta)
|
|
681
|
+
ctx.meta = meta;
|
|
682
|
+
let out;
|
|
683
|
+
try {
|
|
684
|
+
out = await dispatch2026(ctx, method, params, meta);
|
|
685
|
+
}
|
|
686
|
+
catch (e) {
|
|
687
|
+
out = { error: { code: -32603, message: errMsg(e) } };
|
|
688
|
+
}
|
|
689
|
+
write26(res, out.status ?? 200, msg.id, out.result, out.error);
|
|
690
|
+
}
|
|
691
|
+
function validate2026(req, method, p, meta) {
|
|
692
|
+
const mismatch = (m) => ({ code: -32020, message: "Header mismatch: " + m });
|
|
693
|
+
const hv = header(req, "MCP-Protocol-Version");
|
|
694
|
+
if (hv === "")
|
|
695
|
+
return mismatch("MCP-Protocol-Version header missing");
|
|
696
|
+
const hm = header(req, "Mcp-Method");
|
|
697
|
+
if (hm !== method)
|
|
698
|
+
return mismatch(`Mcp-Method header value '${hm}' does not match body value '${method}'`);
|
|
699
|
+
if (method === "tools/call" || method === "prompts/get" || method === "resources/read") {
|
|
700
|
+
const want = typeof p[method === "resources/read" ? "uri" : "name"] === "string" ? p[method === "resources/read" ? "uri" : "name"] : "";
|
|
701
|
+
const [got, ok] = decodeHeaderValue(header(req, "Mcp-Name"));
|
|
702
|
+
if (!ok || got !== want)
|
|
703
|
+
return mismatch(`Mcp-Name header value '${got}' does not match body value '${want}'`);
|
|
704
|
+
}
|
|
705
|
+
const mv = meta && typeof meta[META_VERSION] === "string" ? meta[META_VERSION] : "";
|
|
706
|
+
if (mv !== hv)
|
|
707
|
+
return mismatch(`MCP-Protocol-Version header value '${hv}' does not match _meta value '${mv}'`);
|
|
708
|
+
if (mv !== MCP_PROTOCOL_2026) {
|
|
709
|
+
return { code: -32022, message: "Unsupported protocol version", data: { supported: [MCP_PROTOCOL_2026], requested: mv } };
|
|
710
|
+
}
|
|
711
|
+
return undefined;
|
|
712
|
+
}
|
|
713
|
+
async function info2026(ctx) {
|
|
714
|
+
const info = cfg.serverInfo
|
|
715
|
+
? { ...(await cfg.serverInfo(ctx)) }
|
|
716
|
+
: { name: cfg.name, title: cfg.title, version: cfg.version, instructions: cfg.instructions };
|
|
717
|
+
if (!info.name)
|
|
718
|
+
info.name = DEFAULT_NAME;
|
|
719
|
+
if (!info.version)
|
|
720
|
+
info.version = DEFAULT_VERSION;
|
|
721
|
+
return info;
|
|
722
|
+
}
|
|
723
|
+
/** Stamps resultType and _meta.serverInfo (set last, after any ToolResult.meta keys). */
|
|
724
|
+
async function complete26(ctx, m, resultType = "complete") {
|
|
725
|
+
const info = await info2026(ctx);
|
|
726
|
+
const meta = isObj(m._meta) ? { ...m._meta } : {};
|
|
727
|
+
delete meta[META_SERVER_INFO];
|
|
728
|
+
meta[META_SERVER_INFO] = omitEmpty({ name: info.name, title: info.title, version: info.version });
|
|
729
|
+
m._meta = meta;
|
|
730
|
+
m.resultType = resultType;
|
|
731
|
+
return { result: m };
|
|
732
|
+
}
|
|
733
|
+
const cacheScope26 = () => (cfg.authenticate ? "private" : "public");
|
|
734
|
+
async function skills26(ctx) {
|
|
735
|
+
const ss = (await cfg.listSkills(ctx)) ?? [];
|
|
736
|
+
return [...ss].sort((a, b) => compareCodePoints(a.name, b.name));
|
|
737
|
+
}
|
|
738
|
+
async function dispatch2026(ctx, method, p, meta) {
|
|
739
|
+
const internal = (e) => ({ error: { code: -32603, message: errMsg(e) } });
|
|
740
|
+
const invalid = (m) => ({ error: { code: -32602, message: m } });
|
|
741
|
+
switch (method) {
|
|
742
|
+
case "server/discover": {
|
|
743
|
+
const info = await info2026(ctx);
|
|
744
|
+
const caps = { tools: {} };
|
|
745
|
+
if (cfg.listResources || cfg.listSkills)
|
|
746
|
+
caps.resources = {};
|
|
747
|
+
if (cfg.listPrompts)
|
|
748
|
+
caps.prompts = {};
|
|
749
|
+
if (cfg.listSkills)
|
|
750
|
+
caps.extensions = { [MCP_SKILLS_EXTENSION]: {} };
|
|
751
|
+
const r = { supportedVersions: [MCP_PROTOCOL_2026], capabilities: caps, ttlMs: 3600000, cacheScope: "public" };
|
|
752
|
+
if (info.instructions)
|
|
753
|
+
r.instructions = info.instructions;
|
|
754
|
+
return complete26(ctx, r);
|
|
755
|
+
}
|
|
756
|
+
case "ping":
|
|
757
|
+
return complete26(ctx, {});
|
|
758
|
+
case "tools/list": {
|
|
759
|
+
let ts;
|
|
760
|
+
try {
|
|
761
|
+
ts = (await tools(ctx)) ?? [];
|
|
762
|
+
}
|
|
763
|
+
catch (e) {
|
|
764
|
+
return internal(e);
|
|
765
|
+
}
|
|
766
|
+
const sorted = [...ts].sort((a, b) => compareCodePoints(a.name, b.name));
|
|
767
|
+
return complete26(ctx, { tools: sorted.map(mcpWireTool), ttlMs: 60000, cacheScope: cacheScope26() });
|
|
768
|
+
}
|
|
769
|
+
case "tools/call":
|
|
770
|
+
return callTool2026(ctx, p, meta);
|
|
771
|
+
case "skills/list": {
|
|
772
|
+
if (!cfg.listSkills)
|
|
773
|
+
break;
|
|
774
|
+
let ss;
|
|
775
|
+
try {
|
|
776
|
+
ss = await skills26(ctx);
|
|
777
|
+
}
|
|
778
|
+
catch (e) {
|
|
779
|
+
return internal(e);
|
|
780
|
+
}
|
|
781
|
+
const list = ss.map((s) => ({ uri: `skill://${s.name}/SKILL.md`, frontmatter: skillFrontmatter(s) }));
|
|
782
|
+
return complete26(ctx, { skills: list, ttlMs: 60000, cacheScope: cacheScope26() });
|
|
783
|
+
}
|
|
784
|
+
case "skills/get": {
|
|
785
|
+
if (!cfg.listSkills)
|
|
786
|
+
break;
|
|
787
|
+
const uri = typeof p.uri === "string" ? p.uri : "";
|
|
788
|
+
const parsed = parseSkillUri(uri);
|
|
789
|
+
if ("error" in parsed)
|
|
790
|
+
return invalid("Invalid skill URI: " + parsed.error);
|
|
791
|
+
let ss;
|
|
792
|
+
try {
|
|
793
|
+
ss = await skills26(ctx);
|
|
794
|
+
}
|
|
795
|
+
catch (e) {
|
|
796
|
+
return internal(e);
|
|
797
|
+
}
|
|
798
|
+
const s = ss.find((x) => `skill://${x.name}/SKILL.md` === uri);
|
|
799
|
+
if (s)
|
|
800
|
+
return complete26(ctx, { skill: skillEntry(s) });
|
|
801
|
+
return invalid("Unknown skill: " + uri);
|
|
802
|
+
}
|
|
803
|
+
case "resources/list": {
|
|
804
|
+
if (!cfg.listResources)
|
|
805
|
+
break;
|
|
806
|
+
const r = await dispatch(ctx, null, method, p);
|
|
807
|
+
if (r.error)
|
|
808
|
+
return { error: r.error };
|
|
809
|
+
return complete26(ctx, { ...r.result });
|
|
810
|
+
}
|
|
811
|
+
case "resources/read": {
|
|
812
|
+
const uri = typeof p.uri === "string" ? p.uri : "";
|
|
813
|
+
if (uri.startsWith("skill://") && cfg.listSkills) {
|
|
814
|
+
const parsed = parseSkillUri(uri);
|
|
815
|
+
if ("error" in parsed)
|
|
816
|
+
return invalid("Invalid resource URI: " + parsed.error);
|
|
817
|
+
let ss;
|
|
818
|
+
try {
|
|
819
|
+
ss = await skills26(ctx);
|
|
820
|
+
}
|
|
821
|
+
catch (e) {
|
|
822
|
+
return internal(e);
|
|
823
|
+
}
|
|
824
|
+
for (const s of ss) {
|
|
825
|
+
if (s.name !== parsed.name)
|
|
826
|
+
continue;
|
|
827
|
+
const f = (s.files ?? []).find((x) => x.path === parsed.rel);
|
|
828
|
+
if (f) {
|
|
829
|
+
const mimeType = parsed.rel.endsWith(".md") ? "text/markdown" : "text/plain";
|
|
830
|
+
return complete26(ctx, { contents: [{ uri, mimeType, text: f.text }], ttlMs: 60000, cacheScope: cacheScope26() });
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
return invalid("Resource not found: " + uri);
|
|
834
|
+
}
|
|
835
|
+
if (!cfg.listResources || !cfg.readResource)
|
|
836
|
+
break;
|
|
837
|
+
const r = await dispatch(ctx, null, method, p);
|
|
838
|
+
if (r.error)
|
|
839
|
+
return { error: r.error };
|
|
840
|
+
return complete26(ctx, { ...r.result });
|
|
841
|
+
}
|
|
842
|
+
case "prompts/list":
|
|
843
|
+
case "prompts/get": {
|
|
844
|
+
if (!cfg.listPrompts)
|
|
845
|
+
break;
|
|
846
|
+
// Same payload as the legacy wire; only the envelope differs.
|
|
847
|
+
const r = await dispatch(ctx, null, method, p);
|
|
848
|
+
if (r.error)
|
|
849
|
+
return { error: r.error };
|
|
850
|
+
return complete26(ctx, isObj(r.result) ? { ...r.result } : {});
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return { status: 404, error: { code: -32601, message: "Method not found: " + method } };
|
|
854
|
+
}
|
|
855
|
+
async function callTool2026(ctx, p, meta) {
|
|
856
|
+
const name = typeof p.name === "string" ? p.name : "";
|
|
857
|
+
let ts;
|
|
858
|
+
try {
|
|
859
|
+
ts = (await tools(ctx)) ?? [];
|
|
860
|
+
}
|
|
861
|
+
catch (e) {
|
|
862
|
+
return { error: { code: -32603, message: errMsg(e) } };
|
|
863
|
+
}
|
|
864
|
+
const tool = ts.find((t) => t.name === name);
|
|
865
|
+
if (!tool || typeof tool.execute !== "function")
|
|
866
|
+
return { error: { code: -32602, message: "Unknown tool: " + name } };
|
|
867
|
+
const started = Date.now();
|
|
868
|
+
const ev = {
|
|
869
|
+
name: tool.name,
|
|
870
|
+
source: tool.source,
|
|
871
|
+
ms: 0,
|
|
872
|
+
isError: false,
|
|
873
|
+
resultBytes: 0,
|
|
874
|
+
decision: "allow",
|
|
875
|
+
identity: ctx.identity,
|
|
876
|
+
sessionId: "",
|
|
877
|
+
};
|
|
878
|
+
const finish = (out, isError, execErr) => {
|
|
879
|
+
ev.ms = Date.now() - started;
|
|
880
|
+
ev.isError = isError;
|
|
881
|
+
if (execErr !== undefined)
|
|
882
|
+
ev.err = execErr;
|
|
883
|
+
ev.resultBytes = Buffer.byteLength(JSON.stringify(out.result));
|
|
884
|
+
if (cfg.onCall) {
|
|
885
|
+
try {
|
|
886
|
+
cfg.onCall(ev);
|
|
887
|
+
}
|
|
888
|
+
catch {
|
|
889
|
+
// host callback throws are isolated
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return out;
|
|
893
|
+
};
|
|
894
|
+
const result = async (r, execErr) => finish(await complete26(ctx, toolResultToMcp(r, true)), !!r.isError, execErr);
|
|
895
|
+
const inputRequired = async (m) => finish(await complete26(ctx, m, "input_required"), false);
|
|
896
|
+
let args = {};
|
|
897
|
+
const raw = p.arguments;
|
|
898
|
+
if (raw !== undefined && raw !== null) {
|
|
899
|
+
if (!isObj(raw))
|
|
900
|
+
return result({ output: "invalid arguments: must be an object", isError: true });
|
|
901
|
+
args = raw;
|
|
902
|
+
}
|
|
903
|
+
const state = decodeState26(p.requestState);
|
|
904
|
+
const hasState = state !== undefined && state.tool === name;
|
|
905
|
+
let confirmed = hasState && state.confirmed === true;
|
|
906
|
+
let responses = isObj(p.inputResponses) ? p.inputResponses : undefined;
|
|
907
|
+
if (cfg.beforeCall) {
|
|
908
|
+
let d;
|
|
909
|
+
try {
|
|
910
|
+
d = (await cfg.beforeCall(ctx, tool, args)) ?? {};
|
|
911
|
+
}
|
|
912
|
+
catch (e) {
|
|
913
|
+
d = { kind: "deny", message: errMsg(e) };
|
|
914
|
+
}
|
|
915
|
+
const kind = d.kind || "allow";
|
|
916
|
+
ev.decision = kind;
|
|
917
|
+
if (kind === "deny") {
|
|
918
|
+
if (d.result)
|
|
919
|
+
return result(d.result);
|
|
920
|
+
return result({ output: d.message || "denied", isError: true });
|
|
921
|
+
}
|
|
922
|
+
if (kind === "confirm" && !confirmed) {
|
|
923
|
+
const ans = inputResponse26("confirm", responses?.confirm);
|
|
924
|
+
if (hasState && ans) {
|
|
925
|
+
if (!ans.ok)
|
|
926
|
+
return result({ output: "declined", isError: true });
|
|
927
|
+
if (ans.data?.confirm === false)
|
|
928
|
+
return result({ output: "declined", isError: true });
|
|
929
|
+
confirmed = true;
|
|
930
|
+
responses = undefined; // the confirm round is consumed
|
|
931
|
+
}
|
|
932
|
+
else if (!canElicit26(meta)) {
|
|
933
|
+
if (d.result)
|
|
934
|
+
return result(d.result);
|
|
935
|
+
return result({ output: "confirmation required: " + (d.message ?? ""), isError: true });
|
|
936
|
+
}
|
|
937
|
+
else {
|
|
938
|
+
const msg = d.message || "Confirm this action?";
|
|
939
|
+
return inputRequired({
|
|
940
|
+
inputRequests: {
|
|
941
|
+
confirm: {
|
|
942
|
+
method: "elicitation/create",
|
|
943
|
+
params: {
|
|
944
|
+
mode: "form",
|
|
945
|
+
message: msg,
|
|
946
|
+
requestedSchema: {
|
|
947
|
+
type: "object",
|
|
948
|
+
properties: { confirm: { type: "boolean", title: "Confirm", description: msg } },
|
|
949
|
+
},
|
|
950
|
+
},
|
|
951
|
+
},
|
|
952
|
+
},
|
|
953
|
+
requestState: encodeState26(name, false),
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
const tc = { signal: ctx.signal, mcp: ctx };
|
|
959
|
+
if (hasState && responses && Object.keys(responses).length === 1) {
|
|
960
|
+
const [k, v] = Object.entries(responses)[0];
|
|
961
|
+
const a = inputResponse26(k, v);
|
|
962
|
+
if (a)
|
|
963
|
+
tc.answer = a;
|
|
964
|
+
}
|
|
965
|
+
let res;
|
|
966
|
+
let execErr;
|
|
967
|
+
try {
|
|
968
|
+
res = await tool.execute(args, tc);
|
|
969
|
+
}
|
|
970
|
+
catch (e) {
|
|
971
|
+
execErr = e;
|
|
972
|
+
res = { output: errMsg(e), isError: true };
|
|
973
|
+
}
|
|
974
|
+
const req = execErr === undefined ? pendingOf(res) : undefined;
|
|
975
|
+
if (req) {
|
|
976
|
+
return inputRequired({ inputRequests: { [req.id]: inputRequest26(req) }, requestState: encodeState26(name, confirmed) });
|
|
977
|
+
}
|
|
978
|
+
return result(res, execErr);
|
|
979
|
+
}
|
|
980
|
+
const handler = ((req, res) => {
|
|
981
|
+
const ac = new AbortController();
|
|
982
|
+
res.on("close", () => ac.abort());
|
|
983
|
+
const ctx = { request: req, sessionId: "", signal: ac.signal };
|
|
984
|
+
void (async () => {
|
|
985
|
+
let pre;
|
|
986
|
+
if (req.method === "POST" && has2026 && isJsonPost(req)) {
|
|
987
|
+
pre = await readBody(req);
|
|
988
|
+
if (await tryPost2026(ctx, req, res, pre))
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
if (only2026 && req.method !== "POST") {
|
|
992
|
+
req.resume();
|
|
993
|
+
return httpError(res, 405, "Method Not Allowed", { Allow: "POST" });
|
|
994
|
+
}
|
|
995
|
+
if (cfg.authenticate) {
|
|
996
|
+
try {
|
|
997
|
+
ctx.identity = await cfg.authenticate(req);
|
|
998
|
+
}
|
|
999
|
+
catch (err) {
|
|
1000
|
+
if (!pre)
|
|
1001
|
+
req.resume();
|
|
1002
|
+
return writeAuthFailure(cfg, req, res, err);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
switch (req.method) {
|
|
1006
|
+
case "POST":
|
|
1007
|
+
return handlePost(ctx, req, res, pre);
|
|
1008
|
+
case "GET":
|
|
1009
|
+
return handleGet(ctx, req, res);
|
|
1010
|
+
case "DELETE":
|
|
1011
|
+
return handleDelete(ctx, req, res);
|
|
1012
|
+
default:
|
|
1013
|
+
return httpError(res, 405, "Method Not Allowed", { Allow: "GET, POST, DELETE" });
|
|
1014
|
+
}
|
|
1015
|
+
})().catch((e) => {
|
|
1016
|
+
if (!res.headersSent)
|
|
1017
|
+
httpError(res, 500, "Internal Server Error: " + errMsg(e));
|
|
1018
|
+
else
|
|
1019
|
+
res.end();
|
|
1020
|
+
});
|
|
1021
|
+
});
|
|
1022
|
+
handler.notifyToolsChanged = () => {
|
|
1023
|
+
const line = `event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed" })}\n\n`;
|
|
1024
|
+
for (const s of streams) {
|
|
1025
|
+
try {
|
|
1026
|
+
s.write(line);
|
|
1027
|
+
}
|
|
1028
|
+
catch {
|
|
1029
|
+
// a dead stream is dropped by its close handler
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
return handler;
|
|
1034
|
+
}
|
|
1035
|
+
function write26(res, status, id, result, error) {
|
|
1036
|
+
const msg = { jsonrpc: "2.0", id: id ?? null };
|
|
1037
|
+
if (error)
|
|
1038
|
+
msg.error = error;
|
|
1039
|
+
else
|
|
1040
|
+
msg.result = result;
|
|
1041
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1042
|
+
res.end(JSON.stringify(msg) + "\n");
|
|
1043
|
+
}
|
|
1044
|
+
const skillFrontmatter = (s) => s.frontmatter ?? { name: s.name };
|
|
1045
|
+
function skillEntry(s) {
|
|
1046
|
+
const files = [...(s.files ?? [])].sort((a, b) => compareCodePoints(a.path, b.path));
|
|
1047
|
+
return {
|
|
1048
|
+
uri: `skill://${s.name}/SKILL.md`,
|
|
1049
|
+
frontmatter: skillFrontmatter(s),
|
|
1050
|
+
resources: files.map((f) => ({
|
|
1051
|
+
uri: `skill://${s.name}/${f.path}`,
|
|
1052
|
+
digest: "sha256:" + createHash("sha256").update(f.text, "utf8").digest("hex"),
|
|
1053
|
+
size: Buffer.byteLength(f.text, "utf8"),
|
|
1054
|
+
})),
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
/** Percent-decodes once, like Go's url.PathUnescape; undefined on a malformed escape. */
|
|
1058
|
+
function pathUnescape(s) {
|
|
1059
|
+
if (!s.includes("%"))
|
|
1060
|
+
return s;
|
|
1061
|
+
const bytes = [];
|
|
1062
|
+
for (let i = 0; i < s.length;) {
|
|
1063
|
+
if (s[i] === "%") {
|
|
1064
|
+
const hex = s.slice(i + 1, i + 3);
|
|
1065
|
+
if (!/^[0-9a-fA-F]{2}$/.test(hex))
|
|
1066
|
+
return undefined;
|
|
1067
|
+
bytes.push(parseInt(hex, 16));
|
|
1068
|
+
i += 3;
|
|
1069
|
+
}
|
|
1070
|
+
else {
|
|
1071
|
+
const cp = s.codePointAt(i);
|
|
1072
|
+
const ch = String.fromCodePoint(cp);
|
|
1073
|
+
bytes.push(...Buffer.from(ch, "utf8"));
|
|
1074
|
+
i += ch.length;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
return Buffer.from(bytes).toString("utf8");
|
|
1078
|
+
}
|
|
1079
|
+
/** Validates skill://<name>/<path> (SPEC §2.7 containment). */
|
|
1080
|
+
function parseSkillUri(uri) {
|
|
1081
|
+
if (!uri.startsWith("skill://"))
|
|
1082
|
+
return { error: "not a skill:// URI" };
|
|
1083
|
+
const dec = pathUnescape(uri.slice("skill://".length));
|
|
1084
|
+
if (dec === undefined)
|
|
1085
|
+
return { error: "bad percent-encoding" };
|
|
1086
|
+
if (/[%\\\0]/.test(dec))
|
|
1087
|
+
return { error: "skill URI has encoded, backslash or NUL characters" };
|
|
1088
|
+
const segs = dec.split("/");
|
|
1089
|
+
if (segs.length < 2)
|
|
1090
|
+
return { error: "skill URI needs skill://<name>/<path>" };
|
|
1091
|
+
for (const s of segs) {
|
|
1092
|
+
if (s === "" || s === "." || s === ".." || s.includes(":")) {
|
|
1093
|
+
return { error: "skill URI path escapes the skill (empty, '.', '..' or ':' segment)" };
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
return { name: segs[0], rel: segs.slice(1).join("/") };
|
|
1097
|
+
}
|
|
1098
|
+
/** requestState = base64url-nopad(compact JSON {"tool":…[,"confirmed":true]}); not integrity-protected. */
|
|
1099
|
+
function encodeState26(tool, confirmed) {
|
|
1100
|
+
const o = { tool };
|
|
1101
|
+
if (confirmed)
|
|
1102
|
+
o.confirmed = true;
|
|
1103
|
+
return Buffer.from(JSON.stringify(o), "utf8").toString("base64url");
|
|
1104
|
+
}
|
|
1105
|
+
function decodeState26(v) {
|
|
1106
|
+
if (typeof v !== "string" || !/^[A-Za-z0-9_-]+$/.test(v) || v.length % 4 === 1)
|
|
1107
|
+
return undefined;
|
|
1108
|
+
try {
|
|
1109
|
+
const o = JSON.parse(Buffer.from(v, "base64url").toString("utf8"));
|
|
1110
|
+
if (!isObj(o))
|
|
1111
|
+
return undefined;
|
|
1112
|
+
if (o.tool !== undefined && o.tool !== null && typeof o.tool !== "string")
|
|
1113
|
+
return undefined;
|
|
1114
|
+
if (o.confirmed !== undefined && o.confirmed !== null && typeof o.confirmed !== "boolean")
|
|
1115
|
+
return undefined;
|
|
1116
|
+
return { tool: o.tool ?? "", confirmed: o.confirmed === true };
|
|
1117
|
+
}
|
|
1118
|
+
catch {
|
|
1119
|
+
return undefined;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
/** Maps one MRTR input response onto a §10 Answer. */
|
|
1123
|
+
function inputResponse26(key, v) {
|
|
1124
|
+
if (!isObj(v))
|
|
1125
|
+
return undefined;
|
|
1126
|
+
const ok = v.action === "accept";
|
|
1127
|
+
const a = { id: key, ok };
|
|
1128
|
+
if (isObj(v.content))
|
|
1129
|
+
a.data = v.content;
|
|
1130
|
+
if (!ok)
|
|
1131
|
+
a.reason = v.action === "decline" ? "declined" : "cancelled";
|
|
1132
|
+
return a;
|
|
1133
|
+
}
|
|
1134
|
+
const canElicit26 = (meta) => {
|
|
1135
|
+
const caps = meta?.[META_CLIENT_CAPS];
|
|
1136
|
+
return isObj(caps) && "elicitation" in caps;
|
|
1137
|
+
};
|
|
1138
|
+
/** A §10 Request as an MRTR input request: authorization+url ⇒ URL mode, else a form. */
|
|
1139
|
+
function inputRequest26(r) {
|
|
1140
|
+
if (r.kind === "authorization" && r.url) {
|
|
1141
|
+
return { method: "elicitation/create", params: { mode: "url", message: r.prompt, url: r.url, elicitationId: r.id } };
|
|
1142
|
+
}
|
|
1143
|
+
const schema = isObj(r.data?.schema) ? r.data.schema : { type: "object", properties: {} };
|
|
1144
|
+
return { method: "elicitation/create", params: { mode: "form", message: r.prompt, requestedSchema: schema } };
|
|
1145
|
+
}
|