@acid-cat/core-ts 0.9.0 → 0.10.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.
@@ -0,0 +1,181 @@
1
+ import { systemClock } from "./clock.js";
2
+ import { endMessage, invalidParams, subscriptionParams } from "./mcp-events.js";
3
+ import { isMaxAgeMs } from "./session.js";
4
+ import { MCP_EVENT_ERRORS, MCP_POLL_DEFAULTS, canonicalJson, decodePollCursor, encodePollCursor, mcpSubscribeError } from "@acid-cat/spec-ts";
5
+ // src/mcp-poll.ts
6
+ /**
7
+ * Poll delivery through MCP (spec/bindings/mcp.md#poll): `events/poll` reads the occurrences of a poll lease. A lease
8
+ * holds one subscription, in a session of its own, and a buffer of its occurrences. The HTTP and the stdio transports
9
+ * share this module. It works in all runtimes.
10
+ */
11
+ /** The poll leases of one server. `close` ends them all. */
12
+ var PollLeases = class {
13
+ #leases = /* @__PURE__ */ new Map();
14
+ /** The leases that start, by key. A second poll with the same key waits for the first. */
15
+ #starting = /* @__PURE__ */ new Map();
16
+ #leaseMs;
17
+ #bufferLimit;
18
+ #maxEvents;
19
+ #nextPollMs;
20
+ #clock;
21
+ #closed = false;
22
+ constructor(options = {}, clock = systemClock) {
23
+ this.#leaseMs = options.leaseMs ?? MCP_POLL_DEFAULTS.leaseMs;
24
+ this.#bufferLimit = options.bufferLimit ?? MCP_POLL_DEFAULTS.bufferLimit;
25
+ this.#maxEvents = options.maxEvents ?? MCP_POLL_DEFAULTS.maxEvents;
26
+ this.#nextPollMs = options.nextPollMs ?? MCP_POLL_DEFAULTS.nextPollMs;
27
+ this.#clock = clock;
28
+ }
29
+ /** The number of leases, for tests. */
30
+ get size() {
31
+ return this.#leases.size;
32
+ }
33
+ /**
34
+ * Answer `events/poll`. `principal` is the identity of the client, or `undefined` if the deployment gives none.
35
+ * `open` opens the session of a new lease.
36
+ */
37
+ async poll(principal, params, open) {
38
+ const read = subscriptionParams(params);
39
+ if ("error" in read) return read;
40
+ const { maxEvents = this.#maxEvents } = params;
41
+ if (typeof maxEvents !== "number" || !Number.isSafeInteger(maxEvents) || maxEvents < 1) return invalidParams("maxEvents must be a positive integer.");
42
+ const key = canonicalJson([
43
+ principal ?? null,
44
+ read.name,
45
+ read.input
46
+ ]);
47
+ await this.#starting.get(key);
48
+ const lease = this.#leases.get(key);
49
+ if (lease?.failure !== void 0) {
50
+ await this.#end(lease);
51
+ return { error: {
52
+ code: MCP_EVENT_ERRORS.internal,
53
+ message: lease.failure
54
+ } };
55
+ }
56
+ const position = read.cursor === void 0 ? void 0 : decodePollCursor(read.cursor);
57
+ if (lease === void 0) {
58
+ const provider = read.cursor === void 0 ? void 0 : position?.provider;
59
+ const maxAgeMs = provider === void 0 && isMaxAgeMs(read.maxAgeMs) ? void 0 : read.maxAgeMs;
60
+ const started = this.#start(key, read.name, read.input, provider, maxAgeMs, open);
61
+ this.#starting.set(key, started);
62
+ const answer = await started.finally(() => this.#starting.delete(key));
63
+ if ("error" in answer) return answer;
64
+ return this.#result([], this.#cursorOf(answer, 0), read.cursor !== void 0 && provider === void 0, false);
65
+ }
66
+ if (!isMaxAgeMs(read.maxAgeMs)) return invalidParams("maxAgeMs must be an integer that is 0 or more.");
67
+ this.#renew(lease);
68
+ if (read.cursor === void 0) return this.#result([], this.#cursorOf(lease, lease.newest), false, false);
69
+ if (position?.lease === lease.id) return this.#read(lease, position.position, maxEvents, read.cursor);
70
+ return this.#result([], this.#cursorOf(lease, lease.newest), true, false);
71
+ }
72
+ /** End all leases. */
73
+ async close() {
74
+ this.#closed = true;
75
+ await Promise.allSettled(this.#starting.values());
76
+ await Promise.allSettled([...this.#leases.values()].map((lease) => this.#end(lease)));
77
+ }
78
+ /** Start a lease: open its session, subscribe, and keep the occurrences. A failure before the first occurrence starts no lease. */
79
+ async #start(key, name, input, provider, maxAgeMs, open) {
80
+ const own = await open();
81
+ const subscribed = await own.session.subscribe(name, input, {
82
+ ...provider === void 0 ? {} : { cursor: provider },
83
+ ...maxAgeMs === void 0 ? {} : { maxAgeMs }
84
+ });
85
+ if ("kind" in subscribed || this.#closed) {
86
+ if (!("kind" in subscribed)) await subscribed.return();
87
+ await own.close();
88
+ if ("kind" in subscribed) return { error: mcpSubscribeError(subscribed) };
89
+ return { error: {
90
+ code: MCP_EVENT_ERRORS.internal,
91
+ message: "The server is closing."
92
+ } };
93
+ }
94
+ const lease = {
95
+ id: crypto.randomUUID(),
96
+ key,
97
+ own,
98
+ occurrences: subscribed,
99
+ entries: [],
100
+ newest: 0,
101
+ startProvider: provider,
102
+ lastProvider: provider,
103
+ failure: void 0,
104
+ ended: false,
105
+ cancelTimer: () => {}
106
+ };
107
+ this.#leases.set(key, lease);
108
+ this.#renew(lease);
109
+ this.#pump(lease);
110
+ return lease;
111
+ }
112
+ /** Read the occurrences of the subscription of a lease into its buffer, until the subscription ends. */
113
+ async #pump(lease) {
114
+ try {
115
+ for (let step = await lease.occurrences.next(); step.done !== true; step = await lease.occurrences.next()) {
116
+ const occurrence = step.value;
117
+ if (occurrence.cursor !== void 0) lease.lastProvider = occurrence.cursor;
118
+ lease.newest += 1;
119
+ lease.entries.push({
120
+ position: lease.newest,
121
+ occurrence,
122
+ provider: lease.lastProvider
123
+ });
124
+ if (lease.entries.length > this.#bufferLimit) lease.entries.shift();
125
+ }
126
+ } catch (error) {
127
+ if (!lease.ended) lease.failure = endMessage(error);
128
+ }
129
+ if (!lease.ended) await lease.own.close();
130
+ }
131
+ /** Renew a lease: it ends when it gets no poll for the lease time. */
132
+ #renew(lease) {
133
+ lease.cancelTimer();
134
+ lease.cancelTimer = this.#clock.schedule(() => void this.#end(lease), this.#leaseMs);
135
+ }
136
+ /** End a lease: cancel its subscription, close its session, and forget it. */
137
+ async #end(lease) {
138
+ if (lease.ended) return;
139
+ lease.ended = true;
140
+ lease.cancelTimer();
141
+ if (this.#leases.get(lease.key) === lease) this.#leases.delete(lease.key);
142
+ await lease.occurrences.return();
143
+ await lease.own.close();
144
+ }
145
+ /** The occurrences of a lease after a position, at most `maxEvents`. */
146
+ #read(lease, after, maxEvents, requestCursor) {
147
+ const older = after < (lease.entries[0]?.position ?? lease.newest + 1) - 1;
148
+ const available = lease.entries.filter((entry) => entry.position > after);
149
+ const events = available.slice(0, maxEvents);
150
+ const last = events.at(-1);
151
+ const truncated = older || events.some((entry) => entry.occurrence.truncated === true);
152
+ return this.#result(events.map((entry) => withoutTruncated(entry.occurrence)), last === void 0 ? requestCursor : this.#cursorOf(lease, last.position), truncated, available.length > maxEvents);
153
+ }
154
+ /** The cursor of a position of a lease. */
155
+ #cursorOf(lease, position) {
156
+ const provider = position === 0 ? lease.startProvider : position >= lease.newest ? lease.lastProvider : lease.entries.find((entry) => entry.position === position)?.provider;
157
+ return encodePollCursor(provider === void 0 ? {
158
+ lease: lease.id,
159
+ position
160
+ } : {
161
+ lease: lease.id,
162
+ position,
163
+ provider
164
+ });
165
+ }
166
+ #result(events, cursor, truncated, hasMore) {
167
+ return { result: {
168
+ events: [...events],
169
+ cursor,
170
+ truncated,
171
+ hasMore,
172
+ nextPollMs: this.#nextPollMs
173
+ } };
174
+ }
175
+ };
176
+ /** An occurrence without `truncated`, as an item of `events`. */
177
+ function withoutTruncated(occurrence) {
178
+ const { truncated: _, ...rest } = occurrence;
179
+ return { ...rest };
180
+ }
181
+ export { PollLeases };
@@ -1,4 +1,6 @@
1
1
  import type { App, ToJsonSchema } from "./app.js";
2
+ import type { Clock } from "./clock.js";
3
+ import type { PollOptions } from "./mcp-poll.js";
2
4
  export interface McpStdioOptions<Context> {
3
5
  readonly name: string;
4
6
  readonly version: string;
@@ -9,6 +11,17 @@ export interface McpStdioOptions<Context> {
9
11
  readonly toJsonSchema: ToJsonSchema;
10
12
  /** The target that the server serves. The default is `"mcp"`. */
11
13
  readonly target?: string | undefined;
14
+ /** The time with nothing sent on an `events/stream` request after which the server sends a heartbeat, in milliseconds. The default is 15 seconds. */
15
+ readonly heartbeatMs?: number | undefined;
16
+ /** The number of occurrences that each subscription keeps for a client that reads slowly. The default is 256. */
17
+ readonly bufferLimit?: number | undefined;
18
+ /**
19
+ * The settings of poll delivery: the lease time, the size of the buffer, `maxEvents` and `nextPollMs`. Each lease
20
+ * has a session of its own with `context`. The leases have no identity, because the process serves one client.
21
+ */
22
+ readonly poll?: PollOptions | undefined;
23
+ /** The clock of the poll leases. The default is the clock of the system. Tests give a clock that they control. */
24
+ readonly clock?: Clock | undefined;
12
25
  }
13
26
  export interface McpStdioIo {
14
27
  /** Standard input: bytes or text, one JSON-RPC message on each line. */
package/dist/mcp-stdio.js CHANGED
@@ -1,7 +1,9 @@
1
- import { createSession } from "./session.js";
2
1
  import { hasObjectOutput, listTools } from "./mcp.js";
3
- import { MCP_PROTOCOL_VERSION, MCP_UNKNOWN_TOOL, isJsonObject, mcpToolResult } from "@acid-cat/spec-ts";
4
- //#region src/mcp-stdio.ts
2
+ import { eventCapabilities, listEvents, servesEvents, startEventStream, unsupportedWebhook } from "./mcp-events.js";
3
+ import { PollLeases } from "./mcp-poll.js";
4
+ import { createSession } from "./session.js";
5
+ import { KEEPALIVE_MS, MCP_PROTOCOL_VERSION, MCP_UNKNOWN_TOOL, isJsonObject, mcpToolResult } from "@acid-cat/spec-ts";
6
+ // src/mcp-stdio.ts
5
7
  /**
6
8
  * The MCP binding through standard input and standard output (spec/bindings/mcp.md#stdio). The process is one session.
7
9
  * The server serves revision 2026-07-28, and the `initialize` handshake of revisions 2025-11-25 and 2025-06-18.
@@ -30,13 +32,32 @@ async function runMcpStdio(app, options, io) {
30
32
  const session = createSession(app, {
31
33
  target: options.target ?? "mcp",
32
34
  context: options.context,
33
- toJsonSchema: options.toJsonSchema
35
+ toJsonSchema: options.toJsonSchema,
36
+ ...options.bufferLimit === void 0 ? {} : { bufferLimit: options.bufferLimit }
34
37
  });
35
38
  const instructions = options.instructions ?? app.description;
39
+ const open = () => {
40
+ const own = createSession(app, {
41
+ target: options.target ?? "mcp",
42
+ context: options.context,
43
+ toJsonSchema: options.toJsonSchema,
44
+ ...options.bufferLimit === void 0 ? {} : { bufferLimit: options.bufferLimit }
45
+ });
46
+ return Promise.resolve({
47
+ session: own,
48
+ close: own.dispose
49
+ });
50
+ };
51
+ const leases = new PollLeases(options.poll, options.clock);
36
52
  const server = new StdioServer(session, {
37
- ...options,
38
- instructions
39
- }, io.write);
53
+ name: options.name,
54
+ version: options.version,
55
+ instructions,
56
+ heartbeatMs: options.heartbeatMs ?? KEEPALIVE_MS
57
+ }, io.write, {
58
+ leases,
59
+ open
60
+ });
40
61
  try {
41
62
  for await (const line of lines(io.input)) server.receive(line);
42
63
  } finally {
@@ -73,14 +94,23 @@ var StdioServer = class {
73
94
  #write;
74
95
  /** The calls in progress, by request id. `notifications/cancelled` aborts them. */
75
96
  #calls = /* @__PURE__ */ new Map();
97
+ /**
98
+ * The `events/stream` requests in progress, by request id. `notifications/cancelled` aborts them. When the session
99
+ * closes, their subscriptions end as complete, so they are not aborted then.
100
+ */
101
+ #streams = /* @__PURE__ */ new Map();
102
+ /** The work of each `events/stream` request. `close` waits for it, so that the last result is written. */
103
+ #streaming = /* @__PURE__ */ new Set();
76
104
  /** The open `subscriptions/listen` requests of revision 2026-07-28 that asked for tool list changes. */
77
105
  #subscriptions = /* @__PURE__ */ new Set();
78
106
  /** True after the `initialize` handshake: the client then gets `notifications/tools/list_changed` with no subscription. */
79
107
  #initialized = false;
80
108
  #listed;
81
109
  #changes;
82
- constructor(session, options, write) {
110
+ #poll;
111
+ constructor(session, options, write, poll) {
83
112
  this.#session = session;
113
+ this.#poll = poll;
84
114
  this.#options = options;
85
115
  this.#write = write;
86
116
  this.#listed = this.#availableIds();
@@ -108,18 +138,21 @@ var StdioServer = class {
108
138
  id: message.id ?? null
109
139
  });
110
140
  }
111
- /** Close the session: stop the subscriptions, and cancel the calls in progress. */
141
+ /** Close the session: stop the subscriptions and the poll leases, and cancel the calls in progress. */
112
142
  async close() {
113
143
  this.#changes.unsubscribe();
144
+ await this.#poll.leases.close();
114
145
  this.#subscriptions.clear();
115
146
  for (const controller of this.#calls.values()) controller.abort();
116
147
  await this.#session.dispose();
148
+ await Promise.allSettled(this.#streaming);
117
149
  }
118
150
  #notification(message) {
119
151
  if (message.method !== "notifications/cancelled") return;
120
152
  const requestId = message.params["requestId"];
121
153
  if (typeof requestId !== "string" && typeof requestId !== "number") return;
122
154
  this.#calls.get(requestId)?.abort();
155
+ this.#streams.get(requestId)?.abort();
123
156
  this.#subscriptions.delete(requestId);
124
157
  }
125
158
  async #request(message) {
@@ -130,10 +163,7 @@ var StdioServer = class {
130
163
  if (modern && protocolVersion !== MCP_PROTOCOL_VERSION) return this.#error(id, -32022, `Unsupported protocol version ${JSON.stringify(protocolVersion)}`, { supported: SUPPORTED_VERSIONS });
131
164
  /** The result of a request: in revision 2026-07-28 it has the server information and a result type. */
132
165
  const complete = (result, list = false) => this.#result(id, modern ? {
133
- _meta: { "io.modelcontextprotocol/serverInfo": {
134
- name: this.#options.name,
135
- version: this.#options.version
136
- } },
166
+ _meta: this.#serverInfo(),
137
167
  resultType: "complete",
138
168
  ...list ? {
139
169
  ttlMs: 0,
@@ -144,14 +174,65 @@ var StdioServer = class {
144
174
  switch (method) {
145
175
  case "server/discover": return complete({
146
176
  supportedVersions: SUPPORTED_VERSIONS,
147
- capabilities: { tools: { listChanged: true } },
177
+ capabilities: {
178
+ tools: { listChanged: true },
179
+ ...eventCapabilities(this.#session)
180
+ },
148
181
  ...this.#options.instructions === void 0 ? {} : { instructions: this.#options.instructions }
149
182
  }, true);
150
183
  case "subscriptions/listen": return this.#listen(id, message.params);
151
184
  case "tools/list": return complete({ tools: listTools(this.#session) }, true);
152
185
  case "tools/call": return this.#call(id, message.params, complete);
153
- default: return this.#error(id, -32601, `Method not found: ${method}`);
154
186
  }
187
+ if (modern && servesEvents(this.#session)) switch (method) {
188
+ case "events/list": return complete({ events: listEvents(this.#session) }, true);
189
+ case "events/poll": {
190
+ const answered = await this.#poll.leases.poll(void 0, message.params, this.#poll.open);
191
+ if ("error" in answered) return this.#error(id, answered.error.code, answered.error.message, answered.error.data);
192
+ return complete(answered.result);
193
+ }
194
+ case "events/unsubscribe":
195
+ case "events/subscribe": {
196
+ const { code, message: text, data } = unsupportedWebhook;
197
+ return this.#error(id, code, text, data);
198
+ }
199
+ case "events/stream": if (id !== null) {
200
+ const work = this.#stream(id, message.params).finally(() => this.#streaming.delete(work));
201
+ this.#streaming.add(work);
202
+ return work;
203
+ }
204
+ }
205
+ return this.#error(id, -32601, `Method not found: ${method}`);
206
+ }
207
+ /**
208
+ * An `events/stream` request: the messages of the subscription go to standard output. `notifications/cancelled` with
209
+ * the id of the request cancels it, and then the server sends nothing more for it.
210
+ */
211
+ async #stream(id, params) {
212
+ const controller = new AbortController();
213
+ this.#streams.set(id, controller);
214
+ try {
215
+ const started = await startEventStream(this.#session, id, params, {
216
+ heartbeatMs: this.#options.heartbeatMs,
217
+ resultMeta: this.#serverInfo(),
218
+ signal: controller.signal
219
+ });
220
+ if (controller.signal.aborted) return;
221
+ if ("error" in started) return this.#error(id, started.error.code, started.error.message, started.error.data);
222
+ for (let messages = await started.messages(); messages !== void 0; messages = await started.messages()) {
223
+ if (controller.signal.aborted) return;
224
+ messages.forEach((message) => this.#send(message));
225
+ }
226
+ } finally {
227
+ if (this.#streams.get(id) === controller) this.#streams.delete(id);
228
+ }
229
+ }
230
+ /** The `_meta` of a result of revision 2026-07-28: the information about the server. */
231
+ #serverInfo() {
232
+ return { "io.modelcontextprotocol/serverInfo": {
233
+ name: this.#options.name,
234
+ version: this.#options.version
235
+ } };
155
236
  }
156
237
  /** The `initialize` handshake. The server selects the revision of the client if it serves it, or else its latest. */
157
238
  #initialize(id, offered) {
@@ -240,5 +321,4 @@ var StdioServer = class {
240
321
  this.#write(`${JSON.stringify(message)}\n`);
241
322
  }
242
323
  };
243
- //#endregion
244
324
  export { main, runMcpStdio };
package/dist/mcp.d.ts CHANGED
@@ -1,5 +1,9 @@
1
+ import type { JsonObject } from "@acid-cat/spec-ts";
1
2
  import type { App, ToJsonSchema } from "./app.js";
2
3
  import type { ContextOption } from "./context.js";
4
+ import type { Clock } from "./clock.js";
5
+ import type { WebhookDelivery } from "./mcp-events.js";
6
+ import type { PollOptions } from "./mcp-poll.js";
3
7
  import type { Session } from "./session.js";
4
8
  /** The options of `mcpHandler`. Give `context` to share one context, or `createContext` to make one for each request. */
5
9
  export type McpOptions<Context> = McpSettings & ContextOption<Context>;
@@ -18,15 +22,47 @@ export interface McpSettings {
18
22
  readonly target?: string | undefined;
19
23
  /** The maximum size of a request body, in bytes. The default is 1 MiB. */
20
24
  readonly maximumBytes?: number | undefined;
25
+ /** The time with nothing sent on an `events/stream` response after which the server sends a heartbeat, in milliseconds. The default is 15 seconds. */
26
+ readonly heartbeatMs?: number | undefined;
27
+ /** The number of occurrences that each subscription keeps for a client that reads slowly. The default is 256. */
28
+ readonly bufferLimit?: number | undefined;
29
+ /**
30
+ * The identity of the client of a request: the principal (spec/bindings/mcp.md#webhook). It is part of the key of a
31
+ * poll lease and of a webhook subscription. `undefined` means that the request has no identity. With no function, no
32
+ * request has an identity.
33
+ */
34
+ readonly principal?: ((request: Request) => string | undefined | Promise<string | undefined>) | undefined;
35
+ /** The settings of poll delivery: the lease time, the size of the buffer, `maxEvents` and `nextPollMs`. */
36
+ readonly poll?: PollOptions | undefined;
37
+ /**
38
+ * Webhook delivery. It is off when this member is absent. `webhookDelivery` of `@acid-cat/core-ts/node` makes it.
39
+ * A request to `events/subscribe` or `events/unsubscribe` with no principal gets `Forbidden`.
40
+ */
41
+ readonly webhooks?: WebhookDelivery | undefined;
42
+ /** The clock of the poll leases. The default is the clock of the system. Tests give a clock that they control. */
43
+ readonly clock?: Clock | undefined;
44
+ }
45
+ /**
46
+ * The web request handler of an MCP server through HTTP. `close` ends the poll leases and the webhook subscriptions.
47
+ * `await using` calls it at the end of its block.
48
+ */
49
+ export interface McpHandler {
50
+ (request: Request): Promise<Response>;
51
+ readonly close: () => Promise<void>;
52
+ readonly [Symbol.asyncDispose]: () => Promise<void>;
21
53
  }
22
- /** A web request handler for the MCP server. Each request has its own session, and with `createContext` its own context. */
23
- export declare function mcpHandler<Context>(app: App<Context>, options: McpOptions<Context>): (request: Request) => Promise<Response>;
54
+ /**
55
+ * A web request handler for the MCP server. Each request has its own session, and with `createContext` its own
56
+ * context. Each poll lease and each webhook subscription also has its own session, opened with the context of the
57
+ * request that started it.
58
+ */
59
+ export declare function mcpHandler<Context>(app: App<Context>, options: McpOptions<Context>): McpHandler;
24
60
  /** The tools of the available capabilities in the view of a session (spec/bindings/mcp.md#tools). */
25
61
  export declare function listTools(session: Session): {
26
62
  name: string;
27
63
  description: string;
28
- inputSchema: import("@acid-cat/spec-ts").JsonObject;
29
- outputSchema?: import("@acid-cat/spec-ts").JsonObject;
64
+ inputSchema: JsonObject;
65
+ outputSchema?: JsonObject;
30
66
  annotations: {
31
67
  readOnlyHint: boolean;
32
68
  };
package/dist/mcp.js CHANGED
@@ -1,17 +1,20 @@
1
+ import { openContext } from "./context.js";
2
+ import { eventCapabilities, forbidden, listEvents, servesEvents, startEventStream, unsupportedWebhook } from "./mcp-events.js";
3
+ import { PollLeases } from "./mcp-poll.js";
1
4
  import { createSession } from "./session.js";
2
- import { withContext } from "./context.js";
3
- import { MCP_PROTOCOL_VERSION, MCP_REJECTIONS, MCP_UNKNOWN_TOOL, bundle, isJsonObject, mcpToolResult, readBounded, screenRequest } from "@acid-cat/spec-ts";
4
- //#region src/mcp.ts
5
+ import { textStream } from "./stream.js";
6
+ import { KEEPALIVE_MS, MCP_PROTOCOL_VERSION, MCP_REJECTIONS, MCP_UNKNOWN_TOOL, SSE_MEDIA_TYPE, bundle, isJsonObject, mcpToolResult, readBounded, screenRequest, sseMessage } from "@acid-cat/spec-ts";
7
+ // src/mcp.ts
5
8
  /**
6
- * The MCP binding (spec/bindings/mcp.md): an MCP server through HTTP, revision 2026-07-28, with no state on the
7
- * server between requests.
9
+ * The MCP binding (spec/bindings/mcp.md): an MCP server through HTTP, revision 2026-07-28. The server keeps no state
10
+ * between requests, except poll leases and webhook subscriptions.
8
11
  */
9
12
  /**
10
13
  * Read the members of a JSON-RPC request from a parsed body. A member that does not have its type is left out. An
11
14
  * `id` that is not a string or a number is `null`.
12
15
  */
13
16
  function readRequest(body) {
14
- if (!isJsonObject(body)) return {};
17
+ if (!isJsonObject(body)) return { params: {} };
15
18
  const { id, method, params } = body;
16
19
  const meta = isJsonObject(params) ? params["_meta"] : void 0;
17
20
  return {
@@ -19,13 +22,22 @@ function readRequest(body) {
19
22
  method: typeof method === "string" ? method : void 0,
20
23
  name: isJsonObject(params) && typeof params["name"] === "string" ? params["name"] : void 0,
21
24
  arguments: isJsonObject(params) ? params["arguments"] : void 0,
25
+ params: isJsonObject(params) ? params : {},
22
26
  protocolVersion: isJsonObject(meta) ? meta["io.modelcontextprotocol/protocolVersion"] : void 0
23
27
  };
24
28
  }
25
- /** A web request handler for the MCP server. Each request has its own session, and with `createContext` its own context. */
29
+ /**
30
+ * A web request handler for the MCP server. Each request has its own session, and with `createContext` its own
31
+ * context. Each poll lease and each webhook subscription also has its own session, opened with the context of the
32
+ * request that started it.
33
+ */
26
34
  function mcpHandler(app, options) {
27
35
  const maximumBytes = options.maximumBytes ?? 1048576;
28
- return async (request) => {
36
+ const leases = new PollLeases(options.poll, options.clock);
37
+ const closeAll = async () => {
38
+ await Promise.allSettled([leases.close(), options.webhooks?.close()]);
39
+ };
40
+ const handle = async (request) => {
29
41
  const screened = screenRequest(request, {
30
42
  methods: ["POST"],
31
43
  allowedOrigins: options.allowedOrigins,
@@ -52,30 +64,91 @@ function mcpHandler(app, options) {
52
64
  const metaVersion = message.protocolVersion;
53
65
  if (metaVersion === void 0) return error(400, id, -32602, "Required request metadata is missing");
54
66
  if (metaVersion !== version) return error(400, id, -32020, "MCP-Protocol-Version header does not match request metadata");
55
- return withContext(options, request, async (context) => {
67
+ const open = async () => {
68
+ const opened = await openContext(options, request);
56
69
  const session = createSession(app, {
57
70
  target: options.target ?? "mcp",
58
- context,
59
- toJsonSchema: options.toJsonSchema
71
+ context: opened.context,
72
+ toJsonSchema: options.toJsonSchema,
73
+ ...options.bufferLimit === void 0 ? {} : { bufferLimit: options.bufferLimit }
60
74
  });
61
- try {
62
- return await answer(session, message, {
63
- ...options,
64
- instructions: options.instructions ?? app.description
65
- }, request.signal);
66
- } finally {
75
+ let closing;
76
+ const close = () => closing ??= (async () => {
67
77
  await session.dispose();
78
+ await opened.release();
79
+ })();
80
+ return {
81
+ session,
82
+ close
83
+ };
84
+ };
85
+ const { session, close } = await open();
86
+ let streaming = false;
87
+ try {
88
+ if (message.method === "events/stream" && message.id !== null && servesEvents(session)) {
89
+ const started = await startEventStream(session, message.id, message.params, {
90
+ heartbeatMs: options.heartbeatMs ?? KEEPALIVE_MS,
91
+ resultMeta: serverInfoOf(options),
92
+ signal: request.signal
93
+ });
94
+ if ("error" in started) return error(200, id, started.error.code, started.error.message, started.error.data);
95
+ const { messages, cancel } = started;
96
+ const produce = async () => (await messages())?.map((each) => sseMessage({ data: JSON.stringify(each) }));
97
+ const body = textStream(produce, cancel, close);
98
+ streaming = true;
99
+ return new Response(body, {
100
+ status: 200,
101
+ headers: {
102
+ "Content-Type": SSE_MEDIA_TYPE,
103
+ "Cache-Control": "no-store"
104
+ }
105
+ });
68
106
  }
69
- });
107
+ if (servesEvents(session) && message.method !== void 0 && LONG_LIVED.has(message.method)) {
108
+ const principal = await options.principal?.(request);
109
+ const answered = await longLived(message.method, message.params, principal, open, leases, options.webhooks);
110
+ if ("error" in answered) return error(200, id, answered.error.code, answered.error.message, answered.error.data);
111
+ return reply(id, {
112
+ _meta: serverInfoOf(options),
113
+ resultType: "complete",
114
+ ...answered.result
115
+ });
116
+ }
117
+ return await answer(session, message, {
118
+ ...options,
119
+ instructions: options.instructions ?? app.description
120
+ }, request.signal);
121
+ } finally {
122
+ if (!streaming) await close();
123
+ }
70
124
  };
125
+ return Object.assign(handle, {
126
+ close: closeAll,
127
+ [Symbol.asyncDispose]: closeAll
128
+ });
129
+ }
130
+ /** The methods whose state outlives the request (spec/bindings/mcp.md#subscriptions-that-outlive-a-request). */
131
+ const LONG_LIVED = /* @__PURE__ */ new Set([
132
+ "events/poll",
133
+ "events/subscribe",
134
+ "events/unsubscribe"
135
+ ]);
136
+ /** Answer `events/poll`, `events/subscribe` or `events/unsubscribe`. */
137
+ async function longLived(method, params, principal, open, leases, webhooks) {
138
+ if (method === "events/poll") return leases.poll(principal, params, open);
139
+ if (webhooks === void 0) return { error: unsupportedWebhook };
140
+ if (principal === void 0) return { error: forbidden };
141
+ return method === "events/subscribe" ? webhooks.subscribe(principal, params, open) : webhooks.unsubscribe(principal, params);
71
142
  }
143
+ /** The `_meta` of a result: the information about the server. */
144
+ const serverInfoOf = (options) => ({ "io.modelcontextprotocol/serverInfo": {
145
+ name: options.name,
146
+ version: options.version
147
+ } });
72
148
  async function answer(session, message, options, signal) {
73
149
  const id = message.id ?? null;
74
150
  const complete = (result, list = false) => reply(id, {
75
- _meta: { "io.modelcontextprotocol/serverInfo": {
76
- name: options.name,
77
- version: options.version
78
- } },
151
+ _meta: serverInfoOf(options),
79
152
  resultType: "complete",
80
153
  ...list ? {
81
154
  ttlMs: 0,
@@ -86,7 +159,10 @@ async function answer(session, message, options, signal) {
86
159
  switch (message.method) {
87
160
  case "server/discover": return complete({
88
161
  supportedVersions: [MCP_PROTOCOL_VERSION],
89
- capabilities: { tools: { listChanged: false } },
162
+ capabilities: {
163
+ tools: { listChanged: false },
164
+ ...eventCapabilities(session)
165
+ },
90
166
  ...options.instructions === void 0 ? {} : { instructions: options.instructions }
91
167
  }, true);
92
168
  case "tools/list": return complete({ tools: listTools(session) }, true);
@@ -97,8 +173,11 @@ async function answer(session, message, options, signal) {
97
173
  const outcome = await session.invoke(name, message.arguments ?? {}, { signal });
98
174
  return complete(mcpToolResult(outcome, hasObjectOutput(session, name)));
99
175
  }
100
- default: return error(404, id, -32601, `Method not found: ${message.method}`);
176
+ case "events/list":
177
+ if (!servesEvents(session)) break;
178
+ return complete({ events: listEvents(session, options.webhooks !== void 0) }, true);
101
179
  }
180
+ return error(404, id, -32601, `Method not found: ${message.method}`);
102
181
  }
103
182
  /** The tools of the available capabilities in the view of a session (spec/bindings/mcp.md#tools). */
104
183
  function listTools(session) {
@@ -174,5 +253,4 @@ const messages = {
174
253
  "too-large": "The request body is too large.",
175
254
  "not-utf8": "The request body is not UTF-8 JSON."
176
255
  };
177
- //#endregion
178
256
  export { hasObjectOutput, listTools, mcpHandler };
package/dist/node.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export { webhookDelivery } from "./webhook.js";
2
+ export type { WebhookOptions } from "./webhook.js";
1
3
  /** A web request handler. */
2
4
  export type WebHandler = (request: Request) => Promise<Response> | Response;
3
5
  /**
package/dist/node.js CHANGED
@@ -1,8 +1,11 @@
1
+ import { webhookDelivery } from "./webhook.js";
1
2
  import { createServer } from "node:http";
2
- //#region src/node.ts
3
+ // src/node.ts
3
4
  /**
4
5
  * The functions that need Node.js. The main entry point, `@acid-cat/core-ts`, works in all runtimes.
5
6
  *
7
+ * `webhookDelivery` sends the occurrences of webhook subscriptions of the MCP server through HTTP, with `node:https`.
8
+ *
6
9
  * `serve` serves web request handlers, `(request: Request) => Promise<Response>`, such as the handlers of `mcpHandler`
7
10
  * and `httpHandler`, on Node.js, with only `node:http`. The server serves plain HTTP/1.1. For HTTPS, HTTP/2 or
8
11
  * middleware, use a library for web handlers.
@@ -107,6 +110,7 @@ async function send(response, outgoing) {
107
110
  outgoing.end();
108
111
  return;
109
112
  }
113
+ outgoing.flushHeaders();
110
114
  const reader = response.body.getReader();
111
115
  const cancel = () => void reader.cancel().catch(() => void 0);
112
116
  outgoing.on("close", cancel);
@@ -168,5 +172,4 @@ function bodyOf(incoming) {
168
172
  }
169
173
  }, { highWaterMark: 0 });
170
174
  }
171
- //#endregion
172
- export { serve };
175
+ export { serve, webhookDelivery };