@acid-cat/core-ts 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,13 +58,53 @@ Standard Schema does not convert to JSON Schema. Give the converter of your sche
58
58
  | Function | Binding | Result |
59
59
  | --- | --- | --- |
60
60
  | `createSession(app, options)` | [TypeScript](../../spec/bindings/typescript.md) | A session with a handle for each capability in the view |
61
- | `mcpHandler(app, options)` | [MCP](../../spec/bindings/mcp.md) | `(request: Request) => Promise<Response>` |
61
+ | `mcpHandler(app, options)` | [MCP](../../spec/bindings/mcp.md#http) through HTTP | `(request: Request) => Promise<Response>` |
62
+ | `mcpStdioMain(app, options)`, `runMcpStdio(…)` | [MCP](../../spec/bindings/mcp.md#stdio) through stdio | The MCP server of the process, or a promise that resolves when the input ends |
62
63
  | `httpHandler(app, options)` | [HTTP](../../spec/bindings/http.md) | `(request: Request) => Promise<Response>` |
63
64
  | `cliMain(app, options)`, `runCli(…)` | [CLI](../../spec/bindings/cli.md) | The command line of the process, or an exit code |
64
65
  | `installWebMcp(session)` | [WebMCP](../../spec/bindings/webmcp.md) | Tools in the page, with a status |
66
+ | `serve(handler, options)`, from `@acid-cat/core-ts/node` | MCP and HTTP | A Node.js server for the web handlers. See [Serve on Node.js](#serve-on-nodejs) |
65
67
 
66
68
  When the signal of a request aborts, `mcpHandler` and `httpHandler` cancel the call of the request.
67
69
 
70
+ ### Serve on Node.js
71
+
72
+ `@acid-cat/core-ts/node` serves web handlers on Node.js, with only `node:http`. It has no other dependency.
73
+
74
+ ```ts
75
+ import { serve } from "@acid-cat/core-ts/node";
76
+
77
+ const mcp = mcpHandler(aircon, { name: "aircon", version: "1.0.0", allowedOrigins: [], toJsonSchema, context: { unit } });
78
+ const http = httpHandler(aircon, { allowedOrigins: [], toJsonSchema, context: { unit } });
79
+
80
+ const server = await serve({ "/mcp": mcp, "/": http }, { port: 3000 });
81
+ console.log(server.url); // http://127.0.0.1:3000
82
+ ```
83
+
84
+ `serve(handler, options)` takes one handler for all paths, or handlers by path prefix. The longest prefix that matches gets the request, and the prefix `/` matches all paths. The MCP handler answers on all paths, so give it only its own prefix. A path that no prefix matches gets `404`. A handler that rejects gets `500`.
85
+
86
+ | Option | Default | Use |
87
+ | --- | --- | --- |
88
+ | `port` | `3000` | The port. With `0`, the system chooses a free port. `server.port` gives it |
89
+ | `hostname` | `127.0.0.1` | The address. Only the same computer can connect. Use `0.0.0.0` or `::` to accept connections from the network |
90
+
91
+ `serve` resolves when the server listens. `server.close()` stops it, and `await using server = await serve(…)` closes it at the end of the block. When a client closes its connection, the signal of its request aborts, so the handler cancels the call.
92
+
93
+ `serve` gives a plain HTTP/1.1 server. For more, use a library for web handlers, for example [srvx](https://srvx.h3.dev): HTTPS, HTTP/2, middleware, or the same server code on Bun and Deno. Give it the handler. Make sure that the library aborts the signal of a request when the client closes the connection. If it does not, a closed connection does not cancel the call.
94
+
95
+ ### MCP through stdio
96
+
97
+ An MCP client, for example a coding agent, can start the server as a local process. `mcpStdioMain` serves the standard input and output of the process. The process is one session, with one `context`. The server serves revision `2026-07-28`, and the `initialize` handshake of revisions `2025-11-25` and `2025-06-18`. When the availability of a capability changes, the server sends `notifications/tools/list_changed`. `notifications/cancelled` cancels a call. When standard input ends, the session closes and the calls in progress are cancelled.
98
+
99
+ ```ts
100
+ #!/usr/bin/env node
101
+ import { mcpStdioMain } from "@acid-cat/core-ts";
102
+
103
+ mcpStdioMain(aircon, { name: "aircon", version: "1.0.0", context: { unit }, toJsonSchema });
104
+ ```
105
+
106
+ Standard output carries only MCP messages. The session writes defects to standard error. Do not write to standard output from a capability. `runMcpStdio(app, options, { input, write })` is the same server on any stream: `input` is an async iterable of bytes or text, for example a `ReadableStream`, and `write` writes one line. It works in all runtimes.
107
+
68
108
  ### Typed calls and handles
69
109
 
70
110
  `createSession` gives a `Session<Context>`. It calls a capability with the types of the capability in two ways. Both take the capability object, so the types come from its schemas:
@@ -102,6 +142,20 @@ const handler = httpHandler(aircon, {
102
142
  });
103
143
  ```
104
144
 
145
+ ### Command line
146
+
147
+ The command line has one command for each capability in the view of the `cli` target. Each top-level property of the input is a flag with the same name, for example `--celsius 22` or `--celsius=22`. A boolean property is a switch, for example `--on`. A property with an array of strings, numbers or values of a string enum is a repeated flag: each occurrence gives one item, in the order of the flags. A repeated flag that occurs once can also give the whole array as JSON text. Other properties take JSON text. For example, with the command line of the air conditioner, [`examples/aircon/core-ts/cli.ts`](../../examples/aircon/core-ts/cli.ts):
148
+
149
+ ```sh
150
+ node cli.ts aircon temperature set --celsius 22
151
+ node cli.ts aircon schedule set --days mon --days fri --hours 7 --hours 18 # {"days":["mon","fri"],"hours":[7,18]}
152
+ node cli.ts aircon schedule set --days '["mon","fri"]' --hours '[7,18]' # the same input, as JSON text
153
+ node cli.ts aircon schedule set --days '[]' # an empty array
154
+ node cli.ts list # the usage of each command
155
+ ```
156
+
157
+ `list` gives a usage line for each capability. The usage of a repeated flag ends with `...`, for example `--days <mon|tue|wed|thu|fri|sat|sun>... [--hours <integer>...]`. See the [CLI binding](../../spec/bindings/cli.md#commands) for all the rules.
158
+
105
159
  ### Commands of the application
106
160
 
107
161
  The option `commands` of `cliMain` and `runCli` adds commands of the application beside the commands of the capabilities, `call` and `list`. A command has a `name`, a `description` for the help, and `run(args, io)`. `run` gets the arguments after the name of the command, and `io` with `stdout`, `stderr`, `signal` and the `context`. It returns the exit code. The name must not be `call`, `list`, the name of another command of the application, or the first word of the command of a capability. If it is, `runCli` throws. The option `description` gives the text at the top of the help. The default is the description of the application, then its title, then the name of the program.
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createSession } from "./session.js";
2
- import { CLI_EXIT_CODES, REFERENCE, bundle, cliExitCode, cliFlags, cliUsage, commandPath, isCallFailure, isJsonObject } from "@acid-cat/spec-ts";
2
+ import { CLI_EXIT_CODES, bundle, cliExitCode, cliFlags, cliJsonArray, cliRepeatedItems, cliResolve, cliUsage, commandPath, isCallFailure, isJsonObject } from "@acid-cat/spec-ts";
3
3
  //#region src/cli.ts
4
4
  /**
5
5
  * The CLI binding (spec/bindings/cli.md): a command line made from the catalog.
@@ -119,21 +119,32 @@ function parseFlags(args, schema) {
119
119
  const properties = objectOrEmpty(schema.properties);
120
120
  const definitions = objectOrEmpty(schema.$defs);
121
121
  const input = {};
122
+ const repeated = /* @__PURE__ */ new Map();
122
123
  const remaining = args.values();
123
124
  for (const arg of remaining) {
124
125
  if (!arg.startsWith("--")) throw new UsageError(`Unexpected argument ${arg}.`, CLI_EXIT_CODES["invalid-input"]);
125
126
  const [name = "", inline] = arg.slice(2).split(/=(.*)/s, 2);
126
127
  const property = properties[name];
127
128
  if (property === void 0) throw new UsageError(`Unknown flag --${name}.`, CLI_EXIT_CODES["invalid-input"]);
128
- const type = resolve(objectOrEmpty(property), definitions);
129
+ const type = cliResolve(objectOrEmpty(property), definitions);
129
130
  if (type.type === "boolean") {
130
131
  input[name] = inline === void 0 ? true : inline === "true";
131
132
  continue;
132
133
  }
133
134
  const text = inline ?? remaining.next().value;
134
135
  if (text === void 0) throw new UsageError(`The flag --${name} needs a value.`, CLI_EXIT_CODES["invalid-input"]);
135
- input[name] = valueOf(name, text, type);
136
+ const items = cliRepeatedItems(type, definitions);
137
+ if (items === void 0) input[name] = valueOf(name, text, type);
138
+ else {
139
+ const flag = repeated.get(name) ?? {
140
+ items,
141
+ values: []
142
+ };
143
+ flag.values.push(text);
144
+ repeated.set(name, flag);
145
+ }
136
146
  }
147
+ for (const [name, { items, values }] of repeated) input[name] = cliJsonArray(values) ?? values.map((text) => valueOf(name, text, items));
137
148
  const required = Array.isArray(schema.required) ? schema.required.filter((name) => typeof name === "string") : [];
138
149
  for (const name of required) if (!(name in input)) throw new UsageError(`The flag --${name} is required.`, CLI_EXIT_CODES["invalid-input"]);
139
150
  return input;
@@ -155,10 +166,6 @@ function valueOf(name, text, type) {
155
166
  throw new UsageError(`The flag --${name} must be JSON.`, CLI_EXIT_CODES["invalid-input"]);
156
167
  }
157
168
  }
158
- function resolve(property, definitions) {
159
- const name = typeof property.$ref === "string" ? REFERENCE.exec(property.$ref)?.[1] : void 0;
160
- return name === void 0 ? property : objectOrEmpty(definitions[name] ?? property);
161
- }
162
169
  /** A value as a JSON object, or an empty object when it is not one. */
163
170
  const objectOrEmpty = (value) => isJsonObject(value) ? value : {};
164
171
  /** The capability `id` of a view. The view has it, because the session serves it. */
package/dist/index.d.ts CHANGED
@@ -4,6 +4,8 @@ export { createSession } from "./session.js";
4
4
  export type { Handle, HandleOf, Session, SessionOptions } from "./session.js";
5
5
  export { mcpHandler } from "./mcp.js";
6
6
  export type { McpOptions, McpSettings } from "./mcp.js";
7
+ export { main as mcpStdioMain, runMcpStdio } from "./mcp-stdio.js";
8
+ export type { McpStdioIo, McpStdioOptions } from "./mcp-stdio.js";
7
9
  export { httpHandler } from "./http.js";
8
10
  export type { HttpOptions, HttpSettings } from "./http.js";
9
11
  export type { ContextOption } from "./context.js";
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { capabilitiesFor, catalogOf, defineApp, defineCapability, renderCatalog } from "./app.js";
2
2
  import { createSession } from "./session.js";
3
3
  import { mcpHandler } from "./mcp.js";
4
+ import { main as main$1, runMcpStdio } from "./mcp-stdio.js";
4
5
  import { httpHandler } from "./http.js";
5
6
  import { main, runCli } from "./cli.js";
6
7
  import { findHost, installWebMcp } from "./webmcp.js";
7
8
  import { AVAILABLE, CallFailureError, fail, ok, unavailable } from "@acid-cat/spec-ts";
8
- export { AVAILABLE, CallFailureError, capabilitiesFor, catalogOf, main as cliMain, createSession, defineApp, defineCapability, fail, findHost, httpHandler, installWebMcp, mcpHandler, ok, renderCatalog, runCli, unavailable };
9
+ export { AVAILABLE, CallFailureError, capabilitiesFor, catalogOf, main as cliMain, createSession, defineApp, defineCapability, fail, findHost, httpHandler, installWebMcp, mcpHandler, main$1 as mcpStdioMain, ok, renderCatalog, runCli, runMcpStdio, unavailable };
@@ -0,0 +1,28 @@
1
+ import type { App, ToJsonSchema } from "./app.js";
2
+ export interface McpStdioOptions<Context> {
3
+ readonly name: string;
4
+ readonly version: string;
5
+ /** Guidance for a model about the whole server. The default is the description of the application. */
6
+ readonly instructions?: string | undefined;
7
+ /** The context of the session. */
8
+ readonly context: Context;
9
+ readonly toJsonSchema: ToJsonSchema;
10
+ /** The target that the server serves. The default is `"mcp"`. */
11
+ readonly target?: string | undefined;
12
+ }
13
+ export interface McpStdioIo {
14
+ /** Standard input: bytes or text, one JSON-RPC message on each line. */
15
+ readonly input: AsyncIterable<string | Uint8Array>;
16
+ /** Write one line to standard output. The text ends with a newline. */
17
+ readonly write: (text: string) => void;
18
+ }
19
+ /**
20
+ * Serve the MCP server on the lines of `io.input` until the input ends. Then the session closes: the calls in progress
21
+ * are cancelled, and the promise resolves. This function works in all runtimes.
22
+ */
23
+ export declare function runMcpStdio<Context>(app: App<Context>, options: McpStdioOptions<Context>, io: McpStdioIo): Promise<void>;
24
+ /**
25
+ * Run the MCP server on the standard input and output of the process, until standard input ends. The logs of the
26
+ * session go to standard error.
27
+ */
28
+ export declare function main<Context>(app: App<Context>, options: McpStdioOptions<Context>): void;
@@ -0,0 +1,244 @@
1
+ import { createSession } from "./session.js";
2
+ 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
5
+ /**
6
+ * The MCP binding through standard input and standard output (spec/bindings/mcp.md#stdio). The process is one session.
7
+ * The server serves revision 2026-07-28, and the `initialize` handshake of revisions 2025-11-25 and 2025-06-18.
8
+ */
9
+ /** The revisions with an `initialize` handshake that the server serves, the latest first. */
10
+ const HANDSHAKE_VERSIONS = ["2025-11-25", "2025-06-18"];
11
+ const SUPPORTED_VERSIONS = [MCP_PROTOCOL_VERSION, ...HANDSHAKE_VERSIONS];
12
+ const PROTOCOL_VERSION_KEY = "io.modelcontextprotocol/protocolVersion";
13
+ const SUBSCRIPTION_ID_KEY = "io.modelcontextprotocol/subscriptionId";
14
+ function readMessage(body) {
15
+ const { id, method, params } = body;
16
+ const members = isJsonObject(params) ? params : {};
17
+ const meta = members["_meta"];
18
+ return {
19
+ id: typeof id === "string" || typeof id === "number" ? id : "id" in body ? null : void 0,
20
+ method: typeof method === "string" ? method : void 0,
21
+ params: members,
22
+ protocolVersion: isJsonObject(meta) ? meta[PROTOCOL_VERSION_KEY] : void 0
23
+ };
24
+ }
25
+ /**
26
+ * Serve the MCP server on the lines of `io.input` until the input ends. Then the session closes: the calls in progress
27
+ * are cancelled, and the promise resolves. This function works in all runtimes.
28
+ */
29
+ async function runMcpStdio(app, options, io) {
30
+ const session = createSession(app, {
31
+ target: options.target ?? "mcp",
32
+ context: options.context,
33
+ toJsonSchema: options.toJsonSchema
34
+ });
35
+ const instructions = options.instructions ?? app.description;
36
+ const server = new StdioServer(session, {
37
+ ...options,
38
+ instructions
39
+ }, io.write);
40
+ try {
41
+ for await (const line of lines(io.input)) server.receive(line);
42
+ } finally {
43
+ await server.close();
44
+ }
45
+ }
46
+ /**
47
+ * Run the MCP server on the standard input and output of the process, until standard input ends. The logs of the
48
+ * session go to standard error.
49
+ */
50
+ function main(app, options) {
51
+ runMcpStdio(app, options, {
52
+ input: process.stdin,
53
+ write: (text) => process.stdout.write(text)
54
+ });
55
+ }
56
+ /** The lines of a stream of bytes or text, without their line ends. */
57
+ async function* lines(input) {
58
+ const decoder = new TextDecoder();
59
+ let rest = "";
60
+ for await (const chunk of input) {
61
+ rest += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
62
+ const parts = rest.split("\n");
63
+ rest = parts.pop() ?? "";
64
+ yield* parts;
65
+ }
66
+ rest += decoder.decode();
67
+ if (rest !== "") yield rest;
68
+ }
69
+ /** The state of the one session of the process: the requests in progress, the subscriptions, and the handshake. */
70
+ var StdioServer = class {
71
+ #session;
72
+ #options;
73
+ #write;
74
+ /** The calls in progress, by request id. `notifications/cancelled` aborts them. */
75
+ #calls = /* @__PURE__ */ new Map();
76
+ /** The open `subscriptions/listen` requests of revision 2026-07-28 that asked for tool list changes. */
77
+ #subscriptions = /* @__PURE__ */ new Set();
78
+ /** True after the `initialize` handshake: the client then gets `notifications/tools/list_changed` with no subscription. */
79
+ #initialized = false;
80
+ #listed;
81
+ #changes;
82
+ constructor(session, options, write) {
83
+ this.#session = session;
84
+ this.#options = options;
85
+ this.#write = write;
86
+ this.#listed = this.#availableIds();
87
+ this.#changes = session.onChange(() => this.#announce());
88
+ }
89
+ /** Read one line of standard input. An empty line is ignored. */
90
+ receive(line) {
91
+ if (line.trim() === "") return;
92
+ let body;
93
+ try {
94
+ body = JSON.parse(line);
95
+ } catch {
96
+ return this.#error(null, -32700, "The line is not JSON.");
97
+ }
98
+ if (Array.isArray(body)) return this.#error(null, -32600, "JSON-RPC batches are not supported.");
99
+ if (!isJsonObject(body)) return this.#error(null, -32600, "A message must be a JSON object.");
100
+ const message = readMessage(body);
101
+ if (message.method === void 0) {
102
+ if (message.id !== void 0) return;
103
+ return this.#error(null, -32600, "A message must have a method.");
104
+ }
105
+ if (message.id === void 0) return this.#notification(message);
106
+ this.#request({
107
+ ...message,
108
+ id: message.id ?? null
109
+ });
110
+ }
111
+ /** Close the session: stop the subscriptions, and cancel the calls in progress. */
112
+ async close() {
113
+ this.#changes.unsubscribe();
114
+ this.#subscriptions.clear();
115
+ for (const controller of this.#calls.values()) controller.abort();
116
+ await this.#session.dispose();
117
+ }
118
+ #notification(message) {
119
+ if (message.method !== "notifications/cancelled") return;
120
+ const requestId = message.params["requestId"];
121
+ if (typeof requestId !== "string" && typeof requestId !== "number") return;
122
+ this.#calls.get(requestId)?.abort();
123
+ this.#subscriptions.delete(requestId);
124
+ }
125
+ async #request(message) {
126
+ const { id, method, protocolVersion } = message;
127
+ if (method === "initialize") return this.#initialize(id, message.params["protocolVersion"]);
128
+ if (method === "ping") return this.#result(id, {});
129
+ const modern = protocolVersion !== void 0;
130
+ if (modern && protocolVersion !== MCP_PROTOCOL_VERSION) return this.#error(id, -32022, `Unsupported protocol version ${JSON.stringify(protocolVersion)}`, { supported: SUPPORTED_VERSIONS });
131
+ /** The result of a request: in revision 2026-07-28 it has the server information and a result type. */
132
+ 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
+ } },
137
+ resultType: "complete",
138
+ ...list ? {
139
+ ttlMs: 0,
140
+ cacheScope: "private"
141
+ } : {},
142
+ ...result
143
+ } : result);
144
+ switch (method) {
145
+ case "server/discover": return complete({
146
+ supportedVersions: SUPPORTED_VERSIONS,
147
+ capabilities: { tools: { listChanged: true } },
148
+ ...this.#options.instructions === void 0 ? {} : { instructions: this.#options.instructions }
149
+ }, true);
150
+ case "subscriptions/listen": return this.#listen(id, message.params);
151
+ case "tools/list": return complete({ tools: listTools(this.#session) }, true);
152
+ case "tools/call": return this.#call(id, message.params, complete);
153
+ default: return this.#error(id, -32601, `Method not found: ${method}`);
154
+ }
155
+ }
156
+ /** The `initialize` handshake. The server selects the revision of the client if it serves it, or else its latest. */
157
+ #initialize(id, offered) {
158
+ const version = HANDSHAKE_VERSIONS.find((each) => each === offered) ?? HANDSHAKE_VERSIONS[0];
159
+ this.#result(id, {
160
+ protocolVersion: version,
161
+ capabilities: { tools: { listChanged: true } },
162
+ serverInfo: {
163
+ name: this.#options.name,
164
+ version: this.#options.version
165
+ },
166
+ ...this.#options.instructions === void 0 ? {} : { instructions: this.#options.instructions }
167
+ });
168
+ this.#initialized = true;
169
+ }
170
+ /**
171
+ * A `subscriptions/listen` request of revision 2026-07-28. The server honors only tool list changes. The request
172
+ * stays open until the client cancels it or standard input ends, so the server never answers it.
173
+ */
174
+ #listen(id, params) {
175
+ const wanted = params["notifications"];
176
+ const tools = isJsonObject(wanted) && wanted["toolsListChanged"] === true && id !== null;
177
+ this.#send({
178
+ jsonrpc: "2.0",
179
+ method: "notifications/subscriptions/acknowledged",
180
+ params: {
181
+ _meta: { [SUBSCRIPTION_ID_KEY]: id },
182
+ notifications: tools ? { toolsListChanged: true } : {}
183
+ }
184
+ });
185
+ if (tools) this.#subscriptions.add(id);
186
+ }
187
+ async #call(id, params, complete) {
188
+ const name = typeof params["name"] === "string" ? params["name"] : "";
189
+ const handle = this.#session.handles.get(name);
190
+ if (handle === void 0 || !handle.availability.getSnapshot().available) return this.#error(id, MCP_UNKNOWN_TOOL, `Tool '${name}' not found`);
191
+ const controller = new AbortController();
192
+ if (id !== null) this.#calls.set(id, controller);
193
+ try {
194
+ const outcome = await this.#session.invoke(name, params["arguments"] ?? {}, { signal: controller.signal });
195
+ if (controller.signal.aborted) return;
196
+ complete({ ...mcpToolResult(outcome, hasObjectOutput(this.#session, name)) });
197
+ } finally {
198
+ if (id !== null && this.#calls.get(id) === controller) this.#calls.delete(id);
199
+ }
200
+ }
201
+ /** The ids of the available tools, as one string, to compare two sets. */
202
+ #availableIds() {
203
+ return [...this.#session.handles.values()].filter((handle) => handle.availability.getSnapshot().available).map((handle) => handle.id).join("\n");
204
+ }
205
+ /** Tell the client when the set of available tools changes. */
206
+ #announce() {
207
+ const next = this.#availableIds();
208
+ if (next === this.#listed) return;
209
+ this.#listed = next;
210
+ if (this.#initialized) this.#send({
211
+ jsonrpc: "2.0",
212
+ method: "notifications/tools/list_changed",
213
+ params: {}
214
+ });
215
+ for (const id of this.#subscriptions) this.#send({
216
+ jsonrpc: "2.0",
217
+ method: "notifications/tools/list_changed",
218
+ params: { _meta: { [SUBSCRIPTION_ID_KEY]: id } }
219
+ });
220
+ }
221
+ #result(id, result) {
222
+ this.#send({
223
+ jsonrpc: "2.0",
224
+ id,
225
+ result
226
+ });
227
+ }
228
+ #error(id, code, message, data) {
229
+ this.#send({
230
+ jsonrpc: "2.0",
231
+ id,
232
+ error: {
233
+ code,
234
+ message,
235
+ ...data === void 0 ? {} : { data }
236
+ }
237
+ });
238
+ }
239
+ #send(message) {
240
+ this.#write(`${JSON.stringify(message)}\n`);
241
+ }
242
+ };
243
+ //#endregion
244
+ export { main, runMcpStdio };
package/dist/mcp.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { App, ToJsonSchema } from "./app.js";
2
2
  import type { ContextOption } from "./context.js";
3
+ import type { Session } from "./session.js";
3
4
  /** The options of `mcpHandler`. Give `context` to share one context, or `createContext` to make one for each request. */
4
5
  export type McpOptions<Context> = McpSettings & ContextOption<Context>;
5
6
  export interface McpSettings {
@@ -20,3 +21,15 @@ export interface McpSettings {
20
21
  }
21
22
  /** A web request handler for the MCP server. Each request has its own session, and with `createContext` its own context. */
22
23
  export declare function mcpHandler<Context>(app: App<Context>, options: McpOptions<Context>): (request: Request) => Promise<Response>;
24
+ /** The tools of the available capabilities in the view of a session (spec/bindings/mcp.md#tools). */
25
+ export declare function listTools(session: Session): {
26
+ name: string;
27
+ description: string;
28
+ inputSchema: import("@acid-cat/spec-ts").JsonObject;
29
+ outputSchema?: import("@acid-cat/spec-ts").JsonObject;
30
+ annotations: {
31
+ readOnlyHint: boolean;
32
+ };
33
+ }[];
34
+ /** True if the bundled output schema of a capability has `"type": "object"`. */
35
+ export declare function hasObjectOutput(session: Session, id: string): boolean;
package/dist/mcp.js CHANGED
@@ -100,6 +100,7 @@ async function answer(session, message, options, signal) {
100
100
  default: return error(404, id, -32601, `Method not found: ${message.method}`);
101
101
  }
102
102
  }
103
+ /** The tools of the available capabilities in the view of a session (spec/bindings/mcp.md#tools). */
103
104
  function listTools(session) {
104
105
  const types = session.view.$defs ?? {};
105
106
  return [...session.handles.values()].filter((handle) => handle.availability.getSnapshot().available).map((handle) => {
@@ -115,6 +116,7 @@ function listTools(session) {
115
116
  };
116
117
  });
117
118
  }
119
+ /** True if the bundled output schema of a capability has `"type": "object"`. */
118
120
  function hasObjectOutput(session, id) {
119
121
  const output = session.view.capabilities[id]?.output;
120
122
  return output !== void 0 && bundle(output, session.view.$defs ?? {}).type === "object";
@@ -173,4 +175,4 @@ const messages = {
173
175
  "not-utf8": "The request body is not UTF-8 JSON."
174
176
  };
175
177
  //#endregion
176
- export { mcpHandler };
178
+ export { hasObjectOutput, listTools, mcpHandler };
package/dist/node.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ /** A web request handler. */
2
+ export type WebHandler = (request: Request) => Promise<Response> | Response;
3
+ /**
4
+ * Handlers by path prefix, for example `{ "/mcp": mcp, "/api": http }`. A prefix matches its own path and the paths
5
+ * below it: `/api` matches `/api` and `/api/status`, but not `/apis`. The longest prefix that matches gets the request.
6
+ * The prefix `/` matches all paths. The handler gets the full URL of the request.
7
+ */
8
+ export interface Routes {
9
+ readonly [prefix: string]: WebHandler;
10
+ }
11
+ export interface ServeOptions {
12
+ /** The port. The default is `3000`. With `0`, the system chooses a free port. */
13
+ readonly port?: number | undefined;
14
+ /**
15
+ * The address to listen on. The default is `127.0.0.1`, so that only the same computer can connect. Use `0.0.0.0`
16
+ * or `::` to accept connections from the network.
17
+ */
18
+ readonly hostname?: string | undefined;
19
+ }
20
+ /** A server that runs. `await using` closes it at the end of its block. */
21
+ export interface Served extends AsyncDisposable {
22
+ /** The base URL, for example `http://127.0.0.1:3000`. */
23
+ readonly url: string;
24
+ /** The port that the server listens on. */
25
+ readonly port: number;
26
+ /** Stop accepting connections, and resolve when the open requests are complete. */
27
+ readonly close: () => Promise<void>;
28
+ }
29
+ /**
30
+ * Serve one handler for all paths, or handlers by path prefix. The promise resolves when the server listens, and rejects
31
+ * when it cannot listen, for example when the port is in use.
32
+ *
33
+ * - The signal of each request aborts when the client closes the connection before the response is complete. A handler
34
+ * that follows the signal then cancels its work.
35
+ * - A path that no prefix matches gets `404`. A handler that throws or rejects gets `500`. A request that is not valid
36
+ * HTTP for a URL gets `400`.
37
+ * - The body of a request is a stream that reads from the connection only when the handler reads it.
38
+ */
39
+ export declare function serve(handler: WebHandler | Routes, options?: ServeOptions): Promise<Served>;
package/dist/node.js ADDED
@@ -0,0 +1,172 @@
1
+ import { createServer } from "node:http";
2
+ //#region src/node.ts
3
+ /**
4
+ * The functions that need Node.js. The main entry point, `@acid-cat/core-ts`, works in all runtimes.
5
+ *
6
+ * `serve` serves web request handlers, `(request: Request) => Promise<Response>`, such as the handlers of `mcpHandler`
7
+ * and `httpHandler`, on Node.js, with only `node:http`. The server serves plain HTTP/1.1. For HTTPS, HTTP/2 or
8
+ * middleware, use a library for web handlers.
9
+ */
10
+ /**
11
+ * Serve one handler for all paths, or handlers by path prefix. The promise resolves when the server listens, and rejects
12
+ * when it cannot listen, for example when the port is in use.
13
+ *
14
+ * - The signal of each request aborts when the client closes the connection before the response is complete. A handler
15
+ * that follows the signal then cancels its work.
16
+ * - A path that no prefix matches gets `404`. A handler that throws or rejects gets `500`. A request that is not valid
17
+ * HTTP for a URL gets `400`.
18
+ * - The body of a request is a stream that reads from the connection only when the handler reads it.
19
+ */
20
+ function serve(handler, options = {}) {
21
+ const route = router(handler);
22
+ const hostname = options.hostname ?? "127.0.0.1";
23
+ const server = createServer((incoming, outgoing) => {
24
+ answer(incoming, outgoing, route);
25
+ });
26
+ return new Promise((resolve, reject) => {
27
+ server.once("error", reject);
28
+ server.listen(options.port ?? 3e3, hostname, () => {
29
+ server.off("error", reject);
30
+ const address = server.address();
31
+ const port = typeof address === "object" && address !== null ? address.port : 0;
32
+ const close = () => new Promise((done) => server.close(() => done()));
33
+ resolve({
34
+ url: `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`,
35
+ port,
36
+ close,
37
+ [Symbol.asyncDispose]: close
38
+ });
39
+ });
40
+ });
41
+ }
42
+ /** The handler of a path, or `undefined` when no prefix matches. */
43
+ function router(handler) {
44
+ if (typeof handler === "function") return () => handler;
45
+ const prefixes = Object.keys(handler).sort((a, b) => b.length - a.length);
46
+ const matches = (path, prefix) => prefix.endsWith("/") ? path.startsWith(prefix) || path === prefix.slice(0, -1) : path === prefix || path.startsWith(`${prefix}/`);
47
+ return (path) => {
48
+ const prefix = prefixes.find((candidate) => matches(path, candidate));
49
+ return prefix === void 0 ? void 0 : handler[prefix];
50
+ };
51
+ }
52
+ async function answer(incoming, outgoing, route) {
53
+ const abort = new AbortController();
54
+ outgoing.on("close", () => {
55
+ if (!outgoing.writableFinished) abort.abort();
56
+ });
57
+ let request;
58
+ try {
59
+ request = requestOf(incoming, abort.signal);
60
+ } catch {
61
+ outgoing.writeHead(400).end();
62
+ return;
63
+ }
64
+ const handler = route(new URL(request.url).pathname);
65
+ if (handler === void 0) {
66
+ outgoing.writeHead(404).end();
67
+ return;
68
+ }
69
+ try {
70
+ await send(await handler(request), outgoing);
71
+ } catch {
72
+ if (outgoing.headersSent) outgoing.destroy();
73
+ else outgoing.writeHead(500).end();
74
+ }
75
+ }
76
+ /** The web request of an incoming request. It throws when the request has no valid URL. */
77
+ function requestOf(incoming, signal) {
78
+ const url = new URL(incoming.url ?? "/", `http://${incoming.headers.host ?? "localhost"}`);
79
+ const headers = new Headers();
80
+ for (const [name, value] of Object.entries(incoming.headers)) if (Array.isArray(value)) value.forEach((item) => headers.append(name, item));
81
+ else if (value !== void 0) headers.set(name, value);
82
+ const method = incoming.method ?? "GET";
83
+ const init = {
84
+ method,
85
+ headers,
86
+ signal
87
+ };
88
+ if (method === "GET" || method === "HEAD") return new Request(url, init);
89
+ const withBody = {
90
+ ...init,
91
+ body: bodyOf(incoming),
92
+ duplex: "half"
93
+ };
94
+ return new Request(url, withBody);
95
+ }
96
+ /**
97
+ * Write a web response. The body waits when the connection is full. When the client closes the connection, the body
98
+ * is cancelled.
99
+ */
100
+ async function send(response, outgoing) {
101
+ const headers = {};
102
+ for (const [name, value] of response.headers) if (name !== "set-cookie") headers[name] = value;
103
+ const cookies = response.headers.getSetCookie();
104
+ if (cookies.length > 0) headers["set-cookie"] = cookies;
105
+ outgoing.writeHead(response.status, response.statusText, headers);
106
+ if (response.body === null) {
107
+ outgoing.end();
108
+ return;
109
+ }
110
+ const reader = response.body.getReader();
111
+ const cancel = () => void reader.cancel().catch(() => void 0);
112
+ outgoing.on("close", cancel);
113
+ try {
114
+ for (;;) {
115
+ const chunk = await reader.read();
116
+ if (chunk.done) break;
117
+ if (outgoing.destroyed) return;
118
+ if (!outgoing.write(chunk.value)) await drained(outgoing);
119
+ }
120
+ outgoing.end();
121
+ } finally {
122
+ outgoing.off("close", cancel);
123
+ }
124
+ }
125
+ /** Resolve when the connection can take more data, or when it closes. */
126
+ function drained(outgoing) {
127
+ return new Promise((resolve) => {
128
+ const done = () => {
129
+ outgoing.off("drain", done);
130
+ outgoing.off("close", done);
131
+ resolve();
132
+ };
133
+ outgoing.on("drain", done);
134
+ outgoing.on("close", done);
135
+ });
136
+ }
137
+ /**
138
+ * The body of an incoming request, as a web stream. The stream reads the request only when the handler reads the body,
139
+ * and the request pauses after each chunk until the handler reads again. A request that the handler does not read stays
140
+ * unread, so that Node.js discards it after the response. When the handler cancels the stream, the request discards the
141
+ * rest of its body.
142
+ */
143
+ function bodyOf(incoming) {
144
+ let open = true;
145
+ let reading = false;
146
+ const close = (end) => {
147
+ if (open) end();
148
+ open = false;
149
+ };
150
+ return new ReadableStream({
151
+ pull(controller) {
152
+ if (!reading) {
153
+ reading = true;
154
+ incoming.on("data", (chunk) => {
155
+ if (!open) return;
156
+ if (!(chunk instanceof Uint8Array)) return close(() => controller.error(/* @__PURE__ */ new TypeError("a chunk of the request body is not bytes")));
157
+ controller.enqueue(chunk);
158
+ incoming.pause();
159
+ });
160
+ incoming.on("end", () => close(() => controller.close()));
161
+ incoming.on("error", (error) => close(() => controller.error(error)));
162
+ }
163
+ incoming.resume();
164
+ },
165
+ cancel() {
166
+ open = false;
167
+ incoming.resume();
168
+ }
169
+ }, { highWaterMark: 0 });
170
+ }
171
+ //#endregion
172
+ export { serve };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acid-cat/core-ts",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Serve AcidCat capabilities in plain TypeScript, with no Effect dependency.",
5
5
  "keywords": [
6
6
  "acidcat",
@@ -26,17 +26,21 @@
26
26
  ".": {
27
27
  "types": "./dist/index.d.ts",
28
28
  "default": "./dist/index.js"
29
+ },
30
+ "./node": {
31
+ "types": "./dist/node.d.ts",
32
+ "default": "./dist/node.js"
29
33
  }
30
34
  },
31
35
  "publishConfig": {
32
36
  "access": "public"
33
37
  },
34
38
  "dependencies": {
35
- "@acid-cat/catalog": "0.7.0",
36
- "@acid-cat/spec-ts": "0.7.0"
39
+ "@acid-cat/catalog": "0.9.0",
40
+ "@acid-cat/spec-ts": "0.9.0"
37
41
  },
38
42
  "devDependencies": {
39
- "@acid-cat/cli": "0.7.0",
43
+ "@acid-cat/cli": "0.9.0",
40
44
  "@types/node": "26.6.5",
41
45
  "esbuild": "0.28.2",
42
46
  "playwright": "1.64.0",