@absolutejs/mcp 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -0
- package/dist/index.js +417 -25
- package/dist/src/client.d.ts +7 -1
- package/dist/src/core.d.ts +9 -0
- package/dist/src/dispatch.d.ts +9 -4
- package/dist/src/feedback.d.ts +48 -0
- package/dist/src/index.d.ts +12 -2
- package/dist/src/jsonrpc.d.ts +1 -0
- package/dist/src/server.d.ts +4 -2
- package/dist/src/sessions.d.ts +25 -0
- package/dist/src/types.d.ts +86 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -107,6 +107,86 @@ onCall: ({ meta, name, ok }) =>
|
|
|
107
107
|
ledger.write({ tool: name, ok, member: meta.touched }),
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
+
## Feedback: the channel a client can't give you
|
|
111
|
+
|
|
112
|
+
A connected AI client renders no UI for your server. There is no button for the
|
|
113
|
+
user to press, so when they say _"that was wrong"_ the only path back to you is
|
|
114
|
+
the model relaying it. Every MCP server has this hole, and every one of them
|
|
115
|
+
hand-rolls the same two tools.
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { feedbackTools, FEEDBACK_INSTRUCTIONS } from "@absolutejs/mcp";
|
|
119
|
+
|
|
120
|
+
mcpServer<Caller>({
|
|
121
|
+
instructions: `${myInstructions} ${FEEDBACK_INSTRUCTIONS}`,
|
|
122
|
+
tools: ({ caller }) => ({
|
|
123
|
+
...myTools(caller),
|
|
124
|
+
...feedbackTools({
|
|
125
|
+
caller,
|
|
126
|
+
store: {
|
|
127
|
+
reportProblem: ({ caller, report }) => file(caller, report), // → "Filed as #42."
|
|
128
|
+
submitFeedback: ({ caller, feedback }) => record(caller, feedback),
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`FEEDBACK_INSTRUCTIONS` is the load-bearing half. Without it a model treats a
|
|
136
|
+
complaint as something to apologise for, and the signal dies where it was
|
|
137
|
+
spoken.
|
|
138
|
+
|
|
139
|
+
## Elicitation: ask the user a question mid-call
|
|
140
|
+
|
|
141
|
+
A tool that can't finish without something only the user knows can **ask them**
|
|
142
|
+
(`elicitation/create`) and wait for the answer.
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
mcpServer<Caller>({
|
|
146
|
+
elicitation: { enabled: true },
|
|
147
|
+
tools: () => ({
|
|
148
|
+
book_table: {
|
|
149
|
+
description: "Book a table.",
|
|
150
|
+
inputSchema: { type: "object" },
|
|
151
|
+
mayElicit: true, // opt in: this tool may ask
|
|
152
|
+
handler: async (args, { canElicit, elicit }) => {
|
|
153
|
+
if (!canElicit) return "Tell me the party size and I'll book it.";
|
|
154
|
+
const answer = await elicit({
|
|
155
|
+
message: "How many people?",
|
|
156
|
+
requestedSchema: {
|
|
157
|
+
type: "object",
|
|
158
|
+
properties: { people: { type: "integer", minimum: 1 } },
|
|
159
|
+
required: ["people"],
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
if (answer.action !== "accept") return "No problem — cancelled.";
|
|
163
|
+
return `Booked for ${answer.content.people}.`;
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
}),
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`requestedSchema` is a **flat object of primitives** (string / number / integer
|
|
171
|
+
/ boolean / enum) — the spec restricts it so any client can render a form. The
|
|
172
|
+
answer is `accept` (with `content`), `decline` (they said no), `cancel` (they
|
|
173
|
+
dismissed it), or `unsupported` (this client can't ask anyone — check
|
|
174
|
+
`canElicit` and take another path). Never fabricate an answer for the user; the
|
|
175
|
+
spec also forbids eliciting **sensitive information**.
|
|
176
|
+
|
|
177
|
+
**The trade-off, stated plainly.** Elicitation is the one MCP feature a
|
|
178
|
+
stateless server cannot do: the question goes out on the SSE stream of an
|
|
179
|
+
in-flight `tools/call`, and the client answers on a _separate_ HTTP POST. Two
|
|
180
|
+
requests have to meet, so the pending call is remembered in-process and the
|
|
181
|
+
endpoint becomes **session-stateful** (`Mcp-Session-Id`). Run one instance, or
|
|
182
|
+
pin sessions. Leave `elicitation` off — the default — and nothing changes: the
|
|
183
|
+
server stays stateless, `tools/call` keeps answering with a plain JSON body, and
|
|
184
|
+
only tools marked `mayElicit` ever stream.
|
|
185
|
+
|
|
186
|
+
Consuming a server that elicits? Pass `onElicit` to `createMcpClient` — that is
|
|
187
|
+
what declares the capability, and what the package uses to answer. Omit it and
|
|
188
|
+
servers are told you cannot ask anyone.
|
|
189
|
+
|
|
110
190
|
## A second, stricter endpoint
|
|
111
191
|
|
|
112
192
|
`mcpServer` is per-endpoint, so an admin console is the same call with a
|
package/dist/index.js
CHANGED
|
@@ -65,6 +65,50 @@ var parseBody = async (response, maxBytes) => {
|
|
|
65
65
|
}
|
|
66
66
|
throw new McpClientError("No JSON-RPC response in the event stream");
|
|
67
67
|
};
|
|
68
|
+
var consumeSseStream = async (response, maxBytes, onRequest) => {
|
|
69
|
+
const reader = response.body?.getReader();
|
|
70
|
+
if (!reader)
|
|
71
|
+
throw new McpClientError("The event stream had no body");
|
|
72
|
+
const decoder = new TextDecoder;
|
|
73
|
+
let buffer = "";
|
|
74
|
+
let seen = 0;
|
|
75
|
+
for (;; ) {
|
|
76
|
+
const { done, value } = await reader.read();
|
|
77
|
+
if (done)
|
|
78
|
+
break;
|
|
79
|
+
seen += value?.length ?? 0;
|
|
80
|
+
if (maxBytes > 0 && seen > maxBytes) {
|
|
81
|
+
await reader.cancel().catch(() => {
|
|
82
|
+
return;
|
|
83
|
+
});
|
|
84
|
+
throw new McpClientError("Response exceeded the size cap");
|
|
85
|
+
}
|
|
86
|
+
buffer += decoder.decode(value, { stream: true });
|
|
87
|
+
const frames = buffer.split(`
|
|
88
|
+
|
|
89
|
+
`);
|
|
90
|
+
buffer = frames.pop() ?? "";
|
|
91
|
+
for (const frame of frames) {
|
|
92
|
+
const line = frame.split(/\r?\n/).find((entry) => entry.startsWith("data:"));
|
|
93
|
+
if (!line)
|
|
94
|
+
continue;
|
|
95
|
+
const parsed = JSON.parse(line.slice("data:".length).trim());
|
|
96
|
+
if (!isRecord(parsed))
|
|
97
|
+
continue;
|
|
98
|
+
if (typeof parsed.method === "string") {
|
|
99
|
+
await onRequest(parsed);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if ("result" in parsed || "error" in parsed) {
|
|
103
|
+
await reader.cancel().catch(() => {
|
|
104
|
+
return;
|
|
105
|
+
});
|
|
106
|
+
return parsed;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
throw new McpClientError("No JSON-RPC response in the event stream");
|
|
111
|
+
};
|
|
68
112
|
var createMcpClient = (options) => {
|
|
69
113
|
const doFetch = options.request ?? fetch;
|
|
70
114
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
@@ -72,6 +116,32 @@ var createMcpClient = (options) => {
|
|
|
72
116
|
let protocolVersion = options.protocolVersion ?? DEFAULT_PROTOCOL;
|
|
73
117
|
let sessionId = null;
|
|
74
118
|
let nextId = 1;
|
|
119
|
+
const respond = async (id, result) => {
|
|
120
|
+
const headers = {
|
|
121
|
+
"content-type": "application/json",
|
|
122
|
+
"mcp-protocol-version": protocolVersion,
|
|
123
|
+
...options.headers
|
|
124
|
+
};
|
|
125
|
+
if (sessionId !== null)
|
|
126
|
+
headers["mcp-session-id"] = sessionId;
|
|
127
|
+
await doFetch(options.url, {
|
|
128
|
+
body: JSON.stringify({ id, jsonrpc: "2.0", result }),
|
|
129
|
+
headers,
|
|
130
|
+
method: "POST"
|
|
131
|
+
}).catch(() => {
|
|
132
|
+
return;
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
const answerServer = async (message) => {
|
|
136
|
+
if (message.method !== "elicitation/create")
|
|
137
|
+
return;
|
|
138
|
+
const request = isRecord(message.params) ? {
|
|
139
|
+
message: typeof message.params.message === "string" ? message.params.message : "",
|
|
140
|
+
requestedSchema: isRecord(message.params.requestedSchema) ? message.params.requestedSchema : {}
|
|
141
|
+
} : { message: "", requestedSchema: {} };
|
|
142
|
+
const result = options.onElicit ? await options.onElicit(request) : { action: "decline" };
|
|
143
|
+
await respond(message.id, result);
|
|
144
|
+
};
|
|
75
145
|
const rpc = async (method, params) => {
|
|
76
146
|
const controller = new AbortController;
|
|
77
147
|
const timer = setTimeout(() => {
|
|
@@ -105,7 +175,8 @@ var createMcpClient = (options) => {
|
|
|
105
175
|
status: 401
|
|
106
176
|
});
|
|
107
177
|
}
|
|
108
|
-
const
|
|
178
|
+
const isStream = (response.headers.get("content-type") ?? "").includes("text/event-stream");
|
|
179
|
+
const payload = isStream ? await consumeSseStream(response, maxBytes, answerServer) : await parseBody(response, maxBytes);
|
|
109
180
|
if (!isRecord(payload)) {
|
|
110
181
|
throw new McpClientError("Malformed JSON-RPC response");
|
|
111
182
|
}
|
|
@@ -137,7 +208,7 @@ var createMcpClient = (options) => {
|
|
|
137
208
|
};
|
|
138
209
|
const initialize = async () => {
|
|
139
210
|
const result = await rpc("initialize", {
|
|
140
|
-
capabilities: {},
|
|
211
|
+
capabilities: options.onElicit ? { elicitation: {} } : {},
|
|
141
212
|
clientInfo: options.clientInfo ?? {
|
|
142
213
|
name: "@absolutejs/mcp",
|
|
143
214
|
version: "0"
|
|
@@ -206,6 +277,7 @@ var JSONRPC_METHOD_NOT_FOUND = -32601;
|
|
|
206
277
|
var JSONRPC_INVALID_PARAMS = -32602;
|
|
207
278
|
var JSONRPC_INTERNAL_ERROR = -32603;
|
|
208
279
|
var HTTP_ACCEPTED = 202;
|
|
280
|
+
var HTTP_NO_CONTENT = 204;
|
|
209
281
|
var HTTP_UNAUTHORIZED = 401;
|
|
210
282
|
var HTTP_METHOD_NOT_ALLOWED = 405;
|
|
211
283
|
var jsonHeaders = {
|
|
@@ -268,7 +340,12 @@ var normalizeResult = (value) => {
|
|
|
268
340
|
return { content: value, isError: false };
|
|
269
341
|
return { isError: false, ...value };
|
|
270
342
|
};
|
|
271
|
-
var
|
|
343
|
+
var clientCanElicit = (params) => {
|
|
344
|
+
if (!isRecord(params) || !isRecord(params.capabilities))
|
|
345
|
+
return false;
|
|
346
|
+
return isRecord(params.capabilities.elicitation);
|
|
347
|
+
};
|
|
348
|
+
var initialize = async (config, id, params, context) => {
|
|
272
349
|
const supported = config.supportedProtocols ?? DEFAULT_PROTOCOLS;
|
|
273
350
|
const capabilities = {
|
|
274
351
|
tools: { listChanged: false }
|
|
@@ -278,12 +355,17 @@ var initialize = (config, id, params) => {
|
|
|
278
355
|
if (config.resources) {
|
|
279
356
|
capabilities.resources = { listChanged: false, subscribe: false };
|
|
280
357
|
}
|
|
281
|
-
|
|
358
|
+
const response = rpcResult(id, {
|
|
282
359
|
capabilities,
|
|
283
360
|
...config.instructions === undefined ? {} : { instructions: config.instructions },
|
|
284
361
|
protocolVersion: negotiateProtocol(supported, params),
|
|
285
362
|
serverInfo: config.serverInfo
|
|
286
363
|
});
|
|
364
|
+
if (!config.elicitation?.enabled || !context.sessions)
|
|
365
|
+
return response;
|
|
366
|
+
const sessionId = await context.sessions.create(clientCanElicit(params));
|
|
367
|
+
response.headers.set("Mcp-Session-Id", sessionId);
|
|
368
|
+
return response;
|
|
287
369
|
};
|
|
288
370
|
var toolsList = async (config, caller, scopes, id, params) => {
|
|
289
371
|
const tools = await config.tools({ caller, meta: {} });
|
|
@@ -301,7 +383,84 @@ var toolsList = async (config, caller, scopes, id, params) => {
|
|
|
301
383
|
});
|
|
302
384
|
};
|
|
303
385
|
var errorResult = (id, text) => rpcResult(id, { content: [{ text, type: "text" }], isError: true });
|
|
304
|
-
var
|
|
386
|
+
var noElicit = {
|
|
387
|
+
canElicit: false,
|
|
388
|
+
elicit: () => Promise.resolve({ action: "unsupported" })
|
|
389
|
+
};
|
|
390
|
+
var SSE_HEADERS = {
|
|
391
|
+
"cache-control": "no-cache",
|
|
392
|
+
connection: "keep-alive",
|
|
393
|
+
"content-type": "text/event-stream",
|
|
394
|
+
"x-accel-buffering": "no"
|
|
395
|
+
};
|
|
396
|
+
var sseFrame = (message) => `data: ${JSON.stringify(message)}
|
|
397
|
+
|
|
398
|
+
`;
|
|
399
|
+
var runTool = async (config, caller, id, name, args, meta, tool, context) => {
|
|
400
|
+
let ok = false;
|
|
401
|
+
let payload;
|
|
402
|
+
try {
|
|
403
|
+
const result = normalizeResult(await tool.handler(args, context));
|
|
404
|
+
ok = result.isError !== true;
|
|
405
|
+
payload = { id, jsonrpc: "2.0", result };
|
|
406
|
+
} catch (error) {
|
|
407
|
+
const detail = error instanceof Error ? error.message : "unknown error";
|
|
408
|
+
payload = {
|
|
409
|
+
id,
|
|
410
|
+
jsonrpc: "2.0",
|
|
411
|
+
result: {
|
|
412
|
+
content: [{ text: `Tool failed: ${detail}`, type: "text" }],
|
|
413
|
+
isError: true
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
if (config.onCall)
|
|
418
|
+
await config.onCall({ args, caller, meta, name, ok });
|
|
419
|
+
return payload;
|
|
420
|
+
};
|
|
421
|
+
var toolsCallStreaming = (config, caller, id, name, args, meta, tool, sessions, canElicit) => {
|
|
422
|
+
const encoder = new TextEncoder;
|
|
423
|
+
const body = new ReadableStream({
|
|
424
|
+
async start(controller) {
|
|
425
|
+
let open = true;
|
|
426
|
+
const send = (message) => {
|
|
427
|
+
if (!open)
|
|
428
|
+
return;
|
|
429
|
+
try {
|
|
430
|
+
controller.enqueue(encoder.encode(sseFrame(message)));
|
|
431
|
+
} catch {
|
|
432
|
+
open = false;
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
const context = {
|
|
436
|
+
canElicit,
|
|
437
|
+
elicit: async (request) => {
|
|
438
|
+
if (!canElicit)
|
|
439
|
+
return { action: "unsupported" };
|
|
440
|
+
const pending = sessions.startElicit(request);
|
|
441
|
+
send({
|
|
442
|
+
id: pending.id,
|
|
443
|
+
jsonrpc: "2.0",
|
|
444
|
+
method: "elicitation/create",
|
|
445
|
+
params: request
|
|
446
|
+
});
|
|
447
|
+
return await pending.answer;
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
const payload = await runTool(config, caller, id, name, args, meta, tool, context);
|
|
451
|
+
send(payload);
|
|
452
|
+
if (open) {
|
|
453
|
+
try {
|
|
454
|
+
controller.close();
|
|
455
|
+
} catch {
|
|
456
|
+
open = false;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
return new Response(body, { headers: SSE_HEADERS });
|
|
462
|
+
};
|
|
463
|
+
var toolsCall = async (config, caller, scopes, id, params, context) => {
|
|
305
464
|
if (!isRecord(params) || typeof params.name !== "string") {
|
|
306
465
|
return rpcError(id, JSONRPC_INVALID_PARAMS, "tools/call needs a name");
|
|
307
466
|
}
|
|
@@ -318,19 +477,15 @@ var toolsCall = async (config, caller, scopes, id, params) => {
|
|
|
318
477
|
if (!tool || !scopeAllows(tool, scopes)) {
|
|
319
478
|
return rpcError(id, JSONRPC_INVALID_PARAMS, `Unknown tool: ${name}`);
|
|
320
479
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
ok = result.isError !== true;
|
|
326
|
-
response = rpcResult(id, result);
|
|
327
|
-
} catch (error) {
|
|
328
|
-
const detail = error instanceof Error ? error.message : "unknown error";
|
|
329
|
-
response = errorResult(id, `Tool failed: ${detail}`);
|
|
480
|
+
const sessions = context.sessions;
|
|
481
|
+
const session = sessions ? await sessions.get(context.sessionId ?? null) : null;
|
|
482
|
+
if (tool.mayElicit === true && config.elicitation?.enabled === true && sessions && session) {
|
|
483
|
+
return toolsCallStreaming(config, caller, id, name, args, meta, tool, sessions, session.canElicit);
|
|
330
484
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
485
|
+
const payload = await runTool(config, caller, id, name, args, meta, tool, noElicit);
|
|
486
|
+
return new Response(JSON.stringify(payload), {
|
|
487
|
+
headers: { "content-type": "application/json" }
|
|
488
|
+
});
|
|
334
489
|
};
|
|
335
490
|
var promptsList = (config, id, params) => {
|
|
336
491
|
const definitions = config.prompts?.definitions ?? {};
|
|
@@ -395,24 +550,41 @@ var resourcesRead = async (config, caller, id, params) => {
|
|
|
395
550
|
]
|
|
396
551
|
});
|
|
397
552
|
};
|
|
398
|
-
var
|
|
553
|
+
var elicitAnswer = (message, context) => {
|
|
554
|
+
const requestId = typeof message.id === "string" ? message.id : null;
|
|
555
|
+
if (!requestId || !context.sessions)
|
|
556
|
+
return notificationAck();
|
|
557
|
+
const result = isRecord(message.result) ? message.result : null;
|
|
558
|
+
const action = result?.action;
|
|
559
|
+
const answer = action === "accept" && isRecord(result?.content) ? { action: "accept", content: result.content } : action === "decline" ? { action: "decline" } : { action: "cancel" };
|
|
560
|
+
context.sessions.resolveElicit({
|
|
561
|
+
requestId,
|
|
562
|
+
result: answer,
|
|
563
|
+
sessionId: context.sessionId ?? null
|
|
564
|
+
});
|
|
565
|
+
return notificationAck();
|
|
566
|
+
};
|
|
567
|
+
var dispatchMcp = async (config, caller, scopes, message, context = {}) => {
|
|
399
568
|
if (!isRecord(message) || message.jsonrpc !== "2.0") {
|
|
400
569
|
return rpcError(null, JSONRPC_INVALID_REQUEST, "Not a JSON-RPC 2.0 message");
|
|
401
570
|
}
|
|
402
571
|
if (!("id" in message))
|
|
403
572
|
return notificationAck();
|
|
573
|
+
if (!("method" in message))
|
|
574
|
+
return elicitAnswer(message, context);
|
|
404
575
|
const id = idOf(message);
|
|
405
576
|
const method = typeof message.method === "string" ? message.method : "";
|
|
406
577
|
const { params } = message;
|
|
407
|
-
if (method === "initialize")
|
|
408
|
-
return initialize(config, id, params);
|
|
578
|
+
if (method === "initialize") {
|
|
579
|
+
return await initialize(config, id, params, context);
|
|
580
|
+
}
|
|
409
581
|
if (method === "ping")
|
|
410
582
|
return rpcResult(id, {});
|
|
411
583
|
if (method === "tools/list") {
|
|
412
584
|
return toolsList(config, caller, scopes, id, params);
|
|
413
585
|
}
|
|
414
586
|
if (method === "tools/call") {
|
|
415
|
-
return toolsCall(config, caller, scopes, id, params);
|
|
587
|
+
return toolsCall(config, caller, scopes, id, params, context);
|
|
416
588
|
}
|
|
417
589
|
if (method === "prompts/list")
|
|
418
590
|
return promptsList(config, id, params);
|
|
@@ -426,6 +598,180 @@ var dispatchMcp = async (config, caller, scopes, message) => {
|
|
|
426
598
|
}
|
|
427
599
|
return rpcError(id, JSONRPC_METHOD_NOT_FOUND, `Unknown method: ${method}`);
|
|
428
600
|
};
|
|
601
|
+
// src/feedback.ts
|
|
602
|
+
var FEEDBACK_INSTRUCTIONS = "FEEDBACK: you are the user's only channel back to this server's team \u2014 there are no buttons in this client. If the user says something was wrong, unhelpful, or not what they asked for, call submit_feedback with their reason in their own words; if something is outright broken, call report_problem. Do this INSTEAD of only apologising, and tell them you've passed it on. Log the good as well as the bad.";
|
|
603
|
+
var asText = (input, key) => {
|
|
604
|
+
if (typeof input !== "object" || input === null)
|
|
605
|
+
return;
|
|
606
|
+
const value = Reflect.get(input, key);
|
|
607
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
608
|
+
};
|
|
609
|
+
var isRating = (value) => value === "good" || value === "bad";
|
|
610
|
+
var feedbackTools = (config) => ({
|
|
611
|
+
report_problem: {
|
|
612
|
+
annotations: {
|
|
613
|
+
destructiveHint: false,
|
|
614
|
+
openWorldHint: false,
|
|
615
|
+
readOnlyHint: false,
|
|
616
|
+
title: "Report a problem"
|
|
617
|
+
},
|
|
618
|
+
description: "Report a bug or broken behaviour on the user's behalf. Use when the user says something is broken, wrong, or not working \u2014 confirm the details with them first, then file it. Reports as THIS user's account.",
|
|
619
|
+
handler: async (args) => {
|
|
620
|
+
const problem = asText(args, "problem");
|
|
621
|
+
if (!problem)
|
|
622
|
+
return "Tell me what's broken and I'll report it.";
|
|
623
|
+
const reply = await config.store.reportProblem({
|
|
624
|
+
caller: config.caller,
|
|
625
|
+
report: {
|
|
626
|
+
expected: asText(args, "expected"),
|
|
627
|
+
problem,
|
|
628
|
+
steps: asText(args, "steps"),
|
|
629
|
+
where: asText(args, "where")
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
return reply ?? "Reported. Tell the user it's been filed with the team.";
|
|
633
|
+
},
|
|
634
|
+
inputSchema: {
|
|
635
|
+
properties: {
|
|
636
|
+
expected: {
|
|
637
|
+
description: "What the user expected to happen",
|
|
638
|
+
type: "string"
|
|
639
|
+
},
|
|
640
|
+
problem: {
|
|
641
|
+
description: "What is broken, in the user's words \u2014 one sentence",
|
|
642
|
+
type: "string"
|
|
643
|
+
},
|
|
644
|
+
steps: { description: "How to reproduce it", type: "string" },
|
|
645
|
+
where: {
|
|
646
|
+
description: "Where it happened (page, feature, or tool)",
|
|
647
|
+
type: "string"
|
|
648
|
+
}
|
|
649
|
+
},
|
|
650
|
+
required: ["problem"],
|
|
651
|
+
type: "object"
|
|
652
|
+
}
|
|
653
|
+
},
|
|
654
|
+
submit_feedback: {
|
|
655
|
+
annotations: {
|
|
656
|
+
destructiveHint: false,
|
|
657
|
+
openWorldHint: false,
|
|
658
|
+
readOnlyHint: false,
|
|
659
|
+
title: "Pass on feedback"
|
|
660
|
+
},
|
|
661
|
+
description: "Record the user's verdict on how this is going \u2014 call whenever they say a result was wrong, unhelpful, or missed what they asked for (rating 'bad'), or that something worked well (rating 'good'). Pass their reason in their OWN words. This is the only way feedback from this client reaches the team, so use it rather than only apologising.",
|
|
662
|
+
handler: async (args) => {
|
|
663
|
+
const rating = Reflect.get(args ?? {}, "rating");
|
|
664
|
+
const reason = asText(args, "reason");
|
|
665
|
+
if (!isRating(rating) || !reason) {
|
|
666
|
+
return "Provide the rating ('good' or 'bad') and the user's reason.";
|
|
667
|
+
}
|
|
668
|
+
const reply = await config.store.submitFeedback({
|
|
669
|
+
caller: config.caller,
|
|
670
|
+
feedback: { rating, reason, tool: asText(args, "tool") }
|
|
671
|
+
});
|
|
672
|
+
return reply ?? (rating === "bad" ? "Passed on to the team, with the user's reason attached." : "Logged as positive feedback.");
|
|
673
|
+
},
|
|
674
|
+
inputSchema: {
|
|
675
|
+
properties: {
|
|
676
|
+
rating: {
|
|
677
|
+
description: "'good' or 'bad'",
|
|
678
|
+
enum: ["good", "bad"],
|
|
679
|
+
type: "string"
|
|
680
|
+
},
|
|
681
|
+
reason: {
|
|
682
|
+
description: "What was wrong (or right), in the user's own words",
|
|
683
|
+
type: "string"
|
|
684
|
+
},
|
|
685
|
+
tool: {
|
|
686
|
+
description: "The tool this is about, if it was about one",
|
|
687
|
+
type: "string"
|
|
688
|
+
}
|
|
689
|
+
},
|
|
690
|
+
required: ["rating", "reason"],
|
|
691
|
+
type: "object"
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
// src/sessions.ts
|
|
696
|
+
var DEFAULT_SESSION_TTL_MS = 3600000;
|
|
697
|
+
var DEFAULT_ELICIT_TIMEOUT_MS = 120000;
|
|
698
|
+
var SWEEP_EVERY = 50;
|
|
699
|
+
var createMemoryStore = (ttlMs) => {
|
|
700
|
+
const sessions = new Map;
|
|
701
|
+
let sinceSweep = 0;
|
|
702
|
+
const sweep = () => {
|
|
703
|
+
sinceSweep += 1;
|
|
704
|
+
if (sinceSweep < SWEEP_EVERY)
|
|
705
|
+
return;
|
|
706
|
+
sinceSweep = 0;
|
|
707
|
+
const cutoff = Date.now() - ttlMs;
|
|
708
|
+
sessions.forEach((session, id) => {
|
|
709
|
+
if (session.lastSeen < cutoff)
|
|
710
|
+
sessions.delete(id);
|
|
711
|
+
});
|
|
712
|
+
};
|
|
713
|
+
return {
|
|
714
|
+
create: (session) => {
|
|
715
|
+
sweep();
|
|
716
|
+
const id = crypto.randomUUID();
|
|
717
|
+
sessions.set(id, { canElicit: session.canElicit, lastSeen: Date.now() });
|
|
718
|
+
return id;
|
|
719
|
+
},
|
|
720
|
+
drop: (id) => {
|
|
721
|
+
sessions.delete(id);
|
|
722
|
+
},
|
|
723
|
+
get: (id) => {
|
|
724
|
+
const session = sessions.get(id);
|
|
725
|
+
if (!session)
|
|
726
|
+
return null;
|
|
727
|
+
session.lastSeen = Date.now();
|
|
728
|
+
return { canElicit: session.canElicit };
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
};
|
|
732
|
+
var createSessionRegistry = (options) => {
|
|
733
|
+
const ttlMs = options?.ttlMs ?? DEFAULT_SESSION_TTL_MS;
|
|
734
|
+
const elicitTimeoutMs = options?.elicitTimeoutMs ?? DEFAULT_ELICIT_TIMEOUT_MS;
|
|
735
|
+
const store = options?.store ?? createMemoryStore(ttlMs);
|
|
736
|
+
const pending = new Map;
|
|
737
|
+
const resolveLocal = (answer) => {
|
|
738
|
+
const waiting = pending.get(answer.requestId);
|
|
739
|
+
if (!waiting)
|
|
740
|
+
return false;
|
|
741
|
+
clearTimeout(waiting.timer);
|
|
742
|
+
pending.delete(answer.requestId);
|
|
743
|
+
waiting.resolve(answer.result);
|
|
744
|
+
return true;
|
|
745
|
+
};
|
|
746
|
+
options?.bus?.subscribe((answer) => {
|
|
747
|
+
resolveLocal(answer);
|
|
748
|
+
});
|
|
749
|
+
return {
|
|
750
|
+
create: async (canElicit) => await store.create({ canElicit }),
|
|
751
|
+
drop: async (id) => {
|
|
752
|
+
await store.drop(id);
|
|
753
|
+
},
|
|
754
|
+
get: async (id) => id ? await store.get(id) : null,
|
|
755
|
+
resolveElicit: (answer) => {
|
|
756
|
+
if (resolveLocal(answer))
|
|
757
|
+
return true;
|
|
758
|
+
options?.bus?.publish(answer);
|
|
759
|
+
return false;
|
|
760
|
+
},
|
|
761
|
+
startElicit: (request) => {
|
|
762
|
+
const id = `elicit_${crypto.randomUUID()}`;
|
|
763
|
+
const answer = new Promise((resolve) => {
|
|
764
|
+
const timer = setTimeout(() => {
|
|
765
|
+
pending.delete(id);
|
|
766
|
+
resolve({ action: "cancel" });
|
|
767
|
+
}, elicitTimeoutMs);
|
|
768
|
+
pending.set(id, { resolve, timer });
|
|
769
|
+
});
|
|
770
|
+
return { answer, id, request };
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
};
|
|
774
|
+
|
|
429
775
|
// src/metadata.ts
|
|
430
776
|
var protectedResourceMetadata = (input) => ({
|
|
431
777
|
authorization_servers: [input.issuer],
|
|
@@ -439,6 +785,25 @@ var ROOT_METADATA_PATH = "/.well-known/oauth-protected-resource";
|
|
|
439
785
|
var JSON_HEADERS = {
|
|
440
786
|
"content-type": "application/json"
|
|
441
787
|
};
|
|
788
|
+
var HTTP_NOT_FOUND = 404;
|
|
789
|
+
var registries = new WeakMap;
|
|
790
|
+
var primeMcpSessions = (config) => {
|
|
791
|
+
registryFor(config);
|
|
792
|
+
};
|
|
793
|
+
var registryFor = (config) => {
|
|
794
|
+
if (!config.elicitation?.enabled)
|
|
795
|
+
return;
|
|
796
|
+
const existing = registries.get(config);
|
|
797
|
+
if (existing)
|
|
798
|
+
return existing;
|
|
799
|
+
const created = createSessionRegistry({
|
|
800
|
+
...config.elicitation.bus === undefined ? {} : { bus: config.elicitation.bus },
|
|
801
|
+
...config.elicitation.timeoutMs === undefined ? {} : { elicitTimeoutMs: config.elicitation.timeoutMs },
|
|
802
|
+
...config.elicitation.store === undefined ? {} : { store: config.elicitation.store }
|
|
803
|
+
});
|
|
804
|
+
registries.set(config, created);
|
|
805
|
+
return created;
|
|
806
|
+
};
|
|
442
807
|
var metadataResponse = (config) => new Response(JSON.stringify(protectedResourceMetadata({
|
|
443
808
|
issuer: config.issuer,
|
|
444
809
|
resource: `${config.issuer}${config.path}`,
|
|
@@ -455,7 +820,24 @@ var runMcpPost = async (config, request, body) => {
|
|
|
455
820
|
if (Array.isArray(body)) {
|
|
456
821
|
return rpcError(null, JSONRPC_INVALID_REQUEST, "Batching is not supported");
|
|
457
822
|
}
|
|
458
|
-
|
|
823
|
+
const sessions = registryFor(config);
|
|
824
|
+
const sessionId = request.headers.get("mcp-session-id");
|
|
825
|
+
if (sessions && sessionId && !await sessions.get(sessionId)) {
|
|
826
|
+
return new Response(null, { status: HTTP_NOT_FOUND });
|
|
827
|
+
}
|
|
828
|
+
return dispatchMcp(config, auth.caller, auth.scopes ?? [], body, {
|
|
829
|
+
sessionId,
|
|
830
|
+
sessions
|
|
831
|
+
}).catch(() => rpcError(null, JSONRPC_INVALID_REQUEST, "Internal error"));
|
|
832
|
+
};
|
|
833
|
+
var runMcpDelete = async (config, request) => {
|
|
834
|
+
const sessions = registryFor(config);
|
|
835
|
+
const sessionId = request.headers.get("mcp-session-id");
|
|
836
|
+
if (!sessions || !sessionId) {
|
|
837
|
+
return new Response(null, { status: HTTP_METHOD_NOT_ALLOWED });
|
|
838
|
+
}
|
|
839
|
+
await sessions.drop(sessionId);
|
|
840
|
+
return new Response(null, { status: HTTP_NO_CONTENT });
|
|
459
841
|
};
|
|
460
842
|
var handleMcpRequest = async (config, request) => {
|
|
461
843
|
const { pathname } = new URL(request.url);
|
|
@@ -477,16 +859,23 @@ var handleMcpRequest = async (config, request) => {
|
|
|
477
859
|
});
|
|
478
860
|
return runMcpPost(config, request, body);
|
|
479
861
|
}
|
|
862
|
+
if (request.method === "DELETE" && pathname === config.path) {
|
|
863
|
+
return runMcpDelete(config, request);
|
|
864
|
+
}
|
|
480
865
|
return null;
|
|
481
866
|
};
|
|
482
867
|
|
|
483
868
|
// src/handler.ts
|
|
484
|
-
var createMcpHandler = (config) =>
|
|
869
|
+
var createMcpHandler = (config) => {
|
|
870
|
+
primeMcpSessions(config);
|
|
871
|
+
return (request) => handleMcpRequest(config, request);
|
|
872
|
+
};
|
|
485
873
|
// src/server.ts
|
|
486
874
|
import { Elysia } from "elysia";
|
|
487
875
|
var mcpServer = (config) => {
|
|
488
876
|
const metadataPath = metadataPathFor(config.path);
|
|
489
|
-
|
|
877
|
+
primeMcpSessions(config);
|
|
878
|
+
const base = new Elysia().get(metadataPath, () => metadataResponse(config)).get(config.path, () => new Response(null, { status: HTTP_METHOD_NOT_ALLOWED })).post(config.path, ({ body, request }) => runMcpPost(config, request, body)).delete(config.path, ({ request }) => runMcpDelete(config, request));
|
|
490
879
|
const app = config.serveRootMetadata ? base.get(ROOT_METADATA_PATH, () => metadataResponse(config)) : base;
|
|
491
880
|
return app;
|
|
492
881
|
};
|
|
@@ -495,8 +884,11 @@ export {
|
|
|
495
884
|
protectedResourceMetadata,
|
|
496
885
|
metadataPathFor,
|
|
497
886
|
mcpServer,
|
|
887
|
+
feedbackTools,
|
|
498
888
|
dispatchMcp,
|
|
889
|
+
createSessionRegistry,
|
|
499
890
|
createMcpHandler,
|
|
500
891
|
createMcpClient,
|
|
501
|
-
McpClientError
|
|
892
|
+
McpClientError,
|
|
893
|
+
FEEDBACK_INSTRUCTIONS
|
|
502
894
|
};
|
package/dist/src/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { McpToolAnnotations, McpToolResult } from "./types";
|
|
1
|
+
import type { McpElicitationRequest, McpElicitResult, McpToolAnnotations, McpToolResult } from "./types";
|
|
2
2
|
export declare class McpClientError extends Error {
|
|
3
3
|
readonly code: number | undefined;
|
|
4
4
|
readonly status: number | undefined;
|
|
@@ -12,6 +12,12 @@ export type McpClientOptions = {
|
|
|
12
12
|
name: string;
|
|
13
13
|
version: string;
|
|
14
14
|
};
|
|
15
|
+
/** Answer a server's `elicitation/create` — a question for the USER, asked
|
|
16
|
+
* mid-tool-call. Supplying it declares the `elicitation` capability, so a
|
|
17
|
+
* server may then ask; omit it and servers are told you can't. Return
|
|
18
|
+
* `{action:"decline"}` (the user said no) or `{action:"cancel"}` (they
|
|
19
|
+
* dismissed it) — NEVER fabricate content on the user's behalf. */
|
|
20
|
+
onElicit?: (request: McpElicitationRequest) => Promise<McpElicitResult> | McpElicitResult;
|
|
15
21
|
/** Sent on every request (e.g. `{ authorization: "Bearer …" }`). */
|
|
16
22
|
headers?: Record<string, string>;
|
|
17
23
|
/** Reject responses whose body exceeds this many bytes (0 = no cap). */
|
package/dist/src/core.d.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import type { McpServerConfig } from "./types";
|
|
2
2
|
export declare const ROOT_METADATA_PATH = "/.well-known/oauth-protected-resource";
|
|
3
|
+
/** Build the session registry NOW rather than on the first request. The bus
|
|
4
|
+
* subscription has to exist before any answer can arrive, and an instance that
|
|
5
|
+
* has served no traffic yet is exactly the one a load balancer is about to
|
|
6
|
+
* hand an answer to. */
|
|
7
|
+
export declare const primeMcpSessions: <Caller>(config: McpServerConfig<Caller>) => void;
|
|
3
8
|
export declare const metadataResponse: <Caller>(config: McpServerConfig<Caller>) => Response;
|
|
4
9
|
/** Run one POST: authorize → validate the (already-decoded) body → dispatch.
|
|
5
10
|
* The body is passed in because Elysia pre-parses it while a raw handler must
|
|
6
11
|
* parse it itself; both funnel through here so the logic lives in one place. */
|
|
7
12
|
export declare const runMcpPost: <Caller>(config: McpServerConfig<Caller>, request: Request, body: unknown) => Promise<Response>;
|
|
13
|
+
/** The client is done with its session and says so (spec: Session Management).
|
|
14
|
+
* Only meaningful when elicitation put us in a session at all — otherwise 405,
|
|
15
|
+
* which the spec explicitly allows for servers that don't do sessions. */
|
|
16
|
+
export declare const runMcpDelete: <Caller>(config: McpServerConfig<Caller>, request: Request) => Promise<Response>;
|
|
8
17
|
/** The full path-aware handler over web-standard Request/Response. Returns a
|
|
9
18
|
* Response for any MCP route (POST endpoint, GET 405, discovery metadata) and
|
|
10
19
|
* `null` for anything else, so a host can compose it with its own routes. */
|
package/dist/src/dispatch.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import type { SessionRegistry } from "./sessions";
|
|
1
2
|
import type { McpServerConfig } from "./types";
|
|
2
|
-
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
export
|
|
3
|
+
/** Everything a dispatch needs to know about the HTTP request it came in on.
|
|
4
|
+
* Only elicitation uses it; without it the dispatcher is exactly as stateless
|
|
5
|
+
* as it was. */
|
|
6
|
+
export type McpDispatchContext = {
|
|
7
|
+
sessionId?: string | null;
|
|
8
|
+
sessions?: SessionRegistry;
|
|
9
|
+
};
|
|
10
|
+
export declare const dispatchMcp: <Caller>(config: McpServerConfig<Caller>, caller: Caller, scopes: string[], message: unknown, context?: McpDispatchContext) => Promise<Response>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { McpToolRegistry } from "./types";
|
|
2
|
+
export type McpFeedbackRating = "bad" | "good";
|
|
3
|
+
export type McpProblemReport = {
|
|
4
|
+
/** What the user expected instead. */
|
|
5
|
+
expected?: string;
|
|
6
|
+
/** What is broken, in the user's words. */
|
|
7
|
+
problem: string;
|
|
8
|
+
/** How to reproduce it. */
|
|
9
|
+
steps?: string;
|
|
10
|
+
/** Where it happened — a page, a feature, a tool name. */
|
|
11
|
+
where?: string;
|
|
12
|
+
};
|
|
13
|
+
export type McpFeedbackReport = {
|
|
14
|
+
rating: McpFeedbackRating;
|
|
15
|
+
/** Why — in the USER's words, not the model's summary. */
|
|
16
|
+
reason: string;
|
|
17
|
+
/** The tool the feedback is about, if it was about one. */
|
|
18
|
+
tool?: string;
|
|
19
|
+
};
|
|
20
|
+
/** Where feedback goes. Both handlers return the sentence the model relays back
|
|
21
|
+
* to the user, so the host controls what it promises them (a ticket id, an
|
|
22
|
+
* SLA, a thank-you). Returning nothing is fine — a default is used. */
|
|
23
|
+
export type McpFeedbackStore<Caller> = {
|
|
24
|
+
reportProblem: (input: {
|
|
25
|
+
caller: Caller;
|
|
26
|
+
report: McpProblemReport;
|
|
27
|
+
}) => Promise<string | void> | string | void;
|
|
28
|
+
submitFeedback: (input: {
|
|
29
|
+
caller: Caller;
|
|
30
|
+
feedback: McpFeedbackReport;
|
|
31
|
+
}) => Promise<string | void> | string | void;
|
|
32
|
+
};
|
|
33
|
+
/** Append to your server's `instructions`. Without this the model treats a
|
|
34
|
+
* complaint as something to apologise for rather than something to report. */
|
|
35
|
+
export declare const FEEDBACK_INSTRUCTIONS = "FEEDBACK: you are the user's only channel back to this server's team \u2014 there are no buttons in this client. If the user says something was wrong, unhelpful, or not what they asked for, call submit_feedback with their reason in their own words; if something is outright broken, call report_problem. Do this INSTEAD of only apologising, and tell them you've passed it on. Log the good as well as the bad.";
|
|
36
|
+
/** The two tools, bound to one caller. Call inside your `tools` factory:
|
|
37
|
+
*
|
|
38
|
+
* ```ts
|
|
39
|
+
* tools: ({ caller }) => ({
|
|
40
|
+
* ...myTools(caller),
|
|
41
|
+
* ...feedbackTools({ caller, store }),
|
|
42
|
+
* })
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export declare const feedbackTools: <Caller>(config: {
|
|
46
|
+
caller: Caller;
|
|
47
|
+
store: McpFeedbackStore<Caller>;
|
|
48
|
+
}) => McpToolRegistry;
|
package/dist/src/index.d.ts
CHANGED
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
* `scope` gating, and rich tool results (text/image/structured) are all
|
|
14
14
|
* built in; the package ships no opinion about billing, storage, or access.
|
|
15
15
|
*
|
|
16
|
+
* **Feedback.** A connected AI client gives a server no UI, so a user's "that
|
|
17
|
+
* was wrong" can only come back through the model. {@link feedbackTools} is the
|
|
18
|
+
* pair of tools that carries it (report_problem / submit_feedback), and
|
|
19
|
+
* {@link FEEDBACK_INSTRUCTIONS} is the sentence that makes a model actually use
|
|
20
|
+
* them instead of just apologising. {@link McpServerConfig.elicitation} goes
|
|
21
|
+
* further: a tool marked `mayElicit` can ASK the user a question mid-call
|
|
22
|
+
* (`elicitation/create`) and wait for the answer.
|
|
23
|
+
*
|
|
16
24
|
* **Consume.** {@link createMcpClient} is a streamable-HTTP client for calling
|
|
17
25
|
* OTHER MCP servers — the half you need to expose a user's own connected tools
|
|
18
26
|
* to your agent. Safety wrapping around untrusted remote tools (namespacing,
|
|
@@ -24,8 +32,10 @@
|
|
|
24
32
|
*/
|
|
25
33
|
export { verifyBearer, type BearerResult, type BearerVerifier, type VerifiedJwt, type VerifyBearerConfig, } from "./auth";
|
|
26
34
|
export { createMcpClient, McpClientError, type McpClient, type McpClientOptions, type McpInitializeResult, type McpRemoteTool, } from "./client";
|
|
27
|
-
export { dispatchMcp } from "./dispatch";
|
|
35
|
+
export { dispatchMcp, type McpDispatchContext } from "./dispatch";
|
|
36
|
+
export { FEEDBACK_INSTRUCTIONS, feedbackTools, type McpFeedbackRating, type McpFeedbackReport, type McpFeedbackStore, type McpProblemReport, } from "./feedback";
|
|
28
37
|
export { createMcpHandler } from "./handler";
|
|
29
38
|
export { metadataPathFor, protectedResourceMetadata, type ProtectedResourceMetadata, } from "./metadata";
|
|
30
39
|
export { mcpServer } from "./server";
|
|
31
|
-
export
|
|
40
|
+
export { createSessionRegistry, type SessionRegistry } from "./sessions";
|
|
41
|
+
export type { McpAudioContent, McpElicitationRequest, McpElicitResult, McpAuthResult, McpCallGate, McpCallMeta, McpContent, McpImageContent, McpPromptArgument, McpPromptDefinition, McpPrompts, McpResource, McpResourceLink, McpResources, McpServerConfig, McpServerInfo, McpTextContent, McpTool, McpToolAnnotations, McpToolCallContext, McpToolContext, McpToolRegistry, McpToolResult, McpToolReturn, } from "./types";
|
package/dist/src/jsonrpc.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export declare const JSONRPC_METHOD_NOT_FOUND = -32601;
|
|
|
4
4
|
export declare const JSONRPC_INVALID_PARAMS = -32602;
|
|
5
5
|
export declare const JSONRPC_INTERNAL_ERROR = -32603;
|
|
6
6
|
export declare const HTTP_ACCEPTED = 202;
|
|
7
|
+
export declare const HTTP_NO_CONTENT = 204;
|
|
7
8
|
export declare const HTTP_UNAUTHORIZED = 401;
|
|
8
9
|
export declare const HTTP_METHOD_NOT_ALLOWED = 405;
|
|
9
10
|
export type JsonRpcId = string | number | null;
|
package/dist/src/server.d.ts
CHANGED
|
@@ -3,8 +3,10 @@ import type { McpServerConfig } from "./types";
|
|
|
3
3
|
/** Build the MCP endpoint as an Elysia plugin. Mount it with `.use(...)`.
|
|
4
4
|
*
|
|
5
5
|
* Serves:
|
|
6
|
-
* - `POST <path>` — the JSON-RPC endpoint (
|
|
7
|
-
*
|
|
6
|
+
* - `POST <path>` — the JSON-RPC endpoint (streamable HTTP; answers with a
|
|
7
|
+
* plain JSON body, or an SSE stream when a tool may elicit)
|
|
8
|
+
* - `GET <path>` — 405 (no standalone server-initiated stream)
|
|
9
|
+
* - `DELETE <path>` — end an elicitation session (405 when sessionless)
|
|
8
10
|
* - `GET /.well-known/oauth-protected-resource<path>` — RFC 9728 metadata
|
|
9
11
|
* - `GET /.well-known/oauth-protected-resource` — the same, if
|
|
10
12
|
* `serveRootMetadata` is set (only one endpoint per app should). */
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { McpElicitAnswer, McpElicitBus, McpElicitResult, McpElicitationRequest, McpSessionStore } from "./types";
|
|
2
|
+
export type SessionRegistry = ReturnType<typeof createSessionRegistry>;
|
|
3
|
+
export declare const createSessionRegistry: (options?: {
|
|
4
|
+
bus?: McpElicitBus;
|
|
5
|
+
elicitTimeoutMs?: number;
|
|
6
|
+
store?: McpSessionStore;
|
|
7
|
+
ttlMs?: number;
|
|
8
|
+
}) => {
|
|
9
|
+
create: (canElicit: boolean) => Promise<string>;
|
|
10
|
+
drop: (id: string) => Promise<void>;
|
|
11
|
+
get: (id: string | null) => Promise<{
|
|
12
|
+
canElicit: boolean;
|
|
13
|
+
} | null>;
|
|
14
|
+
/** The client answered. If the call that asked is running HERE, resolve it.
|
|
15
|
+
* If not, put the answer on the bus so the instance that is waiting can —
|
|
16
|
+
* the answer must find the promise, and the promise cannot move. */
|
|
17
|
+
resolveElicit: (answer: McpElicitAnswer) => boolean;
|
|
18
|
+
/** Register an outbound question. Returns the id to send it under and the
|
|
19
|
+
* promise that settles when the user answers — or when they never do. */
|
|
20
|
+
startElicit: (request: McpElicitationRequest) => {
|
|
21
|
+
answer: Promise<McpElicitResult>;
|
|
22
|
+
id: string;
|
|
23
|
+
request: McpElicitationRequest;
|
|
24
|
+
};
|
|
25
|
+
};
|
package/dist/src/types.d.ts
CHANGED
|
@@ -40,12 +40,82 @@ export type McpToolResult = {
|
|
|
40
40
|
};
|
|
41
41
|
/** What a tool handler may return. A bare string is the common case. */
|
|
42
42
|
export type McpToolReturn = McpContent[] | McpToolResult | string;
|
|
43
|
+
/** A question the SERVER asks the USER, mid-tool-call, through the client
|
|
44
|
+
* (`elicitation/create`). The schema is a deliberately restricted subset of
|
|
45
|
+
* JSON Schema — a FLAT object of primitives (string / number / integer /
|
|
46
|
+
* boolean / enum), so any client can render a form for it. Nested objects and
|
|
47
|
+
* arrays are not allowed by the spec.
|
|
48
|
+
*
|
|
49
|
+
* Servers MUST NOT elicit sensitive information (spec, Security). */
|
|
50
|
+
export type McpElicitationRequest = {
|
|
51
|
+
message: string;
|
|
52
|
+
requestedSchema: Record<string, unknown>;
|
|
53
|
+
};
|
|
54
|
+
/** What came back. `unsupported` is ours, not the spec's: it is what you get
|
|
55
|
+
* when the client never declared the elicitation capability, so a tool can
|
|
56
|
+
* fall back instead of pretending the user declined. */
|
|
57
|
+
export type McpElicitResult = {
|
|
58
|
+
action: "accept";
|
|
59
|
+
content: Record<string, unknown>;
|
|
60
|
+
} | {
|
|
61
|
+
action: "cancel";
|
|
62
|
+
} | {
|
|
63
|
+
action: "decline";
|
|
64
|
+
} | {
|
|
65
|
+
action: "unsupported";
|
|
66
|
+
};
|
|
67
|
+
/** The client's answer, on its way back to whichever instance is waiting. */
|
|
68
|
+
export type McpElicitAnswer = {
|
|
69
|
+
requestId: string;
|
|
70
|
+
result: McpElicitResult;
|
|
71
|
+
sessionId: string | null;
|
|
72
|
+
};
|
|
73
|
+
/** Where session state lives. The default is in-memory (one instance). Put it
|
|
74
|
+
* in your database and any instance can serve any session. Nothing here is
|
|
75
|
+
* sensitive or large — an id and a capability flag. */
|
|
76
|
+
export type McpSessionStore = {
|
|
77
|
+
create: (session: {
|
|
78
|
+
canElicit: boolean;
|
|
79
|
+
}) => Promise<string> | string;
|
|
80
|
+
drop: (id: string) => Promise<void> | void;
|
|
81
|
+
get: (id: string) => Promise<{
|
|
82
|
+
canElicit: boolean;
|
|
83
|
+
} | null> | {
|
|
84
|
+
canElicit: boolean;
|
|
85
|
+
} | null;
|
|
86
|
+
};
|
|
87
|
+
/** How an answer reaches the instance that asked the question. The tool call
|
|
88
|
+
* and its pending promise live on ONE process; the client's answer POST can
|
|
89
|
+
* land on any of them. Wire this to whatever fan-out you already run
|
|
90
|
+
* (Postgres LISTEN/NOTIFY, Redis, …) and elicitation works with no sticky
|
|
91
|
+
* routing. Omit it and you must run a single instance (or pin sessions). */
|
|
92
|
+
export type McpElicitBus = {
|
|
93
|
+
/** An answer nobody here was waiting for — someone else might be. */
|
|
94
|
+
publish: (answer: McpElicitAnswer) => void;
|
|
95
|
+
subscribe: (handler: (answer: McpElicitAnswer) => void) => void;
|
|
96
|
+
};
|
|
97
|
+
/** Passed to a tool handler as its second argument. Ignore it and nothing
|
|
98
|
+
* changes — every existing handler keeps working. */
|
|
99
|
+
export type McpToolCallContext = {
|
|
100
|
+
/** True when this client can actually show the user a form. Check it before
|
|
101
|
+
* designing a flow around elicit(). */
|
|
102
|
+
canElicit: boolean;
|
|
103
|
+
/** Ask the user a question and wait for the answer. Resolves to
|
|
104
|
+
* `{action:"unsupported"}` immediately when the client can't elicit, and to
|
|
105
|
+
* `{action:"cancel"}` if they never answer. */
|
|
106
|
+
elicit: (request: McpElicitationRequest) => Promise<McpElicitResult>;
|
|
107
|
+
};
|
|
43
108
|
/** One callable tool. `inputSchema` is a JSON Schema object. */
|
|
44
109
|
export type McpTool = {
|
|
45
110
|
annotations?: McpToolAnnotations;
|
|
46
111
|
description: string;
|
|
47
|
-
handler: (args: unknown) => McpToolReturn | Promise<McpToolReturn>;
|
|
112
|
+
handler: (args: unknown, context: McpToolCallContext) => McpToolReturn | Promise<McpToolReturn>;
|
|
48
113
|
inputSchema: Record<string, unknown>;
|
|
114
|
+
/** Set when this tool may call `context.elicit`. It makes the server answer
|
|
115
|
+
* the `tools/call` with an SSE stream (the only way to send the user a
|
|
116
|
+
* question mid-call) instead of a plain JSON body — so it is opt-in per
|
|
117
|
+
* tool, and a server whose tools never elicit stays purely stateless. */
|
|
118
|
+
mayElicit?: boolean;
|
|
49
119
|
/** JSON Schema for `structuredContent`, advertised on `tools/list`. */
|
|
50
120
|
outputSchema?: Record<string, unknown>;
|
|
51
121
|
/** If set, the tool is only listed and callable when the caller's scopes
|
|
@@ -135,6 +205,21 @@ export type McpServerConfig<Caller> = {
|
|
|
135
205
|
instructions?: string;
|
|
136
206
|
/** The token issuer — used for discovery metadata and the challenge URL. */
|
|
137
207
|
issuer: string;
|
|
208
|
+
/** Turn on elicitation (server asks the USER a question mid-tool-call).
|
|
209
|
+
* Off by default, because it makes the endpoint SESSION-STATEFUL: the
|
|
210
|
+
* client answers on a separate HTTP request, so the pending call has to be
|
|
211
|
+
* remembered in-process. Run one instance, or pin `Mcp-Session-Id`. Tools
|
|
212
|
+
* must also opt in with `mayElicit`. */
|
|
213
|
+
elicitation?: {
|
|
214
|
+
/** Route answers to the instance that asked. Required to run more than one
|
|
215
|
+
* instance without sticky sessions. */
|
|
216
|
+
bus?: McpElicitBus;
|
|
217
|
+
enabled: true;
|
|
218
|
+
/** Shared session state. Required to run more than one instance. */
|
|
219
|
+
store?: McpSessionStore;
|
|
220
|
+
/** How long a question waits for a human before it gives up (default 2m). */
|
|
221
|
+
timeoutMs?: number;
|
|
222
|
+
};
|
|
138
223
|
/** Page size for tools/prompts/resources list pagination (default 50). */
|
|
139
224
|
listPageSize?: number;
|
|
140
225
|
/** Fired after every `tools/call` for auditing. `meta` carries anything the
|
package/package.json
CHANGED