@acid-cat/core-ts 0.8.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,7 +58,8 @@ 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 |
@@ -91,6 +92,19 @@ console.log(server.url); // http://127.0.0.1:3000
91
92
 
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.
93
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
+
94
108
  ### Typed calls and handles
95
109
 
96
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:
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acid-cat/core-ts",
3
- "version": "0.8.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",
@@ -36,11 +36,11 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
- "@acid-cat/catalog": "0.8.0",
40
- "@acid-cat/spec-ts": "0.8.0"
39
+ "@acid-cat/catalog": "0.9.0",
40
+ "@acid-cat/spec-ts": "0.9.0"
41
41
  },
42
42
  "devDependencies": {
43
- "@acid-cat/cli": "0.8.0",
43
+ "@acid-cat/cli": "0.9.0",
44
44
  "@types/node": "26.6.5",
45
45
  "esbuild": "0.28.2",
46
46
  "playwright": "1.64.0",