@absolutejs/mcp 0.3.0 → 0.4.1

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
@@ -177,11 +177,40 @@ spec also forbids eliciting **sensitive information**.
177
177
  **The trade-off, stated plainly.** Elicitation is the one MCP feature a
178
178
  stateless server cannot do: the question goes out on the SSE stream of an
179
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.
180
+ requests have to meet, so the endpoint becomes **session-stateful**
181
+ (`Mcp-Session-Id`). Leave `elicitation` off — the default — and nothing changes:
182
+ the server stays stateless, `tools/call` keeps answering with a plain JSON body,
183
+ and only tools marked `mayElicit` ever stream.
184
+
185
+ **Running more than one instance.** Behind one server the defaults handle it.
186
+ Behind several, two different things break, and each has a seam:
187
+
188
+ ```ts
189
+ elicitation: {
190
+ enabled: true,
191
+ // (1) The client initializes on A and calls a tool on B, which has never
192
+ // heard of the session. Put session state where every instance sees it.
193
+ // It is an id and a boolean — nothing sensitive, nothing large.
194
+ store: {
195
+ create: ({ canElicit }) => db.insertSession(canElicit), // → id
196
+ get: (id) => db.findSession(id), // → { canElicit } | null
197
+ drop: (id) => db.deleteSession(id),
198
+ },
199
+ // (2) The tool call and its question live on ONE instance, but the user's
200
+ // answer POST can land on any of them. A promise cannot move, so route
201
+ // the answer to the instance that is waiting — over whatever fan-out you
202
+ // already run (Postgres LISTEN/NOTIFY, Redis, …).
203
+ bus: {
204
+ publish: (answer) => notify("mcp_elicit", answer),
205
+ subscribe: (handler) => listen("mcp_elicit", handler),
206
+ },
207
+ }
208
+ ```
209
+
210
+ Supply neither and run a single instance (or pin sessions). Supply both and
211
+ elicitation is safe behind a load balancer with **no sticky routing** — there is
212
+ a test for exactly that: instance A asks, the answer lands on B, the bus carries
213
+ it back, and A's call finishes.
185
214
 
186
215
  Consuming a server that elicits? Pass `onElicit` to `createMcpClient` — that is
187
216
  what declares the capability, and what the package uses to answer. Omit it and
package/dist/index.js CHANGED
@@ -345,7 +345,7 @@ var clientCanElicit = (params) => {
345
345
  return false;
346
346
  return isRecord(params.capabilities.elicitation);
347
347
  };
348
- var initialize = (config, id, params, context) => {
348
+ var initialize = async (config, id, params, context) => {
349
349
  const supported = config.supportedProtocols ?? DEFAULT_PROTOCOLS;
350
350
  const capabilities = {
351
351
  tools: { listChanged: false }
@@ -363,7 +363,7 @@ var initialize = (config, id, params, context) => {
363
363
  });
364
364
  if (!config.elicitation?.enabled || !context.sessions)
365
365
  return response;
366
- const sessionId = context.sessions.create(clientCanElicit(params));
366
+ const sessionId = await context.sessions.create(clientCanElicit(params));
367
367
  response.headers.set("Mcp-Session-Id", sessionId);
368
368
  return response;
369
369
  };
@@ -418,7 +418,7 @@ var runTool = async (config, caller, id, name, args, meta, tool, context) => {
418
418
  await config.onCall({ args, caller, meta, name, ok });
419
419
  return payload;
420
420
  };
421
- var toolsCallStreaming = (config, caller, id, name, args, meta, tool, sessions, sessionId, canElicit) => {
421
+ var toolsCallStreaming = (config, caller, id, name, args, meta, tool, sessions, canElicit) => {
422
422
  const encoder = new TextEncoder;
423
423
  const body = new ReadableStream({
424
424
  async start(controller) {
@@ -437,9 +437,7 @@ var toolsCallStreaming = (config, caller, id, name, args, meta, tool, sessions,
437
437
  elicit: async (request) => {
438
438
  if (!canElicit)
439
439
  return { action: "unsupported" };
440
- const pending = sessions.startElicit(sessionId, request);
441
- if (!pending.id)
442
- return { action: "cancel" };
440
+ const pending = sessions.startElicit(request);
443
441
  send({
444
442
  id: pending.id,
445
443
  jsonrpc: "2.0",
@@ -479,10 +477,10 @@ var toolsCall = async (config, caller, scopes, id, params, context) => {
479
477
  if (!tool || !scopeAllows(tool, scopes)) {
480
478
  return rpcError(id, JSONRPC_INVALID_PARAMS, `Unknown tool: ${name}`);
481
479
  }
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);
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);
486
484
  }
487
485
  const payload = await runTool(config, caller, id, name, args, meta, tool, noElicit);
488
486
  return new Response(JSON.stringify(payload), {
@@ -559,7 +557,11 @@ var elicitAnswer = (message, context) => {
559
557
  const result = isRecord(message.result) ? message.result : null;
560
558
  const action = result?.action;
561
559
  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);
560
+ context.sessions.resolveElicit({
561
+ requestId,
562
+ result: answer,
563
+ sessionId: context.sessionId ?? null
564
+ });
563
565
  return notificationAck();
564
566
  };
565
567
  var dispatchMcp = async (config, caller, scopes, message, context = {}) => {
@@ -574,7 +576,7 @@ var dispatchMcp = async (config, caller, scopes, message, context = {}) => {
574
576
  const method = typeof message.method === "string" ? message.method : "";
575
577
  const { params } = message;
576
578
  if (method === "initialize") {
577
- return initialize(config, id, params, context);
579
+ return await initialize(config, id, params, context);
578
580
  }
579
581
  if (method === "ping")
580
582
  return rpcResult(id, {});
@@ -694,9 +696,7 @@ var feedbackTools = (config) => ({
694
696
  var DEFAULT_SESSION_TTL_MS = 3600000;
695
697
  var DEFAULT_ELICIT_TIMEOUT_MS = 120000;
696
698
  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;
699
+ var createMemoryStore = (ttlMs) => {
700
700
  const sessions = new Map;
701
701
  let sinceSweep = 0;
702
702
  const sweep = () => {
@@ -706,66 +706,66 @@ var createSessionRegistry = (options) => {
706
706
  sinceSweep = 0;
707
707
  const cutoff = Date.now() - ttlMs;
708
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);
709
+ if (session.lastSeen < cutoff)
710
+ sessions.delete(id);
716
711
  });
717
712
  };
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
713
  return {
728
- create: (canElicit) => {
714
+ create: (session) => {
729
715
  sweep();
730
716
  const id = crypto.randomUUID();
731
- sessions.set(id, { canElicit, lastSeen: Date.now(), pending: new Map });
717
+ sessions.set(id, { canElicit: session.canElicit, lastSeen: Date.now() });
732
718
  return id;
733
719
  },
734
720
  drop: (id) => {
735
- const session = sessions.get(id);
736
- session?.pending.forEach((pending) => {
737
- clearTimeout(pending.timer);
738
- pending.resolve({ action: "cancel" });
739
- });
740
721
  sessions.delete(id);
741
722
  },
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;
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;
752
760
  },
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
- }
761
+ startElicit: (request) => {
762
762
  const id = `elicit_${crypto.randomUUID()}`;
763
763
  const answer = new Promise((resolve) => {
764
764
  const timer = setTimeout(() => {
765
- session.pending.delete(id);
765
+ pending.delete(id);
766
766
  resolve({ action: "cancel" });
767
767
  }, elicitTimeoutMs);
768
- session.pending.set(id, { resolve, timer });
768
+ pending.set(id, { resolve, timer });
769
769
  });
770
770
  return { answer, id, request };
771
771
  }
@@ -787,6 +787,9 @@ var JSON_HEADERS = {
787
787
  };
788
788
  var HTTP_NOT_FOUND = 404;
789
789
  var registries = new WeakMap;
790
+ var primeMcpSessions = (config) => {
791
+ registryFor(config);
792
+ };
790
793
  var registryFor = (config) => {
791
794
  if (!config.elicitation?.enabled)
792
795
  return;
@@ -794,7 +797,9 @@ var registryFor = (config) => {
794
797
  if (existing)
795
798
  return existing;
796
799
  const created = createSessionRegistry({
797
- elicitTimeoutMs: config.elicitation.timeoutMs
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 }
798
803
  });
799
804
  registries.set(config, created);
800
805
  return created;
@@ -817,7 +822,7 @@ var runMcpPost = async (config, request, body) => {
817
822
  }
818
823
  const sessions = registryFor(config);
819
824
  const sessionId = request.headers.get("mcp-session-id");
820
- if (sessions && sessionId && !sessions.get(sessionId)) {
825
+ if (sessions && sessionId && !await sessions.get(sessionId)) {
821
826
  return new Response(null, { status: HTTP_NOT_FOUND });
822
827
  }
823
828
  return dispatchMcp(config, auth.caller, auth.scopes ?? [], body, {
@@ -825,13 +830,13 @@ var runMcpPost = async (config, request, body) => {
825
830
  sessions
826
831
  }).catch(() => rpcError(null, JSONRPC_INVALID_REQUEST, "Internal error"));
827
832
  };
828
- var runMcpDelete = (config, request) => {
833
+ var runMcpDelete = async (config, request) => {
829
834
  const sessions = registryFor(config);
830
835
  const sessionId = request.headers.get("mcp-session-id");
831
836
  if (!sessions || !sessionId) {
832
837
  return new Response(null, { status: HTTP_METHOD_NOT_ALLOWED });
833
838
  }
834
- sessions.drop(sessionId);
839
+ await sessions.drop(sessionId);
835
840
  return new Response(null, { status: HTTP_NO_CONTENT });
836
841
  };
837
842
  var handleMcpRequest = async (config, request) => {
@@ -861,11 +866,15 @@ var handleMcpRequest = async (config, request) => {
861
866
  };
862
867
 
863
868
  // src/handler.ts
864
- var createMcpHandler = (config) => (request) => handleMcpRequest(config, request);
869
+ var createMcpHandler = (config) => {
870
+ primeMcpSessions(config);
871
+ return (request) => handleMcpRequest(config, request);
872
+ };
865
873
  // src/server.ts
866
874
  import { Elysia } from "elysia";
867
875
  var mcpServer = (config) => {
868
876
  const metadataPath = metadataPathFor(config.path);
877
+ primeMcpSessions(config);
869
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));
870
879
  const app = config.serveRootMetadata ? base.get(ROOT_METADATA_PATH, () => metadataResponse(config)) : base;
871
880
  return app;
@@ -1,5 +1,10 @@
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
@@ -8,7 +13,7 @@ export declare const runMcpPost: <Caller>(config: McpServerConfig<Caller>, reque
8
13
  /** The client is done with its session and says so (spec: Session Management).
9
14
  * Only meaningful when elicitation put us in a session at all — otherwise 405,
10
15
  * which the spec explicitly allows for servers that don't do sessions. */
11
- export declare const runMcpDelete: <Caller>(config: McpServerConfig<Caller>, request: Request) => Response;
16
+ export declare const runMcpDelete: <Caller>(config: McpServerConfig<Caller>, request: Request) => Promise<Response>;
12
17
  /** The full path-aware handler over web-standard Request/Response. Returns a
13
18
  * Response for any MCP route (POST endpoint, GET 405, discovery metadata) and
14
19
  * `null` for anything else, so a host can compose it with its own routes. */
@@ -1,34 +1,25 @@
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
- };
1
+ import type { McpElicitAnswer, McpElicitBus, McpElicitResult, McpElicitationRequest, McpSessionStore } from "./types";
13
2
  export type SessionRegistry = ReturnType<typeof createSessionRegistry>;
14
3
  export declare const createSessionRegistry: (options?: {
4
+ bus?: McpElicitBus;
15
5
  elicitTimeoutMs?: number;
6
+ store?: McpSessionStore;
16
7
  ttlMs?: number;
17
8
  }) => {
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) => {
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) => {
29
21
  answer: Promise<McpElicitResult>;
30
22
  id: string;
31
23
  request: McpElicitationRequest;
32
24
  };
33
25
  };
34
- export {};
@@ -64,6 +64,36 @@ export type McpElicitResult = {
64
64
  } | {
65
65
  action: "unsupported";
66
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
+ };
67
97
  /** Passed to a tool handler as its second argument. Ignore it and nothing
68
98
  * changes — every existing handler keeps working. */
69
99
  export type McpToolCallContext = {
@@ -181,7 +211,13 @@ export type McpServerConfig<Caller> = {
181
211
  * remembered in-process. Run one instance, or pin `Mcp-Session-Id`. Tools
182
212
  * must also opt in with `mayElicit`. */
183
213
  elicitation?: {
214
+ /** Route answers to the instance that asked. Required to run more than one
215
+ * instance without sticky sessions. */
216
+ bus?: McpElicitBus;
184
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). */
185
221
  timeoutMs?: number;
186
222
  };
187
223
  /** Page size for tools/prompts/resources list pagination (default 50). */
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.3.0"
40
+ "version": "0.4.1"
41
41
  }