@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 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
- # Temporary Holding Version
1
+ # @toolgrain/node
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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 {};
@@ -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,3 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ /** Cancelling body consumption must not destroy the socket before an error response is sent. */
3
+ export declare function requestBody(input: IncomingMessage): ReadableStream<Uint8Array>;
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ export declare class InvalidRequestUrlError extends Error {
3
+ }
4
+ /** Proxy headers require normalization by trusted application middleware. */
5
+ export declare function requestUrl(input: IncomingMessage): string;
@@ -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
+ }
@@ -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
@@ -0,0 +1,3 @@
1
+ export { createNodeHttpHandler, createNodeProtectedResourceMetadataHandler, } from "./http.js";
2
+ export { http } from "./http-listener.js";
3
+ export { stdio } from "./stdio.js";
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ import type { Writable } from "node:stream";
2
+ /** Resolves after both the write callback and any required drain event. */
3
+ export declare function writeLine(output: Writable, line: string, signal: AbortSignal): Promise<void>;
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }