@askrjs/node 0.0.8 → 0.0.11

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/dist/mcp.d.ts CHANGED
@@ -2,21 +2,47 @@ import { Readable, Writable } from "node:stream";
2
2
  import { AuthContext } from "@askrjs/auth";
3
3
  import { McpServer } from "@askrjs/server/mcp";
4
4
  //#region src/mcp.d.ts
5
+ /** Options for {@link connectMcpStdio}. */
5
6
  interface McpStdioOptions<Dependencies = undefined> {
7
+ /** Application dependencies passed through to each MCP request. */
6
8
  dependencies: Dependencies;
9
+ /** Stream to read newline-delimited JSON-RPC requests from; defaults to `process.stdin`. */
7
10
  input?: Readable;
11
+ /** Stream to write newline-delimited JSON-RPC responses to; defaults to `process.stdout`. */
8
12
  output?: Writable;
13
+ /** Stream that non-protocol errors are reported to; defaults to `process.stderr`. */
9
14
  diagnostics?: Writable;
15
+ /** Aborting this signal closes the connection. */
10
16
  signal?: AbortSignal;
17
+ /** Auth context to use for requests, or a function that derives one from the environment. */
11
18
  auth?: AuthContext | ((environment: NodeJS.ProcessEnv) => AuthContext | Promise<AuthContext>);
19
+ /** Environment passed to the `auth` function; defaults to `process.env`. */
12
20
  environment?: NodeJS.ProcessEnv;
21
+ /** Maximum size, in bytes, of a single input line before it is rejected; defaults to 1 MiB. */
13
22
  maxLineBytes?: number;
23
+ /** Maximum number of requests handled concurrently; defaults to 16. */
14
24
  maxConcurrency?: number;
15
25
  }
26
+ /** A live MCP stdio connection returned by {@link connectMcpStdio}. */
16
27
  interface McpStdioConnection {
28
+ /** Resolves once the connection has fully closed. */
17
29
  readonly closed: Promise<void>;
30
+ /** Closes the connection, aborting in-flight requests and terminating the MCP session. */
18
31
  close(): Promise<void>;
19
32
  }
33
+ /**
34
+ * Connects an MCP server to newline-delimited JSON-RPC over stdio (or any
35
+ * pair of readable/writable streams).
36
+ *
37
+ * Reads one JSON-RPC message per line, dispatches it to `mcp.handle`, and
38
+ * writes the response back as a line of JSON. Handles request cancellation
39
+ * notifications, enforces `maxConcurrency` and `maxLineBytes`, and cleans up
40
+ * the MCP session when the connection closes.
41
+ *
42
+ * @param mcp - The MCP server to dispatch requests to.
43
+ * @param options - Stdio connection options, including dependencies and stream overrides.
44
+ * @returns A handle exposing `closed` and `close()` for the connection's lifecycle.
45
+ */
20
46
  declare function connectMcpStdio<Dependencies>(mcp: McpServer<Dependencies>, options: McpStdioOptions<Dependencies>): McpStdioConnection;
21
47
  //#endregion
22
48
  export { McpStdioConnection, McpStdioOptions, connectMcpStdio };
package/dist/mcp.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { addAbortListener } from "node:events";
2
+ import { randomUUID } from "node:crypto";
1
3
  //#region src/mcp.ts
2
4
  const anonymous = Object.freeze({
3
5
  authenticated: false,
@@ -5,12 +7,25 @@ const anonymous = Object.freeze({
5
7
  session: null,
6
8
  tenant: null
7
9
  });
10
+ /**
11
+ * Connects an MCP server to newline-delimited JSON-RPC over stdio (or any
12
+ * pair of readable/writable streams).
13
+ *
14
+ * Reads one JSON-RPC message per line, dispatches it to `mcp.handle`, and
15
+ * writes the response back as a line of JSON. Handles request cancellation
16
+ * notifications, enforces `maxConcurrency` and `maxLineBytes`, and cleans up
17
+ * the MCP session when the connection closes.
18
+ *
19
+ * @param mcp - The MCP server to dispatch requests to.
20
+ * @param options - Stdio connection options, including dependencies and stream overrides.
21
+ * @returns A handle exposing `closed` and `close()` for the connection's lifecycle.
22
+ */
8
23
  function connectMcpStdio(mcp, options) {
9
24
  const input = options.input ?? process.stdin;
10
25
  const output = options.output ?? process.stdout;
11
26
  const diagnostics = options.diagnostics ?? process.stderr;
12
27
  const controllers = /* @__PURE__ */ new Map();
13
- const sessionId = crypto.randomUUID();
28
+ const sessionId = randomUUID();
14
29
  let finish;
15
30
  const closed = new Promise((resolve) => {
16
31
  finish = resolve;
@@ -18,31 +33,73 @@ function connectMcpStdio(mcp, options) {
18
33
  let ended = false;
19
34
  const maxLineBytes = options.maxLineBytes ?? 1048576;
20
35
  const maxConcurrency = options.maxConcurrency ?? 16;
21
- if (!Number.isInteger(maxLineBytes) || maxLineBytes <= 0) throw new TypeError("MCP maxLineBytes must be a positive integer.");
22
- if (!Number.isInteger(maxConcurrency) || maxConcurrency <= 0) throw new TypeError("MCP maxConcurrency must be a positive integer.");
36
+ if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes <= 0) throw new TypeError("MCP maxLineBytes must be a positive integer.");
37
+ if (!Number.isSafeInteger(maxConcurrency) || maxConcurrency <= 0) throw new TypeError("MCP maxConcurrency must be a positive integer.");
23
38
  let active = 0;
24
- let cleanupInput = () => void 0;
25
- const write = (message) => new Promise((resolve, reject) => {
26
- output.write(`${JSON.stringify(message)}\n`, (error) => error ? reject(error) : resolve());
27
- });
39
+ let abortListener;
40
+ let cleanupTransport = () => void 0;
41
+ const report = (error) => {
42
+ try {
43
+ diagnostics.write(`MCP stdio error: ${error instanceof Error ? error.message : String(error)}\n`);
44
+ } catch {}
45
+ };
46
+ const write = (message) => {
47
+ if (ended) return Promise.reject(/* @__PURE__ */ new Error("MCP stdio connection is closed."));
48
+ return new Promise((resolve, reject) => {
49
+ output.write(`${JSON.stringify(message)}\n`, (error) => error ? reject(error) : resolve());
50
+ });
51
+ };
28
52
  const close = async () => {
29
53
  if (ended) return closed;
30
54
  ended = true;
31
- cleanupInput();
32
- for (const controller of controllers.values()) controller.abort();
55
+ cleanupTransport();
56
+ abortListener?.[Symbol.dispose]();
57
+ for (const values of controllers.values()) for (const controller of values) controller.abort();
33
58
  controllers.clear();
34
- mcp.terminateSession(sessionId);
35
- finish();
59
+ try {
60
+ mcp.terminateSession(sessionId);
61
+ } catch (error) {
62
+ report(error);
63
+ } finally {
64
+ finish();
65
+ }
36
66
  return closed;
37
67
  };
38
- options.signal?.addEventListener("abort", () => void close(), { once: true });
68
+ if (options.signal) abortListener = addAbortListener(options.signal, () => void close());
69
+ const writeProtocolError = (code, message) => write({
70
+ jsonrpc: "2.0",
71
+ id: null,
72
+ error: {
73
+ code,
74
+ message
75
+ }
76
+ });
39
77
  const handleLine = (line) => {
78
+ if (ended) return;
79
+ let message;
80
+ try {
81
+ message = JSON.parse(line);
82
+ } catch {
83
+ writeProtocolError(-32700, "Parse error").catch(() => void close());
84
+ return;
85
+ }
86
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
87
+ writeProtocolError(-32600, "Invalid Request").catch(() => void close());
88
+ return;
89
+ }
90
+ const value = message;
91
+ if (value.method === "notifications/cancelled" && value.params && typeof value.params === "object") {
92
+ const requestId = value.params.requestId;
93
+ if (typeof requestId === "string" || typeof requestId === "number") for (const controller of controllers.get(requestId) ?? []) controller.abort();
94
+ return;
95
+ }
96
+ const id = value.id;
40
97
  if (active >= maxConcurrency) {
41
- write({
98
+ if (typeof id === "string" || typeof id === "number") write({
42
99
  jsonrpc: "2.0",
43
- id: null,
100
+ id,
44
101
  error: {
45
- code: -32600,
102
+ code: -32e3,
46
103
  message: "Too many concurrent requests"
47
104
  }
48
105
  }).catch(() => void close());
@@ -50,51 +107,52 @@ function connectMcpStdio(mcp, options) {
50
107
  }
51
108
  active += 1;
52
109
  (async () => {
53
- let message;
110
+ const controller = typeof id === "string" || typeof id === "number" ? new AbortController() : void 0;
111
+ if (controller && (typeof id === "string" || typeof id === "number")) {
112
+ const values = controllers.get(id) ?? /* @__PURE__ */ new Set();
113
+ values.add(controller);
114
+ controllers.set(id, values);
115
+ }
54
116
  try {
55
- message = JSON.parse(line);
56
- } catch {
57
- await write({
58
- jsonrpc: "2.0",
59
- id: null,
60
- error: {
61
- code: -32700,
62
- message: "Parse error"
63
- }
117
+ const environment = options.environment ?? process.env;
118
+ const auth = typeof options.auth === "function" ? await options.auth(environment) : options.auth ?? anonymous;
119
+ const result = await mcp.handle(message, {
120
+ dependencies: options.dependencies,
121
+ auth,
122
+ transport: "stdio",
123
+ sessionId,
124
+ supportsPush: true,
125
+ signal: controller?.signal ?? options.signal,
126
+ send: write
64
127
  });
65
- return;
66
- }
67
- if (message && typeof message === "object") {
68
- const value = message;
69
- if (value.method === "notifications/cancelled" && value.params && typeof value.params === "object") {
70
- const requestId = value.params.requestId;
71
- if (typeof requestId === "string" || typeof requestId === "number") controllers.get(requestId)?.abort();
72
- }
73
- const id = value.id;
74
- const controller = typeof id === "string" || typeof id === "number" ? new AbortController() : void 0;
75
- if (controller && (typeof id === "string" || typeof id === "number")) controllers.set(id, controller);
76
- try {
77
- const environment = options.environment ?? process.env;
78
- const auth = typeof options.auth === "function" ? await options.auth(environment) : options.auth ?? anonymous;
79
- const result = await mcp.handle(message, {
80
- dependencies: options.dependencies,
81
- auth,
82
- transport: "stdio",
83
- sessionId,
84
- supportsPush: true,
85
- signal: controller?.signal ?? options.signal,
86
- send: write
128
+ if (result !== void 0 && !ended) await write(result);
129
+ } catch (error) {
130
+ if (!ended) {
131
+ report(error);
132
+ if (typeof id === "string" || typeof id === "number") await write({
133
+ jsonrpc: "2.0",
134
+ id,
135
+ error: {
136
+ code: -32603,
137
+ message: "Internal error"
138
+ }
87
139
  });
88
- if (result !== void 0) await write(result);
89
- } finally {
90
- if (controller && id !== void 0) controllers.delete(id);
140
+ }
141
+ } finally {
142
+ if (controller && (typeof id === "string" || typeof id === "number")) {
143
+ const values = controllers.get(id);
144
+ values?.delete(controller);
145
+ if (values?.size === 0) controllers.delete(id);
91
146
  }
92
147
  }
93
- })().catch((error) => diagnostics.write(`MCP stdio error: ${error instanceof Error ? error.message : String(error)}\n`)).finally(() => {
148
+ })().catch((error) => {
149
+ if (!ended) report(error);
150
+ }).finally(() => {
94
151
  active -= 1;
95
152
  });
96
153
  };
97
154
  const lineBuffer = Buffer.allocUnsafe(maxLineBytes);
155
+ const decoder = new TextDecoder("utf-8", { fatal: true });
98
156
  let lineBytes = 0;
99
157
  let discardingOversizedLine = false;
100
158
  const rejectOversizedLine = () => {
@@ -108,6 +166,7 @@ function connectMcpStdio(mcp, options) {
108
166
  }).catch(() => void close());
109
167
  };
110
168
  const onData = (chunk) => {
169
+ if (ended) return;
111
170
  const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
112
171
  let offset = 0;
113
172
  while (offset < bytes.byteLength) {
@@ -127,29 +186,39 @@ function connectMcpStdio(mcp, options) {
127
186
  if (newline < 0) break;
128
187
  if (!discardingOversizedLine) {
129
188
  const length = lineBytes > 0 && lineBuffer[lineBytes - 1] === 13 ? lineBytes - 1 : lineBytes;
130
- handleLine(lineBuffer.subarray(0, length).toString("utf8"));
189
+ try {
190
+ handleLine(decoder.decode(lineBuffer.subarray(0, length)));
191
+ } catch {
192
+ writeProtocolError(-32700, "Parse error").catch(() => void close());
193
+ }
131
194
  }
132
195
  lineBytes = 0;
133
196
  discardingOversizedLine = false;
134
197
  offset = newline + 1;
135
198
  }
136
199
  };
137
- const finishInput = () => {
138
- if (!ended) {
139
- if (!discardingOversizedLine && lineBytes > 0) handleLine(lineBuffer.subarray(0, lineBytes).toString("utf8"));
140
- ended = true;
141
- cleanupInput();
142
- finish();
143
- }
200
+ const finishInput = () => void close();
201
+ const inputError = (error) => {
202
+ report(error);
203
+ close();
204
+ };
205
+ const outputError = (error) => {
206
+ report(error);
207
+ close();
144
208
  };
145
209
  input.on("data", onData);
146
210
  input.once("end", finishInput);
147
211
  input.once("close", finishInput);
148
- cleanupInput = () => {
212
+ input.once("error", inputError);
213
+ output.once("error", outputError);
214
+ cleanupTransport = () => {
149
215
  input.off("data", onData);
150
216
  input.off("end", finishInput);
151
217
  input.off("close", finishInput);
218
+ input.off("error", inputError);
219
+ output.off("error", outputError);
152
220
  };
221
+ if (input.readableEnded || input.destroyed || output.destroyed) close();
153
222
  return {
154
223
  closed,
155
224
  close
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askrjs/node",
3
- "version": "0.0.8",
3
+ "version": "0.0.11",
4
4
  "description": "Node http adapter for @askrjs/server",
5
5
  "keywords": [
6
6
  "askr",
@@ -43,10 +43,12 @@
43
43
  "access": "public"
44
44
  },
45
45
  "scripts": {
46
+ "bench": "vitest bench --run",
47
+ "bench:http": "vitest bench --run benches/http.bench.ts",
46
48
  "build": "vp pack",
47
49
  "fmt": "vp fmt .",
48
50
  "lint": "vp lint .",
49
- "typecheck": "tsc -p tsconfig.json --noEmit",
51
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit",
50
52
  "test": "vp test run",
51
53
  "test:publint": "publint",
52
54
  "pack:check": "node tests/package-artifacts.js",
@@ -55,18 +57,18 @@
55
57
  "prepublishOnly": "npm run check"
56
58
  },
57
59
  "dependencies": {
58
- "@askrjs/auth": ">=0.0.1 <0.1.0",
59
- "@askrjs/server": ">=0.0.5 <0.1.0",
60
- "ws": "^8.21.1"
60
+ "@askrjs/auth": ">=0.0.8 <0.1.0",
61
+ "@askrjs/server": ">=0.0.11 <0.1.0",
62
+ "@types/ws": "^8.18.1",
63
+ "ws": "^8.21.3"
61
64
  },
62
65
  "devDependencies": {
63
- "@askrjs/schema": ">=0.0.2 <0.1.0",
64
- "@types/node": "^26.0.0",
65
- "@types/ws": "^8.18.1",
66
- "publint": "^0.3.21",
67
- "typescript": "^6.0.0",
68
- "vite-plus": "^0.2.4",
69
- "vitest": "^4.0.0"
66
+ "@askrjs/schema": ">=0.0.5 <0.1.0",
67
+ "@types/node": "^26.2.0",
68
+ "publint": "^0.3.23",
69
+ "typescript": "^7.0.2",
70
+ "vite-plus": "^0.2.8",
71
+ "vitest": "^4.1.10"
70
72
  },
71
73
  "engines": {
72
74
  "node": ">=24.0.0"