@absolutejs/mcp 0.2.0 → 0.3.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 +407 -24
- package/dist/src/client.d.ts +7 -1
- package/dist/src/core.d.ts +4 -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 +34 -0
- package/dist/src/types.d.ts +50 -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 = (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 = 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,86 @@ 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, sessionId, 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(sessionId, request);
|
|
441
|
+
if (!pending.id)
|
|
442
|
+
return { action: "cancel" };
|
|
443
|
+
send({
|
|
444
|
+
id: pending.id,
|
|
445
|
+
jsonrpc: "2.0",
|
|
446
|
+
method: "elicitation/create",
|
|
447
|
+
params: request
|
|
448
|
+
});
|
|
449
|
+
return await pending.answer;
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
const payload = await runTool(config, caller, id, name, args, meta, tool, context);
|
|
453
|
+
send(payload);
|
|
454
|
+
if (open) {
|
|
455
|
+
try {
|
|
456
|
+
controller.close();
|
|
457
|
+
} catch {
|
|
458
|
+
open = false;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
return new Response(body, { headers: SSE_HEADERS });
|
|
464
|
+
};
|
|
465
|
+
var toolsCall = async (config, caller, scopes, id, params, context) => {
|
|
305
466
|
if (!isRecord(params) || typeof params.name !== "string") {
|
|
306
467
|
return rpcError(id, JSONRPC_INVALID_PARAMS, "tools/call needs a name");
|
|
307
468
|
}
|
|
@@ -318,19 +479,15 @@ var toolsCall = async (config, caller, scopes, id, params) => {
|
|
|
318
479
|
if (!tool || !scopeAllows(tool, scopes)) {
|
|
319
480
|
return rpcError(id, JSONRPC_INVALID_PARAMS, `Unknown tool: ${name}`);
|
|
320
481
|
}
|
|
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}`);
|
|
482
|
+
const session = context.sessions?.get(context.sessionId ?? null);
|
|
483
|
+
const streaming = tool.mayElicit === true && config.elicitation?.enabled === true && context.sessions !== undefined && session !== null && session !== undefined && typeof context.sessionId === "string";
|
|
484
|
+
if (streaming && context.sessions && typeof context.sessionId === "string") {
|
|
485
|
+
return toolsCallStreaming(config, caller, id, name, args, meta, tool, context.sessions, context.sessionId, session?.canElicit === true);
|
|
330
486
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
487
|
+
const payload = await runTool(config, caller, id, name, args, meta, tool, noElicit);
|
|
488
|
+
return new Response(JSON.stringify(payload), {
|
|
489
|
+
headers: { "content-type": "application/json" }
|
|
490
|
+
});
|
|
334
491
|
};
|
|
335
492
|
var promptsList = (config, id, params) => {
|
|
336
493
|
const definitions = config.prompts?.definitions ?? {};
|
|
@@ -395,24 +552,37 @@ var resourcesRead = async (config, caller, id, params) => {
|
|
|
395
552
|
]
|
|
396
553
|
});
|
|
397
554
|
};
|
|
398
|
-
var
|
|
555
|
+
var elicitAnswer = (message, context) => {
|
|
556
|
+
const requestId = typeof message.id === "string" ? message.id : null;
|
|
557
|
+
if (!requestId || !context.sessions)
|
|
558
|
+
return notificationAck();
|
|
559
|
+
const result = isRecord(message.result) ? message.result : null;
|
|
560
|
+
const action = result?.action;
|
|
561
|
+
const answer = action === "accept" && isRecord(result?.content) ? { action: "accept", content: result.content } : action === "decline" ? { action: "decline" } : { action: "cancel" };
|
|
562
|
+
context.sessions.resolveElicit(context.sessionId ?? null, requestId, answer);
|
|
563
|
+
return notificationAck();
|
|
564
|
+
};
|
|
565
|
+
var dispatchMcp = async (config, caller, scopes, message, context = {}) => {
|
|
399
566
|
if (!isRecord(message) || message.jsonrpc !== "2.0") {
|
|
400
567
|
return rpcError(null, JSONRPC_INVALID_REQUEST, "Not a JSON-RPC 2.0 message");
|
|
401
568
|
}
|
|
402
569
|
if (!("id" in message))
|
|
403
570
|
return notificationAck();
|
|
571
|
+
if (!("method" in message))
|
|
572
|
+
return elicitAnswer(message, context);
|
|
404
573
|
const id = idOf(message);
|
|
405
574
|
const method = typeof message.method === "string" ? message.method : "";
|
|
406
575
|
const { params } = message;
|
|
407
|
-
if (method === "initialize")
|
|
408
|
-
return initialize(config, id, params);
|
|
576
|
+
if (method === "initialize") {
|
|
577
|
+
return initialize(config, id, params, context);
|
|
578
|
+
}
|
|
409
579
|
if (method === "ping")
|
|
410
580
|
return rpcResult(id, {});
|
|
411
581
|
if (method === "tools/list") {
|
|
412
582
|
return toolsList(config, caller, scopes, id, params);
|
|
413
583
|
}
|
|
414
584
|
if (method === "tools/call") {
|
|
415
|
-
return toolsCall(config, caller, scopes, id, params);
|
|
585
|
+
return toolsCall(config, caller, scopes, id, params, context);
|
|
416
586
|
}
|
|
417
587
|
if (method === "prompts/list")
|
|
418
588
|
return promptsList(config, id, params);
|
|
@@ -426,6 +596,182 @@ var dispatchMcp = async (config, caller, scopes, message) => {
|
|
|
426
596
|
}
|
|
427
597
|
return rpcError(id, JSONRPC_METHOD_NOT_FOUND, `Unknown method: ${method}`);
|
|
428
598
|
};
|
|
599
|
+
// src/feedback.ts
|
|
600
|
+
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.";
|
|
601
|
+
var asText = (input, key) => {
|
|
602
|
+
if (typeof input !== "object" || input === null)
|
|
603
|
+
return;
|
|
604
|
+
const value = Reflect.get(input, key);
|
|
605
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
606
|
+
};
|
|
607
|
+
var isRating = (value) => value === "good" || value === "bad";
|
|
608
|
+
var feedbackTools = (config) => ({
|
|
609
|
+
report_problem: {
|
|
610
|
+
annotations: {
|
|
611
|
+
destructiveHint: false,
|
|
612
|
+
openWorldHint: false,
|
|
613
|
+
readOnlyHint: false,
|
|
614
|
+
title: "Report a problem"
|
|
615
|
+
},
|
|
616
|
+
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.",
|
|
617
|
+
handler: async (args) => {
|
|
618
|
+
const problem = asText(args, "problem");
|
|
619
|
+
if (!problem)
|
|
620
|
+
return "Tell me what's broken and I'll report it.";
|
|
621
|
+
const reply = await config.store.reportProblem({
|
|
622
|
+
caller: config.caller,
|
|
623
|
+
report: {
|
|
624
|
+
expected: asText(args, "expected"),
|
|
625
|
+
problem,
|
|
626
|
+
steps: asText(args, "steps"),
|
|
627
|
+
where: asText(args, "where")
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
return reply ?? "Reported. Tell the user it's been filed with the team.";
|
|
631
|
+
},
|
|
632
|
+
inputSchema: {
|
|
633
|
+
properties: {
|
|
634
|
+
expected: {
|
|
635
|
+
description: "What the user expected to happen",
|
|
636
|
+
type: "string"
|
|
637
|
+
},
|
|
638
|
+
problem: {
|
|
639
|
+
description: "What is broken, in the user's words \u2014 one sentence",
|
|
640
|
+
type: "string"
|
|
641
|
+
},
|
|
642
|
+
steps: { description: "How to reproduce it", type: "string" },
|
|
643
|
+
where: {
|
|
644
|
+
description: "Where it happened (page, feature, or tool)",
|
|
645
|
+
type: "string"
|
|
646
|
+
}
|
|
647
|
+
},
|
|
648
|
+
required: ["problem"],
|
|
649
|
+
type: "object"
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
submit_feedback: {
|
|
653
|
+
annotations: {
|
|
654
|
+
destructiveHint: false,
|
|
655
|
+
openWorldHint: false,
|
|
656
|
+
readOnlyHint: false,
|
|
657
|
+
title: "Pass on feedback"
|
|
658
|
+
},
|
|
659
|
+
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.",
|
|
660
|
+
handler: async (args) => {
|
|
661
|
+
const rating = Reflect.get(args ?? {}, "rating");
|
|
662
|
+
const reason = asText(args, "reason");
|
|
663
|
+
if (!isRating(rating) || !reason) {
|
|
664
|
+
return "Provide the rating ('good' or 'bad') and the user's reason.";
|
|
665
|
+
}
|
|
666
|
+
const reply = await config.store.submitFeedback({
|
|
667
|
+
caller: config.caller,
|
|
668
|
+
feedback: { rating, reason, tool: asText(args, "tool") }
|
|
669
|
+
});
|
|
670
|
+
return reply ?? (rating === "bad" ? "Passed on to the team, with the user's reason attached." : "Logged as positive feedback.");
|
|
671
|
+
},
|
|
672
|
+
inputSchema: {
|
|
673
|
+
properties: {
|
|
674
|
+
rating: {
|
|
675
|
+
description: "'good' or 'bad'",
|
|
676
|
+
enum: ["good", "bad"],
|
|
677
|
+
type: "string"
|
|
678
|
+
},
|
|
679
|
+
reason: {
|
|
680
|
+
description: "What was wrong (or right), in the user's own words",
|
|
681
|
+
type: "string"
|
|
682
|
+
},
|
|
683
|
+
tool: {
|
|
684
|
+
description: "The tool this is about, if it was about one",
|
|
685
|
+
type: "string"
|
|
686
|
+
}
|
|
687
|
+
},
|
|
688
|
+
required: ["rating", "reason"],
|
|
689
|
+
type: "object"
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
// src/sessions.ts
|
|
694
|
+
var DEFAULT_SESSION_TTL_MS = 3600000;
|
|
695
|
+
var DEFAULT_ELICIT_TIMEOUT_MS = 120000;
|
|
696
|
+
var SWEEP_EVERY = 50;
|
|
697
|
+
var createSessionRegistry = (options) => {
|
|
698
|
+
const ttlMs = options?.ttlMs ?? DEFAULT_SESSION_TTL_MS;
|
|
699
|
+
const elicitTimeoutMs = options?.elicitTimeoutMs ?? DEFAULT_ELICIT_TIMEOUT_MS;
|
|
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
|
+
return;
|
|
711
|
+
session.pending.forEach((pending) => {
|
|
712
|
+
clearTimeout(pending.timer);
|
|
713
|
+
pending.resolve({ action: "cancel" });
|
|
714
|
+
});
|
|
715
|
+
sessions.delete(id);
|
|
716
|
+
});
|
|
717
|
+
};
|
|
718
|
+
const touch = (id) => {
|
|
719
|
+
if (!id)
|
|
720
|
+
return null;
|
|
721
|
+
const session = sessions.get(id);
|
|
722
|
+
if (!session)
|
|
723
|
+
return null;
|
|
724
|
+
session.lastSeen = Date.now();
|
|
725
|
+
return session;
|
|
726
|
+
};
|
|
727
|
+
return {
|
|
728
|
+
create: (canElicit) => {
|
|
729
|
+
sweep();
|
|
730
|
+
const id = crypto.randomUUID();
|
|
731
|
+
sessions.set(id, { canElicit, lastSeen: Date.now(), pending: new Map });
|
|
732
|
+
return id;
|
|
733
|
+
},
|
|
734
|
+
drop: (id) => {
|
|
735
|
+
const session = sessions.get(id);
|
|
736
|
+
session?.pending.forEach((pending) => {
|
|
737
|
+
clearTimeout(pending.timer);
|
|
738
|
+
pending.resolve({ action: "cancel" });
|
|
739
|
+
});
|
|
740
|
+
sessions.delete(id);
|
|
741
|
+
},
|
|
742
|
+
get: (id) => touch(id),
|
|
743
|
+
resolveElicit: (sessionId, requestId, result) => {
|
|
744
|
+
const session = touch(sessionId);
|
|
745
|
+
const pending = session?.pending.get(requestId);
|
|
746
|
+
if (!session || !pending)
|
|
747
|
+
return false;
|
|
748
|
+
clearTimeout(pending.timer);
|
|
749
|
+
session.pending.delete(requestId);
|
|
750
|
+
pending.resolve(result);
|
|
751
|
+
return true;
|
|
752
|
+
},
|
|
753
|
+
startElicit: (sessionId, request) => {
|
|
754
|
+
const session = sessions.get(sessionId);
|
|
755
|
+
if (!session) {
|
|
756
|
+
return {
|
|
757
|
+
answer: Promise.resolve({ action: "cancel" }),
|
|
758
|
+
id: "",
|
|
759
|
+
request
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
const id = `elicit_${crypto.randomUUID()}`;
|
|
763
|
+
const answer = new Promise((resolve) => {
|
|
764
|
+
const timer = setTimeout(() => {
|
|
765
|
+
session.pending.delete(id);
|
|
766
|
+
resolve({ action: "cancel" });
|
|
767
|
+
}, elicitTimeoutMs);
|
|
768
|
+
session.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,20 @@ 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 registryFor = (config) => {
|
|
791
|
+
if (!config.elicitation?.enabled)
|
|
792
|
+
return;
|
|
793
|
+
const existing = registries.get(config);
|
|
794
|
+
if (existing)
|
|
795
|
+
return existing;
|
|
796
|
+
const created = createSessionRegistry({
|
|
797
|
+
elicitTimeoutMs: config.elicitation.timeoutMs
|
|
798
|
+
});
|
|
799
|
+
registries.set(config, created);
|
|
800
|
+
return created;
|
|
801
|
+
};
|
|
442
802
|
var metadataResponse = (config) => new Response(JSON.stringify(protectedResourceMetadata({
|
|
443
803
|
issuer: config.issuer,
|
|
444
804
|
resource: `${config.issuer}${config.path}`,
|
|
@@ -455,7 +815,24 @@ var runMcpPost = async (config, request, body) => {
|
|
|
455
815
|
if (Array.isArray(body)) {
|
|
456
816
|
return rpcError(null, JSONRPC_INVALID_REQUEST, "Batching is not supported");
|
|
457
817
|
}
|
|
458
|
-
|
|
818
|
+
const sessions = registryFor(config);
|
|
819
|
+
const sessionId = request.headers.get("mcp-session-id");
|
|
820
|
+
if (sessions && sessionId && !sessions.get(sessionId)) {
|
|
821
|
+
return new Response(null, { status: HTTP_NOT_FOUND });
|
|
822
|
+
}
|
|
823
|
+
return dispatchMcp(config, auth.caller, auth.scopes ?? [], body, {
|
|
824
|
+
sessionId,
|
|
825
|
+
sessions
|
|
826
|
+
}).catch(() => rpcError(null, JSONRPC_INVALID_REQUEST, "Internal error"));
|
|
827
|
+
};
|
|
828
|
+
var runMcpDelete = (config, request) => {
|
|
829
|
+
const sessions = registryFor(config);
|
|
830
|
+
const sessionId = request.headers.get("mcp-session-id");
|
|
831
|
+
if (!sessions || !sessionId) {
|
|
832
|
+
return new Response(null, { status: HTTP_METHOD_NOT_ALLOWED });
|
|
833
|
+
}
|
|
834
|
+
sessions.drop(sessionId);
|
|
835
|
+
return new Response(null, { status: HTTP_NO_CONTENT });
|
|
459
836
|
};
|
|
460
837
|
var handleMcpRequest = async (config, request) => {
|
|
461
838
|
const { pathname } = new URL(request.url);
|
|
@@ -477,6 +854,9 @@ var handleMcpRequest = async (config, request) => {
|
|
|
477
854
|
});
|
|
478
855
|
return runMcpPost(config, request, body);
|
|
479
856
|
}
|
|
857
|
+
if (request.method === "DELETE" && pathname === config.path) {
|
|
858
|
+
return runMcpDelete(config, request);
|
|
859
|
+
}
|
|
480
860
|
return null;
|
|
481
861
|
};
|
|
482
862
|
|
|
@@ -486,7 +866,7 @@ var createMcpHandler = (config) => (request) => handleMcpRequest(config, request
|
|
|
486
866
|
import { Elysia } from "elysia";
|
|
487
867
|
var mcpServer = (config) => {
|
|
488
868
|
const metadataPath = metadataPathFor(config.path);
|
|
489
|
-
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));
|
|
869
|
+
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
870
|
const app = config.serveRootMetadata ? base.get(ROOT_METADATA_PATH, () => metadataResponse(config)) : base;
|
|
491
871
|
return app;
|
|
492
872
|
};
|
|
@@ -495,8 +875,11 @@ export {
|
|
|
495
875
|
protectedResourceMetadata,
|
|
496
876
|
metadataPathFor,
|
|
497
877
|
mcpServer,
|
|
878
|
+
feedbackTools,
|
|
498
879
|
dispatchMcp,
|
|
880
|
+
createSessionRegistry,
|
|
499
881
|
createMcpHandler,
|
|
500
882
|
createMcpClient,
|
|
501
|
-
McpClientError
|
|
883
|
+
McpClientError,
|
|
884
|
+
FEEDBACK_INSTRUCTIONS
|
|
502
885
|
};
|
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
|
@@ -5,6 +5,10 @@ export declare const metadataResponse: <Caller>(config: McpServerConfig<Caller>)
|
|
|
5
5
|
* The body is passed in because Elysia pre-parses it while a raw handler must
|
|
6
6
|
* parse it itself; both funnel through here so the logic lives in one place. */
|
|
7
7
|
export declare const runMcpPost: <Caller>(config: McpServerConfig<Caller>, request: Request, body: unknown) => Promise<Response>;
|
|
8
|
+
/** The client is done with its session and says so (spec: Session Management).
|
|
9
|
+
* Only meaningful when elicitation put us in a session at all — otherwise 405,
|
|
10
|
+
* which the spec explicitly allows for servers that don't do sessions. */
|
|
11
|
+
export declare const runMcpDelete: <Caller>(config: McpServerConfig<Caller>, request: Request) => Response;
|
|
8
12
|
/** The full path-aware handler over web-standard Request/Response. Returns a
|
|
9
13
|
* Response for any MCP route (POST endpoint, GET 405, discovery metadata) and
|
|
10
14
|
* `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,34 @@
|
|
|
1
|
+
import type { McpElicitResult, McpElicitationRequest } from "./types";
|
|
2
|
+
type Pending = {
|
|
3
|
+
resolve: (result: McpElicitResult) => void;
|
|
4
|
+
timer: ReturnType<typeof setTimeout>;
|
|
5
|
+
};
|
|
6
|
+
type Session = {
|
|
7
|
+
/** The client declared the `elicitation` capability at initialize. */
|
|
8
|
+
canElicit: boolean;
|
|
9
|
+
lastSeen: number;
|
|
10
|
+
/** In-flight elicitations, keyed by the JSON-RPC id we sent. */
|
|
11
|
+
pending: Map<string, Pending>;
|
|
12
|
+
};
|
|
13
|
+
export type SessionRegistry = ReturnType<typeof createSessionRegistry>;
|
|
14
|
+
export declare const createSessionRegistry: (options?: {
|
|
15
|
+
elicitTimeoutMs?: number;
|
|
16
|
+
ttlMs?: number;
|
|
17
|
+
}) => {
|
|
18
|
+
/** A new session, returned to the client as `Mcp-Session-Id`. */
|
|
19
|
+
create: (canElicit: boolean) => `${string}-${string}-${string}-${string}-${string}`;
|
|
20
|
+
drop: (id: string) => void;
|
|
21
|
+
get: (id: string | null) => Session | null;
|
|
22
|
+
/** The client answered one of our elicitation requests. Returns false when
|
|
23
|
+
* the id is unknown (a stale answer, or a foreign session) — the caller
|
|
24
|
+
* should still 202 it, per the transport rules. */
|
|
25
|
+
resolveElicit: (sessionId: string | null, requestId: string, result: McpElicitResult) => boolean;
|
|
26
|
+
/** Register an outbound elicitation and get back the id to send with it,
|
|
27
|
+
* plus the promise that settles when the client answers (or gives up). */
|
|
28
|
+
startElicit: (sessionId: string, request: McpElicitationRequest) => {
|
|
29
|
+
answer: Promise<McpElicitResult>;
|
|
30
|
+
id: string;
|
|
31
|
+
request: McpElicitationRequest;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
export {};
|
package/dist/src/types.d.ts
CHANGED
|
@@ -40,12 +40,52 @@ 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
|
+
/** Passed to a tool handler as its second argument. Ignore it and nothing
|
|
68
|
+
* changes — every existing handler keeps working. */
|
|
69
|
+
export type McpToolCallContext = {
|
|
70
|
+
/** True when this client can actually show the user a form. Check it before
|
|
71
|
+
* designing a flow around elicit(). */
|
|
72
|
+
canElicit: boolean;
|
|
73
|
+
/** Ask the user a question and wait for the answer. Resolves to
|
|
74
|
+
* `{action:"unsupported"}` immediately when the client can't elicit, and to
|
|
75
|
+
* `{action:"cancel"}` if they never answer. */
|
|
76
|
+
elicit: (request: McpElicitationRequest) => Promise<McpElicitResult>;
|
|
77
|
+
};
|
|
43
78
|
/** One callable tool. `inputSchema` is a JSON Schema object. */
|
|
44
79
|
export type McpTool = {
|
|
45
80
|
annotations?: McpToolAnnotations;
|
|
46
81
|
description: string;
|
|
47
|
-
handler: (args: unknown) => McpToolReturn | Promise<McpToolReturn>;
|
|
82
|
+
handler: (args: unknown, context: McpToolCallContext) => McpToolReturn | Promise<McpToolReturn>;
|
|
48
83
|
inputSchema: Record<string, unknown>;
|
|
84
|
+
/** Set when this tool may call `context.elicit`. It makes the server answer
|
|
85
|
+
* the `tools/call` with an SSE stream (the only way to send the user a
|
|
86
|
+
* question mid-call) instead of a plain JSON body — so it is opt-in per
|
|
87
|
+
* tool, and a server whose tools never elicit stays purely stateless. */
|
|
88
|
+
mayElicit?: boolean;
|
|
49
89
|
/** JSON Schema for `structuredContent`, advertised on `tools/list`. */
|
|
50
90
|
outputSchema?: Record<string, unknown>;
|
|
51
91
|
/** If set, the tool is only listed and callable when the caller's scopes
|
|
@@ -135,6 +175,15 @@ export type McpServerConfig<Caller> = {
|
|
|
135
175
|
instructions?: string;
|
|
136
176
|
/** The token issuer — used for discovery metadata and the challenge URL. */
|
|
137
177
|
issuer: string;
|
|
178
|
+
/** Turn on elicitation (server asks the USER a question mid-tool-call).
|
|
179
|
+
* Off by default, because it makes the endpoint SESSION-STATEFUL: the
|
|
180
|
+
* client answers on a separate HTTP request, so the pending call has to be
|
|
181
|
+
* remembered in-process. Run one instance, or pin `Mcp-Session-Id`. Tools
|
|
182
|
+
* must also opt in with `mayElicit`. */
|
|
183
|
+
elicitation?: {
|
|
184
|
+
enabled: true;
|
|
185
|
+
timeoutMs?: number;
|
|
186
|
+
};
|
|
138
187
|
/** Page size for tools/prompts/resources list pagination (default 50). */
|
|
139
188
|
listPageSize?: number;
|
|
140
189
|
/** Fired after every `tools/call` for auditing. `meta` carries anything the
|
package/package.json
CHANGED