@absolutejs/mcp 0.1.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 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 payload = await parseBody(response, maxBytes);
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"
@@ -150,16 +221,26 @@ var createMcpClient = (options) => {
150
221
  await notify("notifications/initialized");
151
222
  return isRecord(result) ? result : {};
152
223
  };
224
+ const MAX_LIST_PAGES = 40;
153
225
  const listTools = async () => {
154
- const result = await rpc("tools/list");
155
- const tools = isRecord(result) && Array.isArray(result.tools) ? result.tools : [];
156
- return tools.filter(isRecord).map((tool) => ({
157
- annotations: isRecord(tool.annotations) ? tool.annotations : undefined,
158
- description: typeof tool.description === "string" ? tool.description : undefined,
159
- inputSchema: isRecord(tool.inputSchema) ? tool.inputSchema : undefined,
160
- name: typeof tool.name === "string" ? tool.name : "",
161
- outputSchema: isRecord(tool.outputSchema) ? tool.outputSchema : undefined
162
- }));
226
+ const collected = [];
227
+ let cursor;
228
+ for (let page = 0;page < MAX_LIST_PAGES; page += 1) {
229
+ const result = await rpc("tools/list", cursor === undefined ? undefined : { cursor });
230
+ const tools = isRecord(result) && Array.isArray(result.tools) ? result.tools : [];
231
+ collected.push(...tools.filter(isRecord).map((tool) => ({
232
+ annotations: isRecord(tool.annotations) ? tool.annotations : undefined,
233
+ description: typeof tool.description === "string" ? tool.description : undefined,
234
+ inputSchema: isRecord(tool.inputSchema) ? tool.inputSchema : undefined,
235
+ name: typeof tool.name === "string" ? tool.name : "",
236
+ outputSchema: isRecord(tool.outputSchema) ? tool.outputSchema : undefined
237
+ })));
238
+ const next = isRecord(result) && typeof result.nextCursor === "string" ? result.nextCursor : undefined;
239
+ if (next === undefined)
240
+ break;
241
+ cursor = next;
242
+ }
243
+ return collected;
163
244
  };
164
245
  const callTool = async (name, args) => {
165
246
  const result = await rpc("tools/call", { arguments: args ?? {}, name });
@@ -169,8 +250,19 @@ var createMcpClient = (options) => {
169
250
  return { content: [], isError: false };
170
251
  };
171
252
  const listResources = async () => {
172
- const result = await rpc("resources/list");
173
- return isRecord(result) && Array.isArray(result.resources) ? result.resources : [];
253
+ const collected = [];
254
+ let cursor;
255
+ for (let page = 0;page < MAX_LIST_PAGES; page += 1) {
256
+ const result = await rpc("resources/list", cursor === undefined ? undefined : { cursor });
257
+ if (isRecord(result) && Array.isArray(result.resources)) {
258
+ collected.push(...result.resources);
259
+ }
260
+ const next = isRecord(result) && typeof result.nextCursor === "string" ? result.nextCursor : undefined;
261
+ if (next === undefined)
262
+ break;
263
+ cursor = next;
264
+ }
265
+ return collected;
174
266
  };
175
267
  const readResource = async (uri) => rpc("resources/read", { uri });
176
268
  const ping = async () => {
@@ -185,6 +277,7 @@ var JSONRPC_METHOD_NOT_FOUND = -32601;
185
277
  var JSONRPC_INVALID_PARAMS = -32602;
186
278
  var JSONRPC_INTERNAL_ERROR = -32603;
187
279
  var HTTP_ACCEPTED = 202;
280
+ var HTTP_NO_CONTENT = 204;
188
281
  var HTTP_UNAUTHORIZED = 401;
189
282
  var HTTP_METHOD_NOT_ALLOWED = 405;
190
283
  var jsonHeaders = {
@@ -212,6 +305,26 @@ var unauthorized = (metadataUrl, detail) => new Response(JSON.stringify({
212
305
  // src/dispatch.ts
213
306
  var DEFAULT_PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"];
214
307
  var DEFAULT_RESOURCE_MIME = "text/markdown";
308
+ var DEFAULT_LIST_PAGE_SIZE = 50;
309
+ var decodeCursor = (params) => {
310
+ if (!isRecord(params) || typeof params.cursor !== "string")
311
+ return 0;
312
+ try {
313
+ const parsed = Number.parseInt(atob(params.cursor), 10);
314
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
315
+ } catch {
316
+ return 0;
317
+ }
318
+ };
319
+ var encodeCursor = (offset) => btoa(String(offset));
320
+ var paginate = (items, offset, pageSize) => {
321
+ const page = items.slice(offset, offset + pageSize);
322
+ const nextOffset = offset + pageSize;
323
+ return {
324
+ items: page,
325
+ ...nextOffset < items.length ? { nextCursor: encodeCursor(nextOffset) } : {}
326
+ };
327
+ };
215
328
  var idOf = (message) => typeof message.id === "string" || typeof message.id === "number" ? message.id : null;
216
329
  var negotiateProtocol = (supported, params) => {
217
330
  const preferred = supported[0] ?? DEFAULT_PROTOCOLS[0] ?? "";
@@ -227,7 +340,12 @@ var normalizeResult = (value) => {
227
340
  return { content: value, isError: false };
228
341
  return { isError: false, ...value };
229
342
  };
230
- var initialize = (config, id, params) => {
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) => {
231
349
  const supported = config.supportedProtocols ?? DEFAULT_PROTOCOLS;
232
350
  const capabilities = {
233
351
  tools: { listChanged: false }
@@ -237,27 +355,114 @@ var initialize = (config, id, params) => {
237
355
  if (config.resources) {
238
356
  capabilities.resources = { listChanged: false, subscribe: false };
239
357
  }
240
- return rpcResult(id, {
358
+ const response = rpcResult(id, {
241
359
  capabilities,
242
360
  ...config.instructions === undefined ? {} : { instructions: config.instructions },
243
361
  protocolVersion: negotiateProtocol(supported, params),
244
362
  serverInfo: config.serverInfo
245
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;
246
369
  };
247
- var toolsList = async (config, caller, scopes, id) => {
370
+ var toolsList = async (config, caller, scopes, id, params) => {
248
371
  const tools = await config.tools({ caller, meta: {} });
372
+ const visible = Object.entries(tools).filter(([, tool]) => scopeAllows(tool, scopes)).map(([name, tool]) => ({
373
+ annotations: tool.annotations,
374
+ description: tool.description,
375
+ inputSchema: tool.inputSchema,
376
+ name,
377
+ ...tool.outputSchema === undefined ? {} : { outputSchema: tool.outputSchema }
378
+ }));
379
+ const { items, nextCursor } = paginate(visible, decodeCursor(params), config.listPageSize ?? DEFAULT_LIST_PAGE_SIZE);
249
380
  return rpcResult(id, {
250
- tools: Object.entries(tools).filter(([, tool]) => scopeAllows(tool, scopes)).map(([name, tool]) => ({
251
- annotations: tool.annotations,
252
- description: tool.description,
253
- inputSchema: tool.inputSchema,
254
- name,
255
- ...tool.outputSchema === undefined ? {} : { outputSchema: tool.outputSchema }
256
- }))
381
+ tools: items,
382
+ ...nextCursor === undefined ? {} : { nextCursor }
257
383
  });
258
384
  };
259
385
  var errorResult = (id, text) => rpcResult(id, { content: [{ text, type: "text" }], isError: true });
260
- var toolsCall = async (config, caller, scopes, id, params) => {
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) => {
261
466
  if (!isRecord(params) || typeof params.name !== "string") {
262
467
  return rpcError(id, JSONRPC_INVALID_PARAMS, "tools/call needs a name");
263
468
  }
@@ -274,29 +479,28 @@ var toolsCall = async (config, caller, scopes, id, params) => {
274
479
  if (!tool || !scopeAllows(tool, scopes)) {
275
480
  return rpcError(id, JSONRPC_INVALID_PARAMS, `Unknown tool: ${name}`);
276
481
  }
277
- let ok = false;
278
- let response;
279
- try {
280
- const result = normalizeResult(await tool.handler(args));
281
- ok = result.isError !== true;
282
- response = rpcResult(id, result);
283
- } catch (error) {
284
- const detail = error instanceof Error ? error.message : "unknown error";
285
- 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);
286
486
  }
287
- if (config.onCall)
288
- await config.onCall({ args, caller, meta, name, ok });
289
- return response;
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
+ });
290
491
  };
291
- var promptsList = (config, id) => {
492
+ var promptsList = (config, id, params) => {
292
493
  const definitions = config.prompts?.definitions ?? {};
494
+ const all = Object.entries(definitions).map(([name, def]) => ({
495
+ arguments: def.arguments ?? [],
496
+ description: def.description,
497
+ name,
498
+ title: def.title
499
+ }));
500
+ const { items, nextCursor } = paginate(all, decodeCursor(params), config.listPageSize ?? DEFAULT_LIST_PAGE_SIZE);
293
501
  return rpcResult(id, {
294
- prompts: Object.entries(definitions).map(([name, def]) => ({
295
- arguments: def.arguments ?? [],
296
- description: def.description,
297
- name,
298
- title: def.title
299
- }))
502
+ prompts: items,
503
+ ...nextCursor === undefined ? {} : { nextCursor }
300
504
  });
301
505
  };
302
506
  var promptsGet = async (config, caller, id, params) => {
@@ -320,11 +524,15 @@ var promptsGet = async (config, caller, id, params) => {
320
524
  messages: [{ content: { text, type: "text" }, role: "user" }]
321
525
  });
322
526
  };
323
- var resourcesList = async (config, caller, id) => {
527
+ var resourcesList = async (config, caller, id, params) => {
324
528
  const resources = config.resources;
325
529
  if (!resources)
326
530
  return rpcResult(id, { resources: [] });
327
- return rpcResult(id, { resources: await resources.list({ caller }) });
531
+ const { items, nextCursor } = paginate(await resources.list({ caller }), decodeCursor(params), config.listPageSize ?? DEFAULT_LIST_PAGE_SIZE);
532
+ return rpcResult(id, {
533
+ resources: items,
534
+ ...nextCursor === undefined ? {} : { nextCursor }
535
+ });
328
536
  };
329
537
  var resourcesRead = async (config, caller, id, params) => {
330
538
  const resources = config.resources;
@@ -344,35 +552,226 @@ var resourcesRead = async (config, caller, id, params) => {
344
552
  ]
345
553
  });
346
554
  };
347
- var dispatchMcp = async (config, caller, scopes, message) => {
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 = {}) => {
348
566
  if (!isRecord(message) || message.jsonrpc !== "2.0") {
349
567
  return rpcError(null, JSONRPC_INVALID_REQUEST, "Not a JSON-RPC 2.0 message");
350
568
  }
351
569
  if (!("id" in message))
352
570
  return notificationAck();
571
+ if (!("method" in message))
572
+ return elicitAnswer(message, context);
353
573
  const id = idOf(message);
354
574
  const method = typeof message.method === "string" ? message.method : "";
355
575
  const { params } = message;
356
- if (method === "initialize")
357
- return initialize(config, id, params);
576
+ if (method === "initialize") {
577
+ return initialize(config, id, params, context);
578
+ }
358
579
  if (method === "ping")
359
580
  return rpcResult(id, {});
360
- if (method === "tools/list")
361
- return toolsList(config, caller, scopes, id);
581
+ if (method === "tools/list") {
582
+ return toolsList(config, caller, scopes, id, params);
583
+ }
362
584
  if (method === "tools/call") {
363
- return toolsCall(config, caller, scopes, id, params);
585
+ return toolsCall(config, caller, scopes, id, params, context);
364
586
  }
365
587
  if (method === "prompts/list")
366
- return promptsList(config, id);
588
+ return promptsList(config, id, params);
367
589
  if (method === "prompts/get")
368
590
  return promptsGet(config, caller, id, params);
369
- if (method === "resources/list")
370
- return resourcesList(config, caller, id);
591
+ if (method === "resources/list") {
592
+ return resourcesList(config, caller, id, params);
593
+ }
371
594
  if (method === "resources/read") {
372
595
  return resourcesRead(config, caller, id, params);
373
596
  }
374
597
  return rpcError(id, JSONRPC_METHOD_NOT_FOUND, `Unknown method: ${method}`);
375
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
+
376
775
  // src/metadata.ts
377
776
  var protectedResourceMetadata = (input) => ({
378
777
  authorization_servers: [input.issuer],
@@ -386,6 +785,20 @@ var ROOT_METADATA_PATH = "/.well-known/oauth-protected-resource";
386
785
  var JSON_HEADERS = {
387
786
  "content-type": "application/json"
388
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
+ };
389
802
  var metadataResponse = (config) => new Response(JSON.stringify(protectedResourceMetadata({
390
803
  issuer: config.issuer,
391
804
  resource: `${config.issuer}${config.path}`,
@@ -402,7 +815,24 @@ var runMcpPost = async (config, request, body) => {
402
815
  if (Array.isArray(body)) {
403
816
  return rpcError(null, JSONRPC_INVALID_REQUEST, "Batching is not supported");
404
817
  }
405
- return dispatchMcp(config, auth.caller, auth.scopes ?? [], body).catch(() => rpcError(null, JSONRPC_INVALID_REQUEST, "Internal error"));
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 });
406
836
  };
407
837
  var handleMcpRequest = async (config, request) => {
408
838
  const { pathname } = new URL(request.url);
@@ -424,6 +854,9 @@ var handleMcpRequest = async (config, request) => {
424
854
  });
425
855
  return runMcpPost(config, request, body);
426
856
  }
857
+ if (request.method === "DELETE" && pathname === config.path) {
858
+ return runMcpDelete(config, request);
859
+ }
427
860
  return null;
428
861
  };
429
862
 
@@ -433,7 +866,7 @@ var createMcpHandler = (config) => (request) => handleMcpRequest(config, request
433
866
  import { Elysia } from "elysia";
434
867
  var mcpServer = (config) => {
435
868
  const metadataPath = metadataPathFor(config.path);
436
- 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));
437
870
  const app = config.serveRootMetadata ? base.get(ROOT_METADATA_PATH, () => metadataResponse(config)) : base;
438
871
  return app;
439
872
  };
@@ -442,8 +875,11 @@ export {
442
875
  protectedResourceMetadata,
443
876
  metadataPathFor,
444
877
  mcpServer,
878
+ feedbackTools,
445
879
  dispatchMcp,
880
+ createSessionRegistry,
446
881
  createMcpHandler,
447
882
  createMcpClient,
448
- McpClientError
883
+ McpClientError,
884
+ FEEDBACK_INSTRUCTIONS
449
885
  };
@@ -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). */
@@ -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. */
@@ -1,5 +1,10 @@
1
+ import type { SessionRegistry } from "./sessions";
1
2
  import type { McpServerConfig } from "./types";
2
- /** Route one decoded JSON-RPC message to its handler. `scopes` are the caller's
3
- * granted scopes (from `authorize`); they gate scope-restricted tools.
4
- * Notifications (no `id`) get a bare 202; unknown methods get method-not-found. */
5
- export declare const dispatchMcp: <Caller>(config: McpServerConfig<Caller>, caller: Caller, scopes: string[], message: unknown) => Promise<Response>;
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;
@@ -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 type { McpAudioContent, McpAuthResult, McpCallGate, McpCallMeta, McpContent, McpImageContent, McpPromptArgument, McpPromptDefinition, McpPrompts, McpResource, McpResourceLink, McpResources, McpServerConfig, McpServerInfo, McpTextContent, McpTool, McpToolAnnotations, McpToolContext, McpToolRegistry, McpToolResult, McpToolReturn, } from "./types";
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";
@@ -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;
@@ -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 (stateless streamable HTTP)
7
- * - `GET <path>` — 405 (no server-initiated stream to subscribe to)
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 {};
@@ -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,17 @@ 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
+ };
187
+ /** Page size for tools/prompts/resources list pagination (default 50). */
188
+ listPageSize?: number;
138
189
  /** Fired after every `tools/call` for auditing. `meta` carries anything the
139
190
  * tool handler wrote during the call. */
140
191
  onCall?: (record: {
package/package.json CHANGED
@@ -37,5 +37,5 @@
37
37
  "typecheck": "tsc --noEmit --project tsconfig.json"
38
38
  },
39
39
  "types": "./dist/src/index.d.ts",
40
- "version": "0.1.0"
40
+ "version": "0.3.0"
41
41
  }