@toolgrain/node 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +91 -2
- package/dist/framing.d.ts +8 -0
- package/dist/framing.js +59 -0
- package/dist/http-body.d.ts +3 -0
- package/dist/http-body.js +38 -0
- package/dist/http-connection.d.ts +6 -0
- package/dist/http-connection.js +22 -0
- package/dist/http-handler.d.ts +2 -0
- package/dist/http-handler.js +23 -0
- package/dist/http-listener.d.ts +15 -0
- package/dist/http-listener.js +95 -0
- package/dist/http-request.d.ts +6 -0
- package/dist/http-request.js +36 -0
- package/dist/http-response.d.ts +4 -0
- package/dist/http-response.js +72 -0
- package/dist/http-url.d.ts +5 -0
- package/dist/http-url.js +23 -0
- package/dist/http.d.ts +5 -0
- package/dist/http.js +10 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/stdio.d.ts +10 -0
- package/dist/stdio.js +118 -0
- package/dist/write.d.ts +3 -0
- package/dist/write.js +82 -0
- package/package.json +61 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Keshav Katwe
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,92 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @toolgrain/node
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Node.js stdio and HTTP transports for Toolgrain MCP servers. Tools and server
|
|
4
|
+
composition live in
|
|
5
|
+
[@toolgrain/mcp](https://github.com/keshavkatwe/toolgrain/tree/main/packages/mcp#readme).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Install the same `0.1.0` version across Toolgrain libraries.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install @toolgrain/node@0.1.0 @toolgrain/mcp@0.1.0 zod
|
|
13
|
+
npm install --save-dev typescript @types/node
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Node-facing declarations use Node types, so TypeScript applications need
|
|
17
|
+
`@types/node` in their development dependencies.
|
|
18
|
+
|
|
19
|
+
## Own an HTTP listener
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { createServer, tool } from "@toolgrain/mcp";
|
|
23
|
+
import { http } from "@toolgrain/node";
|
|
24
|
+
import { z } from "zod";
|
|
25
|
+
|
|
26
|
+
const server = createServer({
|
|
27
|
+
name: "hello-http",
|
|
28
|
+
version: "1.0.0",
|
|
29
|
+
tools: {
|
|
30
|
+
greet: tool({
|
|
31
|
+
input: z.object({ name: z.string().min(1) }),
|
|
32
|
+
execute: ({ name }) => `Hello, ${name}!`,
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const transport = http({ port: 3000 });
|
|
38
|
+
await server.listen(transport);
|
|
39
|
+
console.error(`MCP port: ${transport.address()?.port}`);
|
|
40
|
+
|
|
41
|
+
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
|
42
|
+
process.once(signal, () => {
|
|
43
|
+
void server.close();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Save as `server.mts`, compile with TypeScript using
|
|
49
|
+
`--strict --module NodeNext --target ES2022`, and run `node server.mjs`.
|
|
50
|
+
The endpoint defaults to `http://127.0.0.1:3000/mcp`. `server.close()` closes the
|
|
51
|
+
owned listener. Toolgrain installs no process signal handlers; the example adds
|
|
52
|
+
its own shutdown handling.
|
|
53
|
+
|
|
54
|
+
## Stdio and existing listeners
|
|
55
|
+
|
|
56
|
+
To use the same unprotected tool definitions over stdio, import `stdio` from
|
|
57
|
+
`@toolgrain/node` and attach `stdio()` to a fresh server instead of `http()`.
|
|
58
|
+
`stdio({ input?, output?, maxMessageBytes? })` defaults to process stdin/stdout
|
|
59
|
+
and a 1 MiB incoming-message limit. EOF closes the transport. Application logs
|
|
60
|
+
must go to stderr. Closing detaches listeners and pauses input without ending
|
|
61
|
+
supplied streams or exiting the process. See the complete
|
|
62
|
+
[stdio example](https://github.com/keshavkatwe/toolgrain/tree/main/apps/stdio-example).
|
|
63
|
+
|
|
64
|
+
Use `createNodeHttpHandler(server, options?)` with an existing Node HTTP listener
|
|
65
|
+
or Express. Mount the combined handler at the root before body parsing; it owns
|
|
66
|
+
MCP and optional auth-metadata routing, while your application owns shutdown.
|
|
67
|
+
See the [Express example](https://github.com/keshavkatwe/toolgrain/tree/main/apps/express-example#readme).
|
|
68
|
+
|
|
69
|
+
Both HTTP entry points support `path`, `allowedHosts`, `allowedOrigins`, and
|
|
70
|
+
`maxMessageBytes`. `http()` additionally accepts `port` and `hostname`; use port
|
|
71
|
+
0 for an available port and `address()` to read a copied bound address. It
|
|
72
|
+
restricts default loopback hosts to the actual port; externally mounted handlers
|
|
73
|
+
default to loopback on any port. Set production hosts explicitly. Applications
|
|
74
|
+
own CORS, TLS, and trusted proxy normalization. Forwarded headers are ignored.
|
|
75
|
+
|
|
76
|
+
Auth-configured servers require HTTP. See the
|
|
77
|
+
[authorization guide](https://github.com/keshavkatwe/toolgrain/blob/main/docs/authorization.md).
|
|
78
|
+
|
|
79
|
+
## Requirements and protocol support
|
|
80
|
+
|
|
81
|
+
ESM only. Node.js `^24.20.0` or Bun `>=1.4.4 <2`; Zod `^4.6.6` through MCP;
|
|
82
|
+
TypeScript 5.5.4 or later with `strict` enabled. Both MCP `2026-07-28` and
|
|
83
|
+
`2025-06-18` are supported over stdio and independent HTTP POSTs with JSON
|
|
84
|
+
responses. See the
|
|
85
|
+
[support policy](https://github.com/keshavkatwe/toolgrain/blob/main/docs/support-policy.md)
|
|
86
|
+
and [client compatibility notes](https://github.com/keshavkatwe/toolgrain/blob/main/docs/compatibility.md).
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT License, copyright 2026 Keshav Katwe. See the
|
|
91
|
+
[license text](https://github.com/keshavkatwe/toolgrain/blob/main/packages/node/LICENSE).
|
|
92
|
+
Third-party dependencies retain their own licenses.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { TransportCallbacks } from "@toolgrain/mcp";
|
|
2
|
+
type DecoderCallbacks = Pick<TransportCallbacks, "onMessage" | "onParseError">;
|
|
3
|
+
/** Collects byte fragments until a newline, then decodes one UTF-8 JSON message. */
|
|
4
|
+
export declare function createMessageDecoder(callbacks: DecoderCallbacks, maxMessageBytes: number): {
|
|
5
|
+
write(chunk: Buffer): void;
|
|
6
|
+
end(): void;
|
|
7
|
+
};
|
|
8
|
+
export {};
|
package/dist/framing.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Collects byte fragments until a newline, then decodes one UTF-8 JSON message. */
|
|
2
|
+
export function createMessageDecoder(callbacks, maxMessageBytes) {
|
|
3
|
+
let fragments = [];
|
|
4
|
+
let bufferedBytes = 0;
|
|
5
|
+
const utf8 = new TextDecoder("utf-8", { fatal: true });
|
|
6
|
+
function append(fragment) {
|
|
7
|
+
bufferedBytes += fragment.length;
|
|
8
|
+
if (bufferedBytes > maxMessageBytes)
|
|
9
|
+
throw new Error(`MCP message exceeds ${maxMessageBytes} bytes`);
|
|
10
|
+
if (fragment.length)
|
|
11
|
+
fragments.push(fragment);
|
|
12
|
+
}
|
|
13
|
+
function resetBuffer() {
|
|
14
|
+
fragments = [];
|
|
15
|
+
bufferedBytes = 0;
|
|
16
|
+
}
|
|
17
|
+
function decodeMessage() {
|
|
18
|
+
const bytes = Buffer.concat(fragments, bufferedBytes);
|
|
19
|
+
resetBuffer();
|
|
20
|
+
if (isBlank(bytes))
|
|
21
|
+
return;
|
|
22
|
+
let value;
|
|
23
|
+
try {
|
|
24
|
+
value = JSON.parse(utf8.decode(bytes));
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
callbacks.onParseError(error);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
callbacks.onMessage(value);
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
write(chunk) {
|
|
34
|
+
let start = 0;
|
|
35
|
+
for (;;) {
|
|
36
|
+
const newline = chunk.indexOf(10, start);
|
|
37
|
+
if (newline < 0) {
|
|
38
|
+
append(chunk.subarray(start));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
append(chunk.subarray(start, newline));
|
|
42
|
+
decodeMessage();
|
|
43
|
+
start = newline + 1;
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
end() {
|
|
47
|
+
if (!isBlank(Buffer.concat(fragments, bufferedBytes)))
|
|
48
|
+
callbacks.onParseError(new SyntaxError("Unterminated MCP message at end of input"));
|
|
49
|
+
resetBuffer();
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** Lines containing only JSON whitespace carry no message and are skipped. */
|
|
54
|
+
function isBlank(bytes) {
|
|
55
|
+
for (const byte of bytes)
|
|
56
|
+
if (byte !== 0x20 && byte !== 0x09 && byte !== 0x0d)
|
|
57
|
+
return false;
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Cancelling body consumption must not destroy the socket before an error response is sent. */
|
|
2
|
+
export function requestBody(input) {
|
|
3
|
+
let detach;
|
|
4
|
+
return new ReadableStream({
|
|
5
|
+
start(controller) {
|
|
6
|
+
const data = (chunk) => {
|
|
7
|
+
controller.enqueue(chunk);
|
|
8
|
+
input.pause();
|
|
9
|
+
};
|
|
10
|
+
const end = () => {
|
|
11
|
+
detach();
|
|
12
|
+
controller.close();
|
|
13
|
+
};
|
|
14
|
+
const error = (failure) => {
|
|
15
|
+
detach();
|
|
16
|
+
controller.error(failure);
|
|
17
|
+
};
|
|
18
|
+
const aborted = () => error(new Error("Request aborted"));
|
|
19
|
+
detach = () => {
|
|
20
|
+
input.off("data", data);
|
|
21
|
+
input.off("end", end);
|
|
22
|
+
input.off("error", error);
|
|
23
|
+
input.off("aborted", aborted);
|
|
24
|
+
input.pause();
|
|
25
|
+
};
|
|
26
|
+
input.on("data", data);
|
|
27
|
+
input.once("end", end);
|
|
28
|
+
input.once("error", error);
|
|
29
|
+
input.once("aborted", aborted);
|
|
30
|
+
},
|
|
31
|
+
pull() {
|
|
32
|
+
input.resume();
|
|
33
|
+
},
|
|
34
|
+
cancel() {
|
|
35
|
+
detach();
|
|
36
|
+
},
|
|
37
|
+
}, { highWaterMark: 0 });
|
|
38
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
/** One HTTP invocation owns its abort signal and listener cleanup. */
|
|
3
|
+
export declare function watchHttpConnection(input: IncomingMessage, output: ServerResponse): {
|
|
4
|
+
signal: AbortSignal;
|
|
5
|
+
dispose(): void;
|
|
6
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** One HTTP invocation owns its abort signal and listener cleanup. */
|
|
2
|
+
export function watchHttpConnection(input, output) {
|
|
3
|
+
const controller = new AbortController();
|
|
4
|
+
const abort = () => controller.abort();
|
|
5
|
+
const disconnected = () => {
|
|
6
|
+
if (!output.writableFinished)
|
|
7
|
+
abort();
|
|
8
|
+
};
|
|
9
|
+
input.once("aborted", abort);
|
|
10
|
+
input.once("error", abort);
|
|
11
|
+
output.once("error", abort);
|
|
12
|
+
output.once("close", disconnected);
|
|
13
|
+
return {
|
|
14
|
+
signal: controller.signal,
|
|
15
|
+
dispose() {
|
|
16
|
+
input.off("aborted", abort);
|
|
17
|
+
input.off("error", abort);
|
|
18
|
+
output.off("error", abort);
|
|
19
|
+
output.off("close", disconnected);
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
export declare function nodeHttpHandler(handle: (request: Request) => Promise<Response>, matches?: (pathname: string) => boolean): (input: IncomingMessage, output: ServerResponse, next?: () => void) => Promise<void>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { watchHttpConnection } from "./http-connection.js";
|
|
2
|
+
import { handleNodeRequest } from "./http-request.js";
|
|
3
|
+
import { writeHttpFailure, writeHttpResponse } from "./http-response.js";
|
|
4
|
+
import { requestUrl } from "./http-url.js";
|
|
5
|
+
export function nodeHttpHandler(handle, matches) {
|
|
6
|
+
return async (input, output, next) => {
|
|
7
|
+
const connection = watchHttpConnection(input, output);
|
|
8
|
+
try {
|
|
9
|
+
if (next && matches && !matches(new URL(requestUrl(input)).pathname)) {
|
|
10
|
+
next();
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const response = await handleNodeRequest(input, connection.signal, handle);
|
|
14
|
+
await writeHttpResponse(output, response, connection.signal, input.complete);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
await writeHttpFailure(output, connection.signal, error);
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
connection.dispose();
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AddressInfo } from "node:net";
|
|
2
|
+
import type { HttpOptions } from "@toolgrain/http";
|
|
3
|
+
import type { RequestTransport } from "@toolgrain/mcp";
|
|
4
|
+
export interface HttpTransportOptions extends HttpOptions {
|
|
5
|
+
/** Defaults to 3000. Use 0 to let the operating system choose a port. */
|
|
6
|
+
port?: number;
|
|
7
|
+
/** Bind address; defaults to 127.0.0.1. This is separate from allowedHosts. */
|
|
8
|
+
hostname?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface HttpTransport extends RequestTransport {
|
|
11
|
+
/** A copied bound address, or undefined before startup and after closing. */
|
|
12
|
+
address(): AddressInfo | undefined;
|
|
13
|
+
}
|
|
14
|
+
/** Own a Node HTTP listener with combined MCP routing and local host defaults. */
|
|
15
|
+
export declare function http(options?: HttpTransportOptions): HttpTransport;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createServer as createHttpServer, } from "node:http";
|
|
2
|
+
import { createNodeHttpHandler } from "./http.js";
|
|
3
|
+
/** Own a Node HTTP listener with combined MCP routing and local host defaults. */
|
|
4
|
+
export function http(options = {}) {
|
|
5
|
+
const port = options.port ?? 3000;
|
|
6
|
+
const hostname = options.hostname ?? "127.0.0.1";
|
|
7
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
8
|
+
throw new RangeError("port must be an integer between 0 and 65535");
|
|
9
|
+
if (!hostname || /[\s/?#@\\]/.test(hostname))
|
|
10
|
+
throw new TypeError("hostname must be a bind hostname or IP address");
|
|
11
|
+
const config = {
|
|
12
|
+
...options,
|
|
13
|
+
...(options.allowedHosts === undefined
|
|
14
|
+
? {}
|
|
15
|
+
: { allowedHosts: [...options.allowedHosts] }),
|
|
16
|
+
...(options.allowedOrigins === undefined
|
|
17
|
+
? {}
|
|
18
|
+
: { allowedOrigins: [...options.allowedOrigins] }),
|
|
19
|
+
};
|
|
20
|
+
let listener;
|
|
21
|
+
let started = false;
|
|
22
|
+
let closed = false;
|
|
23
|
+
let closePromise;
|
|
24
|
+
const binding = new AbortController();
|
|
25
|
+
let onClose;
|
|
26
|
+
const notifyClose = () => {
|
|
27
|
+
const notify = onClose;
|
|
28
|
+
onClose = undefined;
|
|
29
|
+
notify?.();
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
mode: "request",
|
|
33
|
+
address() {
|
|
34
|
+
const address = listener?.address();
|
|
35
|
+
return address && typeof address !== "string"
|
|
36
|
+
? { ...address }
|
|
37
|
+
: undefined;
|
|
38
|
+
},
|
|
39
|
+
async start(server, callbacks) {
|
|
40
|
+
if (started || closed)
|
|
41
|
+
throw new Error("HTTP transport cannot restart");
|
|
42
|
+
started = true;
|
|
43
|
+
const node = createHttpServer();
|
|
44
|
+
listener = node;
|
|
45
|
+
onClose = callbacks.onClose;
|
|
46
|
+
node.once("close", notifyClose);
|
|
47
|
+
await new Promise((resolve, reject) => {
|
|
48
|
+
const cleanup = () => {
|
|
49
|
+
node.off("error", failed);
|
|
50
|
+
node.off("close", stopped);
|
|
51
|
+
node.off("listening", ready);
|
|
52
|
+
};
|
|
53
|
+
const failed = (error) => {
|
|
54
|
+
cleanup();
|
|
55
|
+
reject(error);
|
|
56
|
+
};
|
|
57
|
+
const stopped = () => failed(new Error("HTTP transport closed during startup"));
|
|
58
|
+
const ready = () => {
|
|
59
|
+
cleanup();
|
|
60
|
+
resolve();
|
|
61
|
+
};
|
|
62
|
+
node.once("error", failed);
|
|
63
|
+
node.once("close", stopped);
|
|
64
|
+
node.once("listening", ready);
|
|
65
|
+
node.listen({ host: hostname, port, signal: binding.signal });
|
|
66
|
+
});
|
|
67
|
+
const address = node.address();
|
|
68
|
+
const localHosts = ["localhost", "127.0.0.1", "[::1]"].map((host) => `${host}:${address.port}`);
|
|
69
|
+
const handle = createNodeHttpHandler(server, {
|
|
70
|
+
...config,
|
|
71
|
+
allowedHosts: config.allowedHosts ?? localHosts,
|
|
72
|
+
});
|
|
73
|
+
node.on("error", callbacks.onError);
|
|
74
|
+
node.on("request", (request, response) => {
|
|
75
|
+
void handle(request, response);
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
close() {
|
|
79
|
+
if (closePromise)
|
|
80
|
+
return closePromise;
|
|
81
|
+
closed = true;
|
|
82
|
+
closePromise = Promise.resolve().then(() => new Promise((resolve) => {
|
|
83
|
+
if (!listener) {
|
|
84
|
+
resolve();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
listener.close(() => resolve());
|
|
88
|
+
binding.abort();
|
|
89
|
+
listener.closeAllConnections();
|
|
90
|
+
}));
|
|
91
|
+
notifyClose();
|
|
92
|
+
return closePromise;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
export declare class ConsumedRequestError extends Error {
|
|
3
|
+
constructor();
|
|
4
|
+
}
|
|
5
|
+
/** Adapt Node input to a Web Request; release body listeners even when validation rejects it. */
|
|
6
|
+
export declare function handleNodeRequest(input: IncomingMessage, signal: AbortSignal, handle: (request: Request) => Promise<Response>): Promise<Response>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { requestBody } from "./http-body.js";
|
|
2
|
+
import { requestUrl } from "./http-url.js";
|
|
3
|
+
export class ConsumedRequestError extends Error {
|
|
4
|
+
constructor() {
|
|
5
|
+
super("Request body has already been consumed");
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
function requestHeaders(input) {
|
|
9
|
+
const headers = new Headers();
|
|
10
|
+
for (let index = 0; index < input.rawHeaders.length; index += 2) {
|
|
11
|
+
headers.append(input.rawHeaders[index], input.rawHeaders[index + 1]);
|
|
12
|
+
}
|
|
13
|
+
return headers;
|
|
14
|
+
}
|
|
15
|
+
/** Adapt Node input to a Web Request; release body listeners even when validation rejects it. */
|
|
16
|
+
export async function handleNodeRequest(input, signal, handle) {
|
|
17
|
+
if (input.readableEnded || input.destroyed || input.readableEncoding)
|
|
18
|
+
throw new ConsumedRequestError();
|
|
19
|
+
let body;
|
|
20
|
+
try {
|
|
21
|
+
const method = input.method ?? "GET";
|
|
22
|
+
const headers = requestHeaders(input);
|
|
23
|
+
body =
|
|
24
|
+
method === "GET" || method === "HEAD" ? undefined : requestBody(input);
|
|
25
|
+
const init = {
|
|
26
|
+
method,
|
|
27
|
+
headers,
|
|
28
|
+
signal,
|
|
29
|
+
...(body ? { body, duplex: "half" } : {}),
|
|
30
|
+
};
|
|
31
|
+
return await handle(new Request(requestUrl(input), init));
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
await body?.cancel().catch(() => { });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ServerResponse } from "node:http";
|
|
2
|
+
/** Preserve Node backpressure and close keep-alive when an incoming body was not fully read. */
|
|
3
|
+
export declare function writeHttpResponse(output: ServerResponse, response: Response, signal: AbortSignal, inputComplete: boolean): Promise<void>;
|
|
4
|
+
export declare function writeHttpFailure(output: ServerResponse, signal: AbortSignal, error: unknown): Promise<void>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { once } from "node:events";
|
|
2
|
+
import { ConsumedRequestError } from "./http-request.js";
|
|
3
|
+
import { InvalidRequestUrlError } from "./http-url.js";
|
|
4
|
+
async function writeBody(output, body, signal) {
|
|
5
|
+
const reader = body.getReader();
|
|
6
|
+
try {
|
|
7
|
+
for (;;) {
|
|
8
|
+
const { done, value } = await reader.read();
|
|
9
|
+
if (done || signal.aborted)
|
|
10
|
+
break;
|
|
11
|
+
if (!output.write(value))
|
|
12
|
+
await once(output, "drain", { signal });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
await reader.cancel().catch(() => { });
|
|
17
|
+
reader.releaseLock();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Preserve Node backpressure and close keep-alive when an incoming body was not fully read. */
|
|
21
|
+
export async function writeHttpResponse(output, response, signal, inputComplete) {
|
|
22
|
+
if (signal.aborted)
|
|
23
|
+
return;
|
|
24
|
+
if (!inputComplete)
|
|
25
|
+
response.headers.set("Connection", "close");
|
|
26
|
+
output.writeHead(response.status, Object.fromEntries(response.headers));
|
|
27
|
+
if (response.body)
|
|
28
|
+
await writeBody(output, response.body, signal);
|
|
29
|
+
if (signal.aborted)
|
|
30
|
+
return;
|
|
31
|
+
await finishResponse(output, signal);
|
|
32
|
+
}
|
|
33
|
+
async function finishResponse(output, signal) {
|
|
34
|
+
await new Promise((resolve, reject) => {
|
|
35
|
+
const cleanup = () => {
|
|
36
|
+
output.off("finish", finish);
|
|
37
|
+
signal.removeEventListener("abort", abort);
|
|
38
|
+
};
|
|
39
|
+
const finish = () => {
|
|
40
|
+
cleanup();
|
|
41
|
+
resolve();
|
|
42
|
+
};
|
|
43
|
+
const abort = () => {
|
|
44
|
+
cleanup();
|
|
45
|
+
reject(signal.reason);
|
|
46
|
+
};
|
|
47
|
+
output.once("finish", finish);
|
|
48
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
49
|
+
try {
|
|
50
|
+
output.end();
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
cleanup();
|
|
54
|
+
reject(error);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
export async function writeHttpFailure(output, signal, error) {
|
|
59
|
+
if (signal.aborted || output.destroyed)
|
|
60
|
+
return;
|
|
61
|
+
if (output.headersSent) {
|
|
62
|
+
output.destroy();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
output.writeHead(error instanceof ConsumedRequestError ||
|
|
66
|
+
error instanceof InvalidRequestUrlError
|
|
67
|
+
? 400
|
|
68
|
+
: 500);
|
|
69
|
+
await finishResponse(output, signal).catch(() => {
|
|
70
|
+
output.destroy();
|
|
71
|
+
});
|
|
72
|
+
}
|
package/dist/http-url.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export class InvalidRequestUrlError extends Error {
|
|
2
|
+
}
|
|
3
|
+
/** Proxy headers require normalization by trusted application middleware. */
|
|
4
|
+
export function requestUrl(input) {
|
|
5
|
+
const host = input.headers.host;
|
|
6
|
+
const target = input.url ?? "/";
|
|
7
|
+
if (!host ||
|
|
8
|
+
/[\s,/?#@\\]/.test(host) ||
|
|
9
|
+
!target.startsWith("/") ||
|
|
10
|
+
target.startsWith("//") ||
|
|
11
|
+
target.includes("\\"))
|
|
12
|
+
throw new InvalidRequestUrlError("Invalid HTTP request URL");
|
|
13
|
+
const secure = "encrypted" in input.socket && input.socket.encrypted === true;
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(target, `${secure ? "https" : "http"}://${host}`);
|
|
16
|
+
if (url.username || url.password || url.hash)
|
|
17
|
+
throw new Error("Invalid URL");
|
|
18
|
+
return url.href;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new InvalidRequestUrlError("Invalid HTTP request URL");
|
|
22
|
+
}
|
|
23
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type HttpOptions } from "@toolgrain/http";
|
|
2
|
+
import type { Server } from "@toolgrain/mcp";
|
|
3
|
+
/** Mount at the root before body parsing. Delegates unmatched paths when next is supplied. */
|
|
4
|
+
export declare function createNodeHttpHandler(server: Server, options?: HttpOptions): (input: import("http").IncomingMessage, output: import("http").ServerResponse, next?: () => void) => Promise<void>;
|
|
5
|
+
export declare function createNodeProtectedResourceMetadataHandler(server: Server, options?: Pick<HttpOptions, "allowedHosts">): (input: import("http").IncomingMessage, output: import("http").ServerResponse, next?: () => void) => Promise<void>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createHttpHandler, createProtectedResourceMetadataHandler, } from "@toolgrain/http";
|
|
2
|
+
import { nodeHttpHandler } from "./http-handler.js";
|
|
3
|
+
/** Mount at the root before body parsing. Delegates unmatched paths when next is supplied. */
|
|
4
|
+
export function createNodeHttpHandler(server, options) {
|
|
5
|
+
const handle = createHttpHandler(server, options);
|
|
6
|
+
return nodeHttpHandler(handle, handle.matches);
|
|
7
|
+
}
|
|
8
|
+
export function createNodeProtectedResourceMetadataHandler(server, options) {
|
|
9
|
+
return nodeHttpHandler(createProtectedResourceMetadataHandler(server, options));
|
|
10
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type { HttpOptions } from "@toolgrain/http";
|
|
2
|
+
export { createNodeHttpHandler, createNodeProtectedResourceMetadataHandler, } from "./http.js";
|
|
3
|
+
export type { HttpTransport, HttpTransportOptions } from "./http-listener.js";
|
|
4
|
+
export { http } from "./http-listener.js";
|
|
5
|
+
export type { StdioOptions } from "./stdio.js";
|
|
6
|
+
export { stdio } from "./stdio.js";
|
package/dist/index.js
ADDED
package/dist/stdio.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Readable, Writable } from "node:stream";
|
|
2
|
+
import type { Transport } from "@toolgrain/mcp";
|
|
3
|
+
export interface StdioOptions {
|
|
4
|
+
input?: Readable;
|
|
5
|
+
output?: Writable;
|
|
6
|
+
/** Maximum UTF-8 bytes per incoming message, excluding its newline. Default: 1 MiB. */
|
|
7
|
+
maxMessageBytes?: number;
|
|
8
|
+
}
|
|
9
|
+
/** A single-use transport; supplied streams remain owned by the caller. */
|
|
10
|
+
export declare function stdio(options?: StdioOptions): Transport;
|
package/dist/stdio.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { stdin, stdout } from "node:process";
|
|
2
|
+
import { createMessageDecoder } from "./framing.js";
|
|
3
|
+
import { writeLine } from "./write.js";
|
|
4
|
+
/** A single-use transport; supplied streams remain owned by the caller. */
|
|
5
|
+
export function stdio(options = {}) {
|
|
6
|
+
return new StdioTransport(options);
|
|
7
|
+
}
|
|
8
|
+
class StdioTransport {
|
|
9
|
+
input;
|
|
10
|
+
output;
|
|
11
|
+
maxMessageBytes;
|
|
12
|
+
writeCancellation = new AbortController();
|
|
13
|
+
state = "idle";
|
|
14
|
+
callbacks;
|
|
15
|
+
decoder;
|
|
16
|
+
writes = Promise.resolve();
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.input = options.input ?? stdin;
|
|
19
|
+
this.output = options.output ?? stdout;
|
|
20
|
+
this.maxMessageBytes = options.maxMessageBytes ?? 1024 * 1024;
|
|
21
|
+
if (!Number.isSafeInteger(this.maxMessageBytes) || this.maxMessageBytes < 1)
|
|
22
|
+
throw new RangeError("maxMessageBytes must be a positive safe integer");
|
|
23
|
+
}
|
|
24
|
+
// Arrow methods preserve detached calls supported by the factory's original API.
|
|
25
|
+
start = async (transportCallbacks) => {
|
|
26
|
+
if (this.state !== "idle")
|
|
27
|
+
throw new Error("Stdio transport can only start once");
|
|
28
|
+
if (this.input.destroyed ||
|
|
29
|
+
this.input.readableEnded ||
|
|
30
|
+
this.output.destroyed ||
|
|
31
|
+
this.output.writableEnded) {
|
|
32
|
+
this.shutdown();
|
|
33
|
+
throw new Error("Stdio streams are already closed");
|
|
34
|
+
}
|
|
35
|
+
this.state = "open";
|
|
36
|
+
this.callbacks = transportCallbacks;
|
|
37
|
+
this.decoder = createMessageDecoder({
|
|
38
|
+
onMessage: (message) => {
|
|
39
|
+
if (this.state === "open")
|
|
40
|
+
this.callbacks?.onMessage(message);
|
|
41
|
+
},
|
|
42
|
+
onParseError: (error) => {
|
|
43
|
+
if (this.state === "open")
|
|
44
|
+
this.callbacks?.onParseError(error);
|
|
45
|
+
},
|
|
46
|
+
}, this.maxMessageBytes);
|
|
47
|
+
this.input.on("end", this.onInputEnd);
|
|
48
|
+
this.input.on("close", this.onStreamClose);
|
|
49
|
+
this.input.on("error", this.onStreamError);
|
|
50
|
+
this.output.on("close", this.onStreamClose);
|
|
51
|
+
this.output.on("error", this.onStreamError);
|
|
52
|
+
this.input.on("data", this.onData);
|
|
53
|
+
this.input.resume();
|
|
54
|
+
};
|
|
55
|
+
send = (message) => {
|
|
56
|
+
const write = this.writes.then(async () => {
|
|
57
|
+
if (this.state !== "open")
|
|
58
|
+
throw new Error("Stdio transport is not open");
|
|
59
|
+
await writeLine(this.output, `${JSON.stringify(message)}\n`, this.writeCancellation.signal);
|
|
60
|
+
});
|
|
61
|
+
this.writes = write.catch(() => {
|
|
62
|
+
/* Keep the write queue usable; the caller receives the rejection. */
|
|
63
|
+
});
|
|
64
|
+
return write;
|
|
65
|
+
};
|
|
66
|
+
close = async () => {
|
|
67
|
+
this.shutdown();
|
|
68
|
+
};
|
|
69
|
+
shutdown(reason) {
|
|
70
|
+
if (this.state === "closed")
|
|
71
|
+
return;
|
|
72
|
+
this.state = "closed";
|
|
73
|
+
const notify = this.callbacks;
|
|
74
|
+
this.callbacks = undefined;
|
|
75
|
+
this.decoder = undefined;
|
|
76
|
+
this.writeCancellation.abort(reason && "error" in reason
|
|
77
|
+
? reason.error
|
|
78
|
+
: new Error("Stdio transport is closed"));
|
|
79
|
+
this.detach();
|
|
80
|
+
if (reason && "error" in reason)
|
|
81
|
+
notify?.onError(reason.error);
|
|
82
|
+
else if (reason)
|
|
83
|
+
notify?.onClose();
|
|
84
|
+
}
|
|
85
|
+
detach() {
|
|
86
|
+
this.input.off("data", this.onData);
|
|
87
|
+
this.input.off("end", this.onInputEnd);
|
|
88
|
+
this.input.off("close", this.onStreamClose);
|
|
89
|
+
this.input.off("error", this.onStreamError);
|
|
90
|
+
this.output.off("close", this.onStreamClose);
|
|
91
|
+
this.output.off("error", this.onStreamError);
|
|
92
|
+
this.input.pause();
|
|
93
|
+
}
|
|
94
|
+
onStreamError = (error) => {
|
|
95
|
+
this.shutdown({ error });
|
|
96
|
+
};
|
|
97
|
+
onStreamClose = () => {
|
|
98
|
+
this.shutdown({ closed: true });
|
|
99
|
+
};
|
|
100
|
+
onInputEnd = () => {
|
|
101
|
+
try {
|
|
102
|
+
this.decoder?.end();
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
this.onStreamError(error);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
this.onStreamClose();
|
|
109
|
+
};
|
|
110
|
+
onData = (chunk) => {
|
|
111
|
+
try {
|
|
112
|
+
this.decoder?.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
this.onStreamError(error);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
package/dist/write.d.ts
ADDED
package/dist/write.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Resolves after both the write callback and any required drain event. */
|
|
2
|
+
export function writeLine(output, line, signal) {
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
if (signal.aborted) {
|
|
5
|
+
reject(signal.reason);
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
let writeCallbackComplete = false;
|
|
9
|
+
let backpressureCleared = false;
|
|
10
|
+
let settled = false;
|
|
11
|
+
function cleanup() {
|
|
12
|
+
output.off("error", onError);
|
|
13
|
+
output.off("close", onClose);
|
|
14
|
+
output.off("drain", onDrain);
|
|
15
|
+
signal.removeEventListener("abort", onAbort);
|
|
16
|
+
}
|
|
17
|
+
function rejectWrite(error) {
|
|
18
|
+
if (!settled) {
|
|
19
|
+
settled = true;
|
|
20
|
+
reject(error);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function finish() {
|
|
24
|
+
if (settled) {
|
|
25
|
+
if (writeCallbackComplete)
|
|
26
|
+
cleanup();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (writeCallbackComplete && backpressureCleared) {
|
|
30
|
+
settled = true;
|
|
31
|
+
cleanup();
|
|
32
|
+
resolve();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function onError(error) {
|
|
36
|
+
rejectWrite(error);
|
|
37
|
+
cleanup();
|
|
38
|
+
}
|
|
39
|
+
function onClose() {
|
|
40
|
+
rejectWrite(new Error("Output stream closed during write"));
|
|
41
|
+
cleanup();
|
|
42
|
+
}
|
|
43
|
+
function onDrain() {
|
|
44
|
+
backpressureCleared = true;
|
|
45
|
+
finish();
|
|
46
|
+
}
|
|
47
|
+
function onAbort() {
|
|
48
|
+
rejectWrite(signal.reason);
|
|
49
|
+
output.off("drain", onDrain);
|
|
50
|
+
signal.removeEventListener("abort", onAbort);
|
|
51
|
+
// Closing the transport cannot cancel a caller-owned stream's OS write.
|
|
52
|
+
// Until its callback completes, retain error/close listeners to consume
|
|
53
|
+
// a late write failure rather than causing an unhandled stream error.
|
|
54
|
+
if (writeCallbackComplete)
|
|
55
|
+
cleanup();
|
|
56
|
+
}
|
|
57
|
+
output.once("error", onError);
|
|
58
|
+
output.once("close", onClose);
|
|
59
|
+
output.once("drain", onDrain);
|
|
60
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
61
|
+
try {
|
|
62
|
+
if (output.destroyed || output.writableEnded)
|
|
63
|
+
throw new Error("Output stream is not writable");
|
|
64
|
+
const accepted = output.write(line, "utf8", (error) => {
|
|
65
|
+
// Writable emits callback errors as events afterward. Keep onError
|
|
66
|
+
// attached until that event, including when shutdown already rejected.
|
|
67
|
+
if (error)
|
|
68
|
+
return;
|
|
69
|
+
writeCallbackComplete = true;
|
|
70
|
+
finish();
|
|
71
|
+
});
|
|
72
|
+
if (accepted) {
|
|
73
|
+
backpressureCleared = true;
|
|
74
|
+
finish();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
rejectWrite(error);
|
|
79
|
+
cleanup();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@toolgrain/node",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Node.js stdio and HTTP transports for Toolgrain MCP servers.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/keshavkatwe/toolgrain.git",
|
|
9
|
+
"directory": "packages/node"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/keshavkatwe/toolgrain/tree/main/packages/node#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/keshavkatwe/toolgrain/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"toolgrain",
|
|
17
|
+
"mcp",
|
|
18
|
+
"model-context-protocol",
|
|
19
|
+
"nodejs",
|
|
20
|
+
"stdio",
|
|
21
|
+
"http"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public",
|
|
25
|
+
"registry": "https://registry.npmjs.org/",
|
|
26
|
+
"tag": "latest"
|
|
27
|
+
},
|
|
28
|
+
"type": "module",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
40
|
+
"check-types": "tsc --noEmit",
|
|
41
|
+
"test": "vitest run",
|
|
42
|
+
"test:package": "node smoke/runtime.mjs && tsc -p smoke/tsconfig.json && toolgrain-repo exports",
|
|
43
|
+
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
|
|
44
|
+
"test:coverage": "vitest run --coverage",
|
|
45
|
+
"test:size": "toolgrain-repo size"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"typescript": "7.0.2",
|
|
49
|
+
"vitest": "5.0.4",
|
|
50
|
+
"@types/node": "24.10.0",
|
|
51
|
+
"zod": "4.6.7",
|
|
52
|
+
"@toolgrain/repo-tools": "0.0.0"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@toolgrain/mcp": "0.1.0",
|
|
56
|
+
"@toolgrain/protocol": "0.1.0",
|
|
57
|
+
"@toolgrain/http": "0.1.0"
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": "^24.20.0",
|
|
61
|
+
"bun": ">=1.4.4 <2"
|
|
62
|
+
}
|
|
63
|
+
}
|