@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 +55 -1
- package/dist/cli.js +14 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -1
- package/dist/mcp-stdio.d.ts +28 -0
- package/dist/mcp-stdio.js +244 -0
- package/dist/mcp.d.ts +13 -0
- package/dist/mcp.js +3 -1
- package/dist/node.d.ts +39 -0
- package/dist/node.js +172 -0
- package/package.json +8 -4
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,
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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.
|
|
36
|
-
"@acid-cat/spec-ts": "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.
|
|
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",
|