@absolutejs/mcp 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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"
@@ -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 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 = async (config, id, params, context) => {
272
349
  const supported = config.supportedProtocols ?? DEFAULT_PROTOCOLS;
273
350
  const capabilities = {
274
351
  tools: { listChanged: false }
@@ -278,12 +355,17 @@ var initialize = (config, id, params) => {
278
355
  if (config.resources) {
279
356
  capabilities.resources = { listChanged: false, subscribe: false };
280
357
  }
281
- return rpcResult(id, {
358
+ const response = rpcResult(id, {
282
359
  capabilities,
283
360
  ...config.instructions === undefined ? {} : { instructions: config.instructions },
284
361
  protocolVersion: negotiateProtocol(supported, params),
285
362
  serverInfo: config.serverInfo
286
363
  });
364
+ if (!config.elicitation?.enabled || !context.sessions)
365
+ return response;
366
+ const sessionId = await context.sessions.create(clientCanElicit(params));
367
+ response.headers.set("Mcp-Session-Id", sessionId);
368
+ return response;
287
369
  };
288
370
  var toolsList = async (config, caller, scopes, id, params) => {
289
371
  const tools = await config.tools({ caller, meta: {} });
@@ -301,7 +383,84 @@ var toolsList = async (config, caller, scopes, id, params) => {
301
383
  });
302
384
  };
303
385
  var errorResult = (id, text) => rpcResult(id, { content: [{ text, type: "text" }], isError: true });
304
- var 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, canElicit) => {
422
+ const encoder = new TextEncoder;
423
+ const body = new ReadableStream({
424
+ async start(controller) {
425
+ let open = true;
426
+ const send = (message) => {
427
+ if (!open)
428
+ return;
429
+ try {
430
+ controller.enqueue(encoder.encode(sseFrame(message)));
431
+ } catch {
432
+ open = false;
433
+ }
434
+ };
435
+ const context = {
436
+ canElicit,
437
+ elicit: async (request) => {
438
+ if (!canElicit)
439
+ return { action: "unsupported" };
440
+ const pending = sessions.startElicit(request);
441
+ send({
442
+ id: pending.id,
443
+ jsonrpc: "2.0",
444
+ method: "elicitation/create",
445
+ params: request
446
+ });
447
+ return await pending.answer;
448
+ }
449
+ };
450
+ const payload = await runTool(config, caller, id, name, args, meta, tool, context);
451
+ send(payload);
452
+ if (open) {
453
+ try {
454
+ controller.close();
455
+ } catch {
456
+ open = false;
457
+ }
458
+ }
459
+ }
460
+ });
461
+ return new Response(body, { headers: SSE_HEADERS });
462
+ };
463
+ var toolsCall = async (config, caller, scopes, id, params, context) => {
305
464
  if (!isRecord(params) || typeof params.name !== "string") {
306
465
  return rpcError(id, JSONRPC_INVALID_PARAMS, "tools/call needs a name");
307
466
  }
@@ -318,19 +477,15 @@ var toolsCall = async (config, caller, scopes, id, params) => {
318
477
  if (!tool || !scopeAllows(tool, scopes)) {
319
478
  return rpcError(id, JSONRPC_INVALID_PARAMS, `Unknown tool: ${name}`);
320
479
  }
321
- let ok = false;
322
- let response;
323
- try {
324
- const result = normalizeResult(await tool.handler(args));
325
- ok = result.isError !== true;
326
- response = rpcResult(id, result);
327
- } catch (error) {
328
- const detail = error instanceof Error ? error.message : "unknown error";
329
- response = errorResult(id, `Tool failed: ${detail}`);
480
+ const sessions = context.sessions;
481
+ const session = sessions ? await sessions.get(context.sessionId ?? null) : null;
482
+ if (tool.mayElicit === true && config.elicitation?.enabled === true && sessions && session) {
483
+ return toolsCallStreaming(config, caller, id, name, args, meta, tool, sessions, session.canElicit);
330
484
  }
331
- if (config.onCall)
332
- await config.onCall({ args, caller, meta, name, ok });
333
- return response;
485
+ const payload = await runTool(config, caller, id, name, args, meta, tool, noElicit);
486
+ return new Response(JSON.stringify(payload), {
487
+ headers: { "content-type": "application/json" }
488
+ });
334
489
  };
335
490
  var promptsList = (config, id, params) => {
336
491
  const definitions = config.prompts?.definitions ?? {};
@@ -395,24 +550,41 @@ var resourcesRead = async (config, caller, id, params) => {
395
550
  ]
396
551
  });
397
552
  };
398
- var dispatchMcp = async (config, caller, scopes, message) => {
553
+ var elicitAnswer = (message, context) => {
554
+ const requestId = typeof message.id === "string" ? message.id : null;
555
+ if (!requestId || !context.sessions)
556
+ return notificationAck();
557
+ const result = isRecord(message.result) ? message.result : null;
558
+ const action = result?.action;
559
+ const answer = action === "accept" && isRecord(result?.content) ? { action: "accept", content: result.content } : action === "decline" ? { action: "decline" } : { action: "cancel" };
560
+ context.sessions.resolveElicit({
561
+ requestId,
562
+ result: answer,
563
+ sessionId: context.sessionId ?? null
564
+ });
565
+ return notificationAck();
566
+ };
567
+ var dispatchMcp = async (config, caller, scopes, message, context = {}) => {
399
568
  if (!isRecord(message) || message.jsonrpc !== "2.0") {
400
569
  return rpcError(null, JSONRPC_INVALID_REQUEST, "Not a JSON-RPC 2.0 message");
401
570
  }
402
571
  if (!("id" in message))
403
572
  return notificationAck();
573
+ if (!("method" in message))
574
+ return elicitAnswer(message, context);
404
575
  const id = idOf(message);
405
576
  const method = typeof message.method === "string" ? message.method : "";
406
577
  const { params } = message;
407
- if (method === "initialize")
408
- return initialize(config, id, params);
578
+ if (method === "initialize") {
579
+ return await initialize(config, id, params, context);
580
+ }
409
581
  if (method === "ping")
410
582
  return rpcResult(id, {});
411
583
  if (method === "tools/list") {
412
584
  return toolsList(config, caller, scopes, id, params);
413
585
  }
414
586
  if (method === "tools/call") {
415
- return toolsCall(config, caller, scopes, id, params);
587
+ return toolsCall(config, caller, scopes, id, params, context);
416
588
  }
417
589
  if (method === "prompts/list")
418
590
  return promptsList(config, id, params);
@@ -426,6 +598,180 @@ var dispatchMcp = async (config, caller, scopes, message) => {
426
598
  }
427
599
  return rpcError(id, JSONRPC_METHOD_NOT_FOUND, `Unknown method: ${method}`);
428
600
  };
601
+ // src/feedback.ts
602
+ var FEEDBACK_INSTRUCTIONS = "FEEDBACK: you are the user's only channel back to this server's team \u2014 there are no buttons in this client. If the user says something was wrong, unhelpful, or not what they asked for, call submit_feedback with their reason in their own words; if something is outright broken, call report_problem. Do this INSTEAD of only apologising, and tell them you've passed it on. Log the good as well as the bad.";
603
+ var asText = (input, key) => {
604
+ if (typeof input !== "object" || input === null)
605
+ return;
606
+ const value = Reflect.get(input, key);
607
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
608
+ };
609
+ var isRating = (value) => value === "good" || value === "bad";
610
+ var feedbackTools = (config) => ({
611
+ report_problem: {
612
+ annotations: {
613
+ destructiveHint: false,
614
+ openWorldHint: false,
615
+ readOnlyHint: false,
616
+ title: "Report a problem"
617
+ },
618
+ description: "Report a bug or broken behaviour on the user's behalf. Use when the user says something is broken, wrong, or not working \u2014 confirm the details with them first, then file it. Reports as THIS user's account.",
619
+ handler: async (args) => {
620
+ const problem = asText(args, "problem");
621
+ if (!problem)
622
+ return "Tell me what's broken and I'll report it.";
623
+ const reply = await config.store.reportProblem({
624
+ caller: config.caller,
625
+ report: {
626
+ expected: asText(args, "expected"),
627
+ problem,
628
+ steps: asText(args, "steps"),
629
+ where: asText(args, "where")
630
+ }
631
+ });
632
+ return reply ?? "Reported. Tell the user it's been filed with the team.";
633
+ },
634
+ inputSchema: {
635
+ properties: {
636
+ expected: {
637
+ description: "What the user expected to happen",
638
+ type: "string"
639
+ },
640
+ problem: {
641
+ description: "What is broken, in the user's words \u2014 one sentence",
642
+ type: "string"
643
+ },
644
+ steps: { description: "How to reproduce it", type: "string" },
645
+ where: {
646
+ description: "Where it happened (page, feature, or tool)",
647
+ type: "string"
648
+ }
649
+ },
650
+ required: ["problem"],
651
+ type: "object"
652
+ }
653
+ },
654
+ submit_feedback: {
655
+ annotations: {
656
+ destructiveHint: false,
657
+ openWorldHint: false,
658
+ readOnlyHint: false,
659
+ title: "Pass on feedback"
660
+ },
661
+ description: "Record the user's verdict on how this is going \u2014 call whenever they say a result was wrong, unhelpful, or missed what they asked for (rating 'bad'), or that something worked well (rating 'good'). Pass their reason in their OWN words. This is the only way feedback from this client reaches the team, so use it rather than only apologising.",
662
+ handler: async (args) => {
663
+ const rating = Reflect.get(args ?? {}, "rating");
664
+ const reason = asText(args, "reason");
665
+ if (!isRating(rating) || !reason) {
666
+ return "Provide the rating ('good' or 'bad') and the user's reason.";
667
+ }
668
+ const reply = await config.store.submitFeedback({
669
+ caller: config.caller,
670
+ feedback: { rating, reason, tool: asText(args, "tool") }
671
+ });
672
+ return reply ?? (rating === "bad" ? "Passed on to the team, with the user's reason attached." : "Logged as positive feedback.");
673
+ },
674
+ inputSchema: {
675
+ properties: {
676
+ rating: {
677
+ description: "'good' or 'bad'",
678
+ enum: ["good", "bad"],
679
+ type: "string"
680
+ },
681
+ reason: {
682
+ description: "What was wrong (or right), in the user's own words",
683
+ type: "string"
684
+ },
685
+ tool: {
686
+ description: "The tool this is about, if it was about one",
687
+ type: "string"
688
+ }
689
+ },
690
+ required: ["rating", "reason"],
691
+ type: "object"
692
+ }
693
+ }
694
+ });
695
+ // src/sessions.ts
696
+ var DEFAULT_SESSION_TTL_MS = 3600000;
697
+ var DEFAULT_ELICIT_TIMEOUT_MS = 120000;
698
+ var SWEEP_EVERY = 50;
699
+ var createMemoryStore = (ttlMs) => {
700
+ const sessions = new Map;
701
+ let sinceSweep = 0;
702
+ const sweep = () => {
703
+ sinceSweep += 1;
704
+ if (sinceSweep < SWEEP_EVERY)
705
+ return;
706
+ sinceSweep = 0;
707
+ const cutoff = Date.now() - ttlMs;
708
+ sessions.forEach((session, id) => {
709
+ if (session.lastSeen < cutoff)
710
+ sessions.delete(id);
711
+ });
712
+ };
713
+ return {
714
+ create: (session) => {
715
+ sweep();
716
+ const id = crypto.randomUUID();
717
+ sessions.set(id, { canElicit: session.canElicit, lastSeen: Date.now() });
718
+ return id;
719
+ },
720
+ drop: (id) => {
721
+ sessions.delete(id);
722
+ },
723
+ get: (id) => {
724
+ const session = sessions.get(id);
725
+ if (!session)
726
+ return null;
727
+ session.lastSeen = Date.now();
728
+ return { canElicit: session.canElicit };
729
+ }
730
+ };
731
+ };
732
+ var createSessionRegistry = (options) => {
733
+ const ttlMs = options?.ttlMs ?? DEFAULT_SESSION_TTL_MS;
734
+ const elicitTimeoutMs = options?.elicitTimeoutMs ?? DEFAULT_ELICIT_TIMEOUT_MS;
735
+ const store = options?.store ?? createMemoryStore(ttlMs);
736
+ const pending = new Map;
737
+ const resolveLocal = (answer) => {
738
+ const waiting = pending.get(answer.requestId);
739
+ if (!waiting)
740
+ return false;
741
+ clearTimeout(waiting.timer);
742
+ pending.delete(answer.requestId);
743
+ waiting.resolve(answer.result);
744
+ return true;
745
+ };
746
+ options?.bus?.subscribe((answer) => {
747
+ resolveLocal(answer);
748
+ });
749
+ return {
750
+ create: async (canElicit) => await store.create({ canElicit }),
751
+ drop: async (id) => {
752
+ await store.drop(id);
753
+ },
754
+ get: async (id) => id ? await store.get(id) : null,
755
+ resolveElicit: (answer) => {
756
+ if (resolveLocal(answer))
757
+ return true;
758
+ options?.bus?.publish(answer);
759
+ return false;
760
+ },
761
+ startElicit: (request) => {
762
+ const id = `elicit_${crypto.randomUUID()}`;
763
+ const answer = new Promise((resolve) => {
764
+ const timer = setTimeout(() => {
765
+ pending.delete(id);
766
+ resolve({ action: "cancel" });
767
+ }, elicitTimeoutMs);
768
+ pending.set(id, { resolve, timer });
769
+ });
770
+ return { answer, id, request };
771
+ }
772
+ };
773
+ };
774
+
429
775
  // src/metadata.ts
430
776
  var protectedResourceMetadata = (input) => ({
431
777
  authorization_servers: [input.issuer],
@@ -439,6 +785,25 @@ var ROOT_METADATA_PATH = "/.well-known/oauth-protected-resource";
439
785
  var JSON_HEADERS = {
440
786
  "content-type": "application/json"
441
787
  };
788
+ var HTTP_NOT_FOUND = 404;
789
+ var registries = new WeakMap;
790
+ var primeMcpSessions = (config) => {
791
+ registryFor(config);
792
+ };
793
+ var registryFor = (config) => {
794
+ if (!config.elicitation?.enabled)
795
+ return;
796
+ const existing = registries.get(config);
797
+ if (existing)
798
+ return existing;
799
+ const created = createSessionRegistry({
800
+ ...config.elicitation.bus === undefined ? {} : { bus: config.elicitation.bus },
801
+ ...config.elicitation.timeoutMs === undefined ? {} : { elicitTimeoutMs: config.elicitation.timeoutMs },
802
+ ...config.elicitation.store === undefined ? {} : { store: config.elicitation.store }
803
+ });
804
+ registries.set(config, created);
805
+ return created;
806
+ };
442
807
  var metadataResponse = (config) => new Response(JSON.stringify(protectedResourceMetadata({
443
808
  issuer: config.issuer,
444
809
  resource: `${config.issuer}${config.path}`,
@@ -455,7 +820,24 @@ var runMcpPost = async (config, request, body) => {
455
820
  if (Array.isArray(body)) {
456
821
  return rpcError(null, JSONRPC_INVALID_REQUEST, "Batching is not supported");
457
822
  }
458
- return dispatchMcp(config, auth.caller, auth.scopes ?? [], body).catch(() => rpcError(null, JSONRPC_INVALID_REQUEST, "Internal error"));
823
+ const sessions = registryFor(config);
824
+ const sessionId = request.headers.get("mcp-session-id");
825
+ if (sessions && sessionId && !await sessions.get(sessionId)) {
826
+ return new Response(null, { status: HTTP_NOT_FOUND });
827
+ }
828
+ return dispatchMcp(config, auth.caller, auth.scopes ?? [], body, {
829
+ sessionId,
830
+ sessions
831
+ }).catch(() => rpcError(null, JSONRPC_INVALID_REQUEST, "Internal error"));
832
+ };
833
+ var runMcpDelete = async (config, request) => {
834
+ const sessions = registryFor(config);
835
+ const sessionId = request.headers.get("mcp-session-id");
836
+ if (!sessions || !sessionId) {
837
+ return new Response(null, { status: HTTP_METHOD_NOT_ALLOWED });
838
+ }
839
+ await sessions.drop(sessionId);
840
+ return new Response(null, { status: HTTP_NO_CONTENT });
459
841
  };
460
842
  var handleMcpRequest = async (config, request) => {
461
843
  const { pathname } = new URL(request.url);
@@ -477,16 +859,23 @@ var handleMcpRequest = async (config, request) => {
477
859
  });
478
860
  return runMcpPost(config, request, body);
479
861
  }
862
+ if (request.method === "DELETE" && pathname === config.path) {
863
+ return runMcpDelete(config, request);
864
+ }
480
865
  return null;
481
866
  };
482
867
 
483
868
  // src/handler.ts
484
- var createMcpHandler = (config) => (request) => handleMcpRequest(config, request);
869
+ var createMcpHandler = (config) => {
870
+ primeMcpSessions(config);
871
+ return (request) => handleMcpRequest(config, request);
872
+ };
485
873
  // src/server.ts
486
874
  import { Elysia } from "elysia";
487
875
  var mcpServer = (config) => {
488
876
  const metadataPath = metadataPathFor(config.path);
489
- 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));
877
+ primeMcpSessions(config);
878
+ const base = new Elysia().get(metadataPath, () => metadataResponse(config)).get(config.path, () => new Response(null, { status: HTTP_METHOD_NOT_ALLOWED })).post(config.path, ({ body, request }) => runMcpPost(config, request, body)).delete(config.path, ({ request }) => runMcpDelete(config, request));
490
879
  const app = config.serveRootMetadata ? base.get(ROOT_METADATA_PATH, () => metadataResponse(config)) : base;
491
880
  return app;
492
881
  };
@@ -495,8 +884,11 @@ export {
495
884
  protectedResourceMetadata,
496
885
  metadataPathFor,
497
886
  mcpServer,
887
+ feedbackTools,
498
888
  dispatchMcp,
889
+ createSessionRegistry,
499
890
  createMcpHandler,
500
891
  createMcpClient,
501
- McpClientError
892
+ McpClientError,
893
+ FEEDBACK_INSTRUCTIONS
502
894
  };
@@ -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). */
@@ -1,10 +1,19 @@
1
1
  import type { McpServerConfig } from "./types";
2
2
  export declare const ROOT_METADATA_PATH = "/.well-known/oauth-protected-resource";
3
+ /** Build the session registry NOW rather than on the first request. The bus
4
+ * subscription has to exist before any answer can arrive, and an instance that
5
+ * has served no traffic yet is exactly the one a load balancer is about to
6
+ * hand an answer to. */
7
+ export declare const primeMcpSessions: <Caller>(config: McpServerConfig<Caller>) => void;
3
8
  export declare const metadataResponse: <Caller>(config: McpServerConfig<Caller>) => Response;
4
9
  /** Run one POST: authorize → validate the (already-decoded) body → dispatch.
5
10
  * The body is passed in because Elysia pre-parses it while a raw handler must
6
11
  * parse it itself; both funnel through here so the logic lives in one place. */
7
12
  export declare const runMcpPost: <Caller>(config: McpServerConfig<Caller>, request: Request, body: unknown) => Promise<Response>;
13
+ /** The client is done with its session and says so (spec: Session Management).
14
+ * Only meaningful when elicitation put us in a session at all — otherwise 405,
15
+ * which the spec explicitly allows for servers that don't do sessions. */
16
+ export declare const runMcpDelete: <Caller>(config: McpServerConfig<Caller>, request: Request) => Promise<Response>;
8
17
  /** The full path-aware handler over web-standard Request/Response. Returns a
9
18
  * Response for any MCP route (POST endpoint, GET 405, discovery metadata) and
10
19
  * `null` for anything else, so a host can compose it with its own routes. */
@@ -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,25 @@
1
+ import type { McpElicitAnswer, McpElicitBus, McpElicitResult, McpElicitationRequest, McpSessionStore } from "./types";
2
+ export type SessionRegistry = ReturnType<typeof createSessionRegistry>;
3
+ export declare const createSessionRegistry: (options?: {
4
+ bus?: McpElicitBus;
5
+ elicitTimeoutMs?: number;
6
+ store?: McpSessionStore;
7
+ ttlMs?: number;
8
+ }) => {
9
+ create: (canElicit: boolean) => Promise<string>;
10
+ drop: (id: string) => Promise<void>;
11
+ get: (id: string | null) => Promise<{
12
+ canElicit: boolean;
13
+ } | null>;
14
+ /** The client answered. If the call that asked is running HERE, resolve it.
15
+ * If not, put the answer on the bus so the instance that is waiting can —
16
+ * the answer must find the promise, and the promise cannot move. */
17
+ resolveElicit: (answer: McpElicitAnswer) => boolean;
18
+ /** Register an outbound question. Returns the id to send it under and the
19
+ * promise that settles when the user answers — or when they never do. */
20
+ startElicit: (request: McpElicitationRequest) => {
21
+ answer: Promise<McpElicitResult>;
22
+ id: string;
23
+ request: McpElicitationRequest;
24
+ };
25
+ };
@@ -40,12 +40,82 @@ export type McpToolResult = {
40
40
  };
41
41
  /** What a tool handler may return. A bare string is the common case. */
42
42
  export type McpToolReturn = McpContent[] | McpToolResult | string;
43
+ /** A question the SERVER asks the USER, mid-tool-call, through the client
44
+ * (`elicitation/create`). The schema is a deliberately restricted subset of
45
+ * JSON Schema — a FLAT object of primitives (string / number / integer /
46
+ * boolean / enum), so any client can render a form for it. Nested objects and
47
+ * arrays are not allowed by the spec.
48
+ *
49
+ * Servers MUST NOT elicit sensitive information (spec, Security). */
50
+ export type McpElicitationRequest = {
51
+ message: string;
52
+ requestedSchema: Record<string, unknown>;
53
+ };
54
+ /** What came back. `unsupported` is ours, not the spec's: it is what you get
55
+ * when the client never declared the elicitation capability, so a tool can
56
+ * fall back instead of pretending the user declined. */
57
+ export type McpElicitResult = {
58
+ action: "accept";
59
+ content: Record<string, unknown>;
60
+ } | {
61
+ action: "cancel";
62
+ } | {
63
+ action: "decline";
64
+ } | {
65
+ action: "unsupported";
66
+ };
67
+ /** The client's answer, on its way back to whichever instance is waiting. */
68
+ export type McpElicitAnswer = {
69
+ requestId: string;
70
+ result: McpElicitResult;
71
+ sessionId: string | null;
72
+ };
73
+ /** Where session state lives. The default is in-memory (one instance). Put it
74
+ * in your database and any instance can serve any session. Nothing here is
75
+ * sensitive or large — an id and a capability flag. */
76
+ export type McpSessionStore = {
77
+ create: (session: {
78
+ canElicit: boolean;
79
+ }) => Promise<string> | string;
80
+ drop: (id: string) => Promise<void> | void;
81
+ get: (id: string) => Promise<{
82
+ canElicit: boolean;
83
+ } | null> | {
84
+ canElicit: boolean;
85
+ } | null;
86
+ };
87
+ /** How an answer reaches the instance that asked the question. The tool call
88
+ * and its pending promise live on ONE process; the client's answer POST can
89
+ * land on any of them. Wire this to whatever fan-out you already run
90
+ * (Postgres LISTEN/NOTIFY, Redis, …) and elicitation works with no sticky
91
+ * routing. Omit it and you must run a single instance (or pin sessions). */
92
+ export type McpElicitBus = {
93
+ /** An answer nobody here was waiting for — someone else might be. */
94
+ publish: (answer: McpElicitAnswer) => void;
95
+ subscribe: (handler: (answer: McpElicitAnswer) => void) => void;
96
+ };
97
+ /** Passed to a tool handler as its second argument. Ignore it and nothing
98
+ * changes — every existing handler keeps working. */
99
+ export type McpToolCallContext = {
100
+ /** True when this client can actually show the user a form. Check it before
101
+ * designing a flow around elicit(). */
102
+ canElicit: boolean;
103
+ /** Ask the user a question and wait for the answer. Resolves to
104
+ * `{action:"unsupported"}` immediately when the client can't elicit, and to
105
+ * `{action:"cancel"}` if they never answer. */
106
+ elicit: (request: McpElicitationRequest) => Promise<McpElicitResult>;
107
+ };
43
108
  /** One callable tool. `inputSchema` is a JSON Schema object. */
44
109
  export type McpTool = {
45
110
  annotations?: McpToolAnnotations;
46
111
  description: string;
47
- handler: (args: unknown) => McpToolReturn | Promise<McpToolReturn>;
112
+ handler: (args: unknown, context: McpToolCallContext) => McpToolReturn | Promise<McpToolReturn>;
48
113
  inputSchema: Record<string, unknown>;
114
+ /** Set when this tool may call `context.elicit`. It makes the server answer
115
+ * the `tools/call` with an SSE stream (the only way to send the user a
116
+ * question mid-call) instead of a plain JSON body — so it is opt-in per
117
+ * tool, and a server whose tools never elicit stays purely stateless. */
118
+ mayElicit?: boolean;
49
119
  /** JSON Schema for `structuredContent`, advertised on `tools/list`. */
50
120
  outputSchema?: Record<string, unknown>;
51
121
  /** If set, the tool is only listed and callable when the caller's scopes
@@ -135,6 +205,21 @@ export type McpServerConfig<Caller> = {
135
205
  instructions?: string;
136
206
  /** The token issuer — used for discovery metadata and the challenge URL. */
137
207
  issuer: string;
208
+ /** Turn on elicitation (server asks the USER a question mid-tool-call).
209
+ * Off by default, because it makes the endpoint SESSION-STATEFUL: the
210
+ * client answers on a separate HTTP request, so the pending call has to be
211
+ * remembered in-process. Run one instance, or pin `Mcp-Session-Id`. Tools
212
+ * must also opt in with `mayElicit`. */
213
+ elicitation?: {
214
+ /** Route answers to the instance that asked. Required to run more than one
215
+ * instance without sticky sessions. */
216
+ bus?: McpElicitBus;
217
+ enabled: true;
218
+ /** Shared session state. Required to run more than one instance. */
219
+ store?: McpSessionStore;
220
+ /** How long a question waits for a human before it gives up (default 2m). */
221
+ timeoutMs?: number;
222
+ };
138
223
  /** Page size for tools/prompts/resources list pagination (default 50). */
139
224
  listPageSize?: number;
140
225
  /** Fired after every `tools/call` for auditing. `meta` carries anything the
package/package.json CHANGED
@@ -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.2.0"
40
+ "version": "0.4.0"
41
41
  }