@acid-cat/core-ts 0.9.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.
- package/README.md +151 -11
- package/dist/app.d.ts +73 -1
- package/dist/app.js +34 -2
- package/dist/cli.d.ts +4 -4
- package/dist/cli.js +111 -16
- package/dist/clock.d.ts +14 -0
- package/dist/clock.js +26 -0
- package/dist/context.d.ts +8 -0
- package/dist/context.js +14 -9
- package/dist/http.d.ts +4 -0
- package/dist/http.js +112 -30
- package/dist/index.d.ts +13 -5
- package/dist/index.js +5 -2
- package/dist/mcp-events.d.ts +87 -0
- package/dist/mcp-events.js +129 -0
- package/dist/mcp-poll.d.ts +28 -0
- package/dist/mcp-poll.js +179 -0
- package/dist/mcp-stdio.d.ts +13 -0
- package/dist/mcp-stdio.js +94 -13
- package/dist/mcp.d.ts +40 -4
- package/dist/mcp.js +102 -23
- package/dist/node.d.ts +2 -0
- package/dist/node.js +5 -1
- package/dist/session.d.ts +53 -3
- package/dist/session.js +129 -1
- package/dist/stream.d.ts +25 -0
- package/dist/stream.js +63 -0
- package/dist/subscription.d.ts +39 -0
- package/dist/subscription.js +145 -0
- package/dist/webhook.d.ts +40 -0
- package/dist/webhook.js +385 -0
- package/package.json +7 -5
package/dist/mcp-poll.js
ADDED
|
@@ -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 };
|
package/dist/mcp-stdio.d.ts
CHANGED
|
@@ -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,6 +1,8 @@
|
|
|
1
1
|
import { createSession } from "./session.js";
|
|
2
|
+
import { eventCapabilities, listEvents, servesEvents, startEventStream, unsupportedWebhook } from "./mcp-events.js";
|
|
3
|
+
import { PollLeases } from "./mcp-poll.js";
|
|
2
4
|
import { hasObjectOutput, listTools } from "./mcp.js";
|
|
3
|
-
import { MCP_PROTOCOL_VERSION, MCP_UNKNOWN_TOOL, isJsonObject, mcpToolResult } from "@acid-cat/spec-ts";
|
|
5
|
+
import { KEEPALIVE_MS, MCP_PROTOCOL_VERSION, MCP_UNKNOWN_TOOL, isJsonObject, mcpToolResult } from "@acid-cat/spec-ts";
|
|
4
6
|
//#region 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.
|
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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:
|
|
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: {
|
|
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) {
|
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
|
-
/**
|
|
23
|
-
|
|
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:
|
|
29
|
-
outputSchema?:
|
|
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
1
|
import { createSession } from "./session.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { openContext } from "./context.js";
|
|
3
|
+
import { textStream } from "./stream.js";
|
|
4
|
+
import { eventCapabilities, forbidden, listEvents, servesEvents, startEventStream, unsupportedWebhook } from "./mcp-events.js";
|
|
5
|
+
import { PollLeases } from "./mcp-poll.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";
|
|
4
7
|
//#region src/mcp.ts
|
|
5
8
|
/**
|
|
6
|
-
* The MCP binding (spec/bindings/mcp.md): an MCP server through HTTP, revision 2026-07-28
|
|
7
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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:
|
|
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: {
|
|
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
|
-
|
|
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) {
|
package/dist/node.d.ts
CHANGED
package/dist/node.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { webhookDelivery } from "./webhook.js";
|
|
1
2
|
import { createServer } from "node:http";
|
|
2
3
|
//#region 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);
|
|
@@ -169,4 +173,4 @@ function bodyOf(incoming) {
|
|
|
169
173
|
}, { highWaterMark: 0 });
|
|
170
174
|
}
|
|
171
175
|
//#endregion
|
|
172
|
-
export { serve };
|
|
176
|
+
export { serve, webhookDelivery };
|