@acid-cat/core-ts 0.8.0 → 0.10.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.
@@ -0,0 +1,179 @@
1
+ import { endMessage, invalidParams, subscriptionParams } from "./mcp-events.js";
2
+ import { systemClock } from "./clock.js";
3
+ import { MCP_EVENT_ERRORS, MCP_POLL_DEFAULTS, canonicalJson, decodePollCursor, encodePollCursor, mcpSubscribeError } from "@acid-cat/spec-ts";
4
+ //#region src/mcp-poll.ts
5
+ /**
6
+ * Poll delivery through MCP (spec/bindings/mcp.md#poll): `events/poll` reads the occurrences of a poll lease. A lease
7
+ * holds one subscription, in a session of its own, and a buffer of its occurrences. The HTTP and the stdio transports
8
+ * share this module. It works in all runtimes.
9
+ */
10
+ /** The poll leases of one server. `close` ends them all. */
11
+ var PollLeases = class {
12
+ #leases = /* @__PURE__ */ new Map();
13
+ /** The leases that start, by key. A second poll with the same key waits for the first. */
14
+ #starting = /* @__PURE__ */ new Map();
15
+ #leaseMs;
16
+ #bufferLimit;
17
+ #maxEvents;
18
+ #nextPollMs;
19
+ #clock;
20
+ #closed = false;
21
+ constructor(options = {}, clock = systemClock) {
22
+ this.#leaseMs = options.leaseMs ?? MCP_POLL_DEFAULTS.leaseMs;
23
+ this.#bufferLimit = options.bufferLimit ?? MCP_POLL_DEFAULTS.bufferLimit;
24
+ this.#maxEvents = options.maxEvents ?? MCP_POLL_DEFAULTS.maxEvents;
25
+ this.#nextPollMs = options.nextPollMs ?? MCP_POLL_DEFAULTS.nextPollMs;
26
+ this.#clock = clock;
27
+ }
28
+ /** The number of leases, for tests. */
29
+ get size() {
30
+ return this.#leases.size;
31
+ }
32
+ /**
33
+ * Answer `events/poll`. `principal` is the identity of the client, or `undefined` if the deployment gives none.
34
+ * `open` opens the session of a new lease.
35
+ */
36
+ async poll(principal, params, open) {
37
+ const read = subscriptionParams(params);
38
+ if ("error" in read) return read;
39
+ const { maxEvents = this.#maxEvents } = params;
40
+ if (typeof maxEvents !== "number" || !Number.isSafeInteger(maxEvents) || maxEvents < 1) return invalidParams("maxEvents must be a positive integer.");
41
+ const key = canonicalJson([
42
+ principal ?? null,
43
+ read.name,
44
+ read.input
45
+ ]);
46
+ await this.#starting.get(key);
47
+ const lease = this.#leases.get(key);
48
+ if (lease?.failure !== void 0) {
49
+ await this.#end(lease);
50
+ return { error: {
51
+ code: MCP_EVENT_ERRORS.internal,
52
+ message: lease.failure
53
+ } };
54
+ }
55
+ const position = read.cursor === void 0 ? void 0 : decodePollCursor(read.cursor);
56
+ if (lease === void 0) {
57
+ const provider = read.cursor === void 0 ? void 0 : position?.provider;
58
+ const started = this.#start(key, read.name, read.input, provider, provider === void 0 ? void 0 : read.maxAgeMs, open);
59
+ this.#starting.set(key, started);
60
+ const answer = await started.finally(() => this.#starting.delete(key));
61
+ if ("error" in answer) return answer;
62
+ return this.#result([], this.#cursorOf(answer, 0), read.cursor !== void 0 && provider === void 0, false);
63
+ }
64
+ this.#renew(lease);
65
+ if (read.cursor === void 0) return this.#result([], this.#cursorOf(lease, lease.newest), false, false);
66
+ if (position?.lease === lease.id) return this.#read(lease, position.position, maxEvents, read.cursor);
67
+ return this.#result([], this.#cursorOf(lease, lease.newest), true, false);
68
+ }
69
+ /** End all leases. */
70
+ async close() {
71
+ this.#closed = true;
72
+ await Promise.allSettled(this.#starting.values());
73
+ await Promise.allSettled([...this.#leases.values()].map((lease) => this.#end(lease)));
74
+ }
75
+ /** Start a lease: open its session, subscribe, and keep the occurrences. A failure before the first occurrence starts no lease. */
76
+ async #start(key, name, input, provider, maxAgeMs, open) {
77
+ const own = await open();
78
+ const subscribed = await own.session.subscribe(name, input, {
79
+ ...provider === void 0 ? {} : { cursor: provider },
80
+ ...maxAgeMs === void 0 ? {} : { maxAgeMs }
81
+ });
82
+ if ("kind" in subscribed || this.#closed) {
83
+ if (!("kind" in subscribed)) await subscribed.return();
84
+ await own.close();
85
+ if ("kind" in subscribed) return { error: mcpSubscribeError(subscribed) };
86
+ return { error: {
87
+ code: MCP_EVENT_ERRORS.internal,
88
+ message: "The server is closing."
89
+ } };
90
+ }
91
+ const lease = {
92
+ id: crypto.randomUUID(),
93
+ key,
94
+ own,
95
+ occurrences: subscribed,
96
+ entries: [],
97
+ newest: 0,
98
+ startProvider: provider,
99
+ lastProvider: provider,
100
+ failure: void 0,
101
+ ended: false,
102
+ cancelTimer: () => {}
103
+ };
104
+ this.#leases.set(key, lease);
105
+ this.#renew(lease);
106
+ this.#pump(lease);
107
+ return lease;
108
+ }
109
+ /** Read the occurrences of the subscription of a lease into its buffer, until the subscription ends. */
110
+ async #pump(lease) {
111
+ try {
112
+ for (let step = await lease.occurrences.next(); step.done !== true; step = await lease.occurrences.next()) {
113
+ const occurrence = step.value;
114
+ if (occurrence.cursor !== void 0) lease.lastProvider = occurrence.cursor;
115
+ lease.newest += 1;
116
+ lease.entries.push({
117
+ position: lease.newest,
118
+ occurrence,
119
+ provider: lease.lastProvider
120
+ });
121
+ if (lease.entries.length > this.#bufferLimit) lease.entries.shift();
122
+ }
123
+ } catch (error) {
124
+ if (!lease.ended) lease.failure = endMessage(error);
125
+ }
126
+ if (!lease.ended) await lease.own.close();
127
+ }
128
+ /** Renew a lease: it ends when it gets no poll for the lease time. */
129
+ #renew(lease) {
130
+ lease.cancelTimer();
131
+ lease.cancelTimer = this.#clock.schedule(() => void this.#end(lease), this.#leaseMs);
132
+ }
133
+ /** End a lease: cancel its subscription, close its session, and forget it. */
134
+ async #end(lease) {
135
+ if (lease.ended) return;
136
+ lease.ended = true;
137
+ lease.cancelTimer();
138
+ if (this.#leases.get(lease.key) === lease) this.#leases.delete(lease.key);
139
+ await lease.occurrences.return();
140
+ await lease.own.close();
141
+ }
142
+ /** The occurrences of a lease after a position, at most `maxEvents`. */
143
+ #read(lease, after, maxEvents, requestCursor) {
144
+ const older = after < (lease.entries[0]?.position ?? lease.newest + 1) - 1;
145
+ const available = lease.entries.filter((entry) => entry.position > after);
146
+ const events = available.slice(0, maxEvents);
147
+ const last = events.at(-1);
148
+ const truncated = older || events.some((entry) => entry.occurrence.truncated === true);
149
+ return this.#result(events.map((entry) => withoutTruncated(entry.occurrence)), last === void 0 ? requestCursor : this.#cursorOf(lease, last.position), truncated, available.length > maxEvents);
150
+ }
151
+ /** The cursor of a position of a lease. */
152
+ #cursorOf(lease, position) {
153
+ const provider = position === 0 ? lease.startProvider : position >= lease.newest ? lease.lastProvider : lease.entries.find((entry) => entry.position === position)?.provider;
154
+ return encodePollCursor(provider === void 0 ? {
155
+ lease: lease.id,
156
+ position
157
+ } : {
158
+ lease: lease.id,
159
+ position,
160
+ provider
161
+ });
162
+ }
163
+ #result(events, cursor, truncated, hasMore) {
164
+ return { result: {
165
+ events: [...events],
166
+ cursor,
167
+ truncated,
168
+ hasMore,
169
+ nextPollMs: this.#nextPollMs
170
+ } };
171
+ }
172
+ };
173
+ /** An occurrence without `truncated`, as an item of `events`. */
174
+ function withoutTruncated(occurrence) {
175
+ const { truncated: _, ...rest } = occurrence;
176
+ return { ...rest };
177
+ }
178
+ //#endregion
179
+ export { PollLeases };
@@ -0,0 +1,41 @@
1
+ import type { App, ToJsonSchema } from "./app.js";
2
+ import type { Clock } from "./clock.js";
3
+ import type { PollOptions } from "./mcp-poll.js";
4
+ export interface McpStdioOptions<Context> {
5
+ readonly name: string;
6
+ readonly version: string;
7
+ /** Guidance for a model about the whole server. The default is the description of the application. */
8
+ readonly instructions?: string | undefined;
9
+ /** The context of the session. */
10
+ readonly context: Context;
11
+ readonly toJsonSchema: ToJsonSchema;
12
+ /** The target that the server serves. The default is `"mcp"`. */
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;
25
+ }
26
+ export interface McpStdioIo {
27
+ /** Standard input: bytes or text, one JSON-RPC message on each line. */
28
+ readonly input: AsyncIterable<string | Uint8Array>;
29
+ /** Write one line to standard output. The text ends with a newline. */
30
+ readonly write: (text: string) => void;
31
+ }
32
+ /**
33
+ * Serve the MCP server on the lines of `io.input` until the input ends. Then the session closes: the calls in progress
34
+ * are cancelled, and the promise resolves. This function works in all runtimes.
35
+ */
36
+ export declare function runMcpStdio<Context>(app: App<Context>, options: McpStdioOptions<Context>, io: McpStdioIo): Promise<void>;
37
+ /**
38
+ * Run the MCP server on the standard input and output of the process, until standard input ends. The logs of the
39
+ * session go to standard error.
40
+ */
41
+ export declare function main<Context>(app: App<Context>, options: McpStdioOptions<Context>): void;
@@ -0,0 +1,325 @@
1
+ import { createSession } from "./session.js";
2
+ import { eventCapabilities, listEvents, servesEvents, startEventStream, unsupportedWebhook } from "./mcp-events.js";
3
+ import { PollLeases } from "./mcp-poll.js";
4
+ import { hasObjectOutput, listTools } from "./mcp.js";
5
+ import { KEEPALIVE_MS, MCP_PROTOCOL_VERSION, MCP_UNKNOWN_TOOL, isJsonObject, mcpToolResult } from "@acid-cat/spec-ts";
6
+ //#region src/mcp-stdio.ts
7
+ /**
8
+ * The MCP binding through standard input and standard output (spec/bindings/mcp.md#stdio). The process is one session.
9
+ * The server serves revision 2026-07-28, and the `initialize` handshake of revisions 2025-11-25 and 2025-06-18.
10
+ */
11
+ /** The revisions with an `initialize` handshake that the server serves, the latest first. */
12
+ const HANDSHAKE_VERSIONS = ["2025-11-25", "2025-06-18"];
13
+ const SUPPORTED_VERSIONS = [MCP_PROTOCOL_VERSION, ...HANDSHAKE_VERSIONS];
14
+ const PROTOCOL_VERSION_KEY = "io.modelcontextprotocol/protocolVersion";
15
+ const SUBSCRIPTION_ID_KEY = "io.modelcontextprotocol/subscriptionId";
16
+ function readMessage(body) {
17
+ const { id, method, params } = body;
18
+ const members = isJsonObject(params) ? params : {};
19
+ const meta = members["_meta"];
20
+ return {
21
+ id: typeof id === "string" || typeof id === "number" ? id : "id" in body ? null : void 0,
22
+ method: typeof method === "string" ? method : void 0,
23
+ params: members,
24
+ protocolVersion: isJsonObject(meta) ? meta[PROTOCOL_VERSION_KEY] : void 0
25
+ };
26
+ }
27
+ /**
28
+ * Serve the MCP server on the lines of `io.input` until the input ends. Then the session closes: the calls in progress
29
+ * are cancelled, and the promise resolves. This function works in all runtimes.
30
+ */
31
+ async function runMcpStdio(app, options, io) {
32
+ const session = createSession(app, {
33
+ target: options.target ?? "mcp",
34
+ context: options.context,
35
+ toJsonSchema: options.toJsonSchema,
36
+ ...options.bufferLimit === void 0 ? {} : { bufferLimit: options.bufferLimit }
37
+ });
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);
52
+ const server = new StdioServer(session, {
53
+ name: options.name,
54
+ version: options.version,
55
+ instructions,
56
+ heartbeatMs: options.heartbeatMs ?? KEEPALIVE_MS
57
+ }, io.write, {
58
+ leases,
59
+ open
60
+ });
61
+ try {
62
+ for await (const line of lines(io.input)) server.receive(line);
63
+ } finally {
64
+ await server.close();
65
+ }
66
+ }
67
+ /**
68
+ * Run the MCP server on the standard input and output of the process, until standard input ends. The logs of the
69
+ * session go to standard error.
70
+ */
71
+ function main(app, options) {
72
+ runMcpStdio(app, options, {
73
+ input: process.stdin,
74
+ write: (text) => process.stdout.write(text)
75
+ });
76
+ }
77
+ /** The lines of a stream of bytes or text, without their line ends. */
78
+ async function* lines(input) {
79
+ const decoder = new TextDecoder();
80
+ let rest = "";
81
+ for await (const chunk of input) {
82
+ rest += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
83
+ const parts = rest.split("\n");
84
+ rest = parts.pop() ?? "";
85
+ yield* parts;
86
+ }
87
+ rest += decoder.decode();
88
+ if (rest !== "") yield rest;
89
+ }
90
+ /** The state of the one session of the process: the requests in progress, the subscriptions, and the handshake. */
91
+ var StdioServer = class {
92
+ #session;
93
+ #options;
94
+ #write;
95
+ /** The calls in progress, by request id. `notifications/cancelled` aborts them. */
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();
104
+ /** The open `subscriptions/listen` requests of revision 2026-07-28 that asked for tool list changes. */
105
+ #subscriptions = /* @__PURE__ */ new Set();
106
+ /** True after the `initialize` handshake: the client then gets `notifications/tools/list_changed` with no subscription. */
107
+ #initialized = false;
108
+ #listed;
109
+ #changes;
110
+ #poll;
111
+ constructor(session, options, write, poll) {
112
+ this.#session = session;
113
+ this.#poll = poll;
114
+ this.#options = options;
115
+ this.#write = write;
116
+ this.#listed = this.#availableIds();
117
+ this.#changes = session.onChange(() => this.#announce());
118
+ }
119
+ /** Read one line of standard input. An empty line is ignored. */
120
+ receive(line) {
121
+ if (line.trim() === "") return;
122
+ let body;
123
+ try {
124
+ body = JSON.parse(line);
125
+ } catch {
126
+ return this.#error(null, -32700, "The line is not JSON.");
127
+ }
128
+ if (Array.isArray(body)) return this.#error(null, -32600, "JSON-RPC batches are not supported.");
129
+ if (!isJsonObject(body)) return this.#error(null, -32600, "A message must be a JSON object.");
130
+ const message = readMessage(body);
131
+ if (message.method === void 0) {
132
+ if (message.id !== void 0) return;
133
+ return this.#error(null, -32600, "A message must have a method.");
134
+ }
135
+ if (message.id === void 0) return this.#notification(message);
136
+ this.#request({
137
+ ...message,
138
+ id: message.id ?? null
139
+ });
140
+ }
141
+ /** Close the session: stop the subscriptions and the poll leases, and cancel the calls in progress. */
142
+ async close() {
143
+ this.#changes.unsubscribe();
144
+ await this.#poll.leases.close();
145
+ this.#subscriptions.clear();
146
+ for (const controller of this.#calls.values()) controller.abort();
147
+ await this.#session.dispose();
148
+ await Promise.allSettled(this.#streaming);
149
+ }
150
+ #notification(message) {
151
+ if (message.method !== "notifications/cancelled") return;
152
+ const requestId = message.params["requestId"];
153
+ if (typeof requestId !== "string" && typeof requestId !== "number") return;
154
+ this.#calls.get(requestId)?.abort();
155
+ this.#streams.get(requestId)?.abort();
156
+ this.#subscriptions.delete(requestId);
157
+ }
158
+ async #request(message) {
159
+ const { id, method, protocolVersion } = message;
160
+ if (method === "initialize") return this.#initialize(id, message.params["protocolVersion"]);
161
+ if (method === "ping") return this.#result(id, {});
162
+ const modern = protocolVersion !== void 0;
163
+ if (modern && protocolVersion !== MCP_PROTOCOL_VERSION) return this.#error(id, -32022, `Unsupported protocol version ${JSON.stringify(protocolVersion)}`, { supported: SUPPORTED_VERSIONS });
164
+ /** The result of a request: in revision 2026-07-28 it has the server information and a result type. */
165
+ const complete = (result, list = false) => this.#result(id, modern ? {
166
+ _meta: this.#serverInfo(),
167
+ resultType: "complete",
168
+ ...list ? {
169
+ ttlMs: 0,
170
+ cacheScope: "private"
171
+ } : {},
172
+ ...result
173
+ } : result);
174
+ switch (method) {
175
+ case "server/discover": return complete({
176
+ supportedVersions: SUPPORTED_VERSIONS,
177
+ capabilities: {
178
+ tools: { listChanged: true },
179
+ ...eventCapabilities(this.#session)
180
+ },
181
+ ...this.#options.instructions === void 0 ? {} : { instructions: this.#options.instructions }
182
+ }, true);
183
+ case "subscriptions/listen": return this.#listen(id, message.params);
184
+ case "tools/list": return complete({ tools: listTools(this.#session) }, true);
185
+ case "tools/call": return this.#call(id, message.params, complete);
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
+ } };
236
+ }
237
+ /** The `initialize` handshake. The server selects the revision of the client if it serves it, or else its latest. */
238
+ #initialize(id, offered) {
239
+ const version = HANDSHAKE_VERSIONS.find((each) => each === offered) ?? HANDSHAKE_VERSIONS[0];
240
+ this.#result(id, {
241
+ protocolVersion: version,
242
+ capabilities: { tools: { listChanged: true } },
243
+ serverInfo: {
244
+ name: this.#options.name,
245
+ version: this.#options.version
246
+ },
247
+ ...this.#options.instructions === void 0 ? {} : { instructions: this.#options.instructions }
248
+ });
249
+ this.#initialized = true;
250
+ }
251
+ /**
252
+ * A `subscriptions/listen` request of revision 2026-07-28. The server honors only tool list changes. The request
253
+ * stays open until the client cancels it or standard input ends, so the server never answers it.
254
+ */
255
+ #listen(id, params) {
256
+ const wanted = params["notifications"];
257
+ const tools = isJsonObject(wanted) && wanted["toolsListChanged"] === true && id !== null;
258
+ this.#send({
259
+ jsonrpc: "2.0",
260
+ method: "notifications/subscriptions/acknowledged",
261
+ params: {
262
+ _meta: { [SUBSCRIPTION_ID_KEY]: id },
263
+ notifications: tools ? { toolsListChanged: true } : {}
264
+ }
265
+ });
266
+ if (tools) this.#subscriptions.add(id);
267
+ }
268
+ async #call(id, params, complete) {
269
+ const name = typeof params["name"] === "string" ? params["name"] : "";
270
+ const handle = this.#session.handles.get(name);
271
+ if (handle === void 0 || !handle.availability.getSnapshot().available) return this.#error(id, MCP_UNKNOWN_TOOL, `Tool '${name}' not found`);
272
+ const controller = new AbortController();
273
+ if (id !== null) this.#calls.set(id, controller);
274
+ try {
275
+ const outcome = await this.#session.invoke(name, params["arguments"] ?? {}, { signal: controller.signal });
276
+ if (controller.signal.aborted) return;
277
+ complete({ ...mcpToolResult(outcome, hasObjectOutput(this.#session, name)) });
278
+ } finally {
279
+ if (id !== null && this.#calls.get(id) === controller) this.#calls.delete(id);
280
+ }
281
+ }
282
+ /** The ids of the available tools, as one string, to compare two sets. */
283
+ #availableIds() {
284
+ return [...this.#session.handles.values()].filter((handle) => handle.availability.getSnapshot().available).map((handle) => handle.id).join("\n");
285
+ }
286
+ /** Tell the client when the set of available tools changes. */
287
+ #announce() {
288
+ const next = this.#availableIds();
289
+ if (next === this.#listed) return;
290
+ this.#listed = next;
291
+ if (this.#initialized) this.#send({
292
+ jsonrpc: "2.0",
293
+ method: "notifications/tools/list_changed",
294
+ params: {}
295
+ });
296
+ for (const id of this.#subscriptions) this.#send({
297
+ jsonrpc: "2.0",
298
+ method: "notifications/tools/list_changed",
299
+ params: { _meta: { [SUBSCRIPTION_ID_KEY]: id } }
300
+ });
301
+ }
302
+ #result(id, result) {
303
+ this.#send({
304
+ jsonrpc: "2.0",
305
+ id,
306
+ result
307
+ });
308
+ }
309
+ #error(id, code, message, data) {
310
+ this.#send({
311
+ jsonrpc: "2.0",
312
+ id,
313
+ error: {
314
+ code,
315
+ message,
316
+ ...data === void 0 ? {} : { data }
317
+ }
318
+ });
319
+ }
320
+ #send(message) {
321
+ this.#write(`${JSON.stringify(message)}\n`);
322
+ }
323
+ };
324
+ //#endregion
325
+ export { main, runMcpStdio };
package/dist/mcp.d.ts CHANGED
@@ -1,5 +1,10 @@
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";
7
+ import type { Session } from "./session.js";
3
8
  /** The options of `mcpHandler`. Give `context` to share one context, or `createContext` to make one for each request. */
4
9
  export type McpOptions<Context> = McpSettings & ContextOption<Context>;
5
10
  export interface McpSettings {
@@ -17,6 +22,50 @@ export interface McpSettings {
17
22
  readonly target?: string | undefined;
18
23
  /** The maximum size of a request body, in bytes. The default is 1 MiB. */
19
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>;
20
53
  }
21
- /** A web request handler for the MCP server. Each request has its own session, and with `createContext` its own context. */
22
- 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;
60
+ /** The tools of the available capabilities in the view of a session (spec/bindings/mcp.md#tools). */
61
+ export declare function listTools(session: Session): {
62
+ name: string;
63
+ description: string;
64
+ inputSchema: JsonObject;
65
+ outputSchema?: JsonObject;
66
+ annotations: {
67
+ readOnlyHint: boolean;
68
+ };
69
+ }[];
70
+ /** True if the bundled output schema of a capability has `"type": "object"`. */
71
+ export declare function hasObjectOutput(session: Session, id: string): boolean;