tinyfish-mcp-lite 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.
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Single fetch call site for the upstream MCP endpoint.
3
+ *
4
+ * POSTs one JSON-RPC message per call with the auth + attribution headers and
5
+ * classifies the response by HTTP status and content type only — it never
6
+ * inspects methods, tool names, or result schemas. The upstream HTTP status is
7
+ * preserved on every variant so the transport adapter can pass it through
8
+ * (the hosted server answers JSON-RPC client errors as 400, server errors as
9
+ * 500, notifications as 204).
10
+ *
11
+ * Web-standard types only (fetch / Response / ReadableStream) — no node:http.
12
+ */
13
+ import { VERSION } from "../version.js";
14
+ import { isAbortError, UpstreamAbortedError, UpstreamAuthError, UpstreamProtocolError, UpstreamUnreachableError, } from "./errors.js";
15
+ export class UpstreamClient {
16
+ url;
17
+ apiKey;
18
+ clientVersion;
19
+ fetchFn;
20
+ constructor(options) {
21
+ this.url = options.url;
22
+ this.apiKey = options.apiKey;
23
+ this.clientVersion = options.clientVersion ?? VERSION;
24
+ this.fetchFn = options.fetchFn ?? globalThis.fetch;
25
+ }
26
+ /** POST one JSON-RPC message upstream and classify the response. */
27
+ async post(message, options = {}) {
28
+ const headers = {
29
+ "Content-Type": "application/json",
30
+ Accept: "application/json, text/event-stream",
31
+ "X-API-Key": this.apiKey,
32
+ "X-TF-Request-Origin": "tinyfish-mcp",
33
+ "X-TF-Client-Name": "tinyfish-mcp",
34
+ "X-TF-Client-Version": this.clientVersion,
35
+ };
36
+ if (options.sessionId !== undefined && options.sessionId !== "") {
37
+ headers["Mcp-Session-Id"] = options.sessionId;
38
+ }
39
+ if (options.protocolVersion !== undefined && options.protocolVersion !== "") {
40
+ headers["MCP-Protocol-Version"] = options.protocolVersion;
41
+ }
42
+ let response;
43
+ try {
44
+ response = await this.fetchFn(this.url, {
45
+ method: "POST",
46
+ headers,
47
+ body: JSON.stringify(message),
48
+ signal: options.signal,
49
+ });
50
+ }
51
+ catch (err) {
52
+ throw toTransportError(err, this.url);
53
+ }
54
+ const status = response.status;
55
+ const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
56
+ if (status === 204 || status === 205) {
57
+ return { kind: "empty", status };
58
+ }
59
+ if (contentType.startsWith("text/event-stream")) {
60
+ if (response.body === null) {
61
+ throw new UpstreamProtocolError("Upstream SSE response has no body", status);
62
+ }
63
+ return { kind: "sse", status, stream: response.body };
64
+ }
65
+ let text;
66
+ try {
67
+ text = await response.text();
68
+ }
69
+ catch (err) {
70
+ throw toTransportError(err, this.url);
71
+ }
72
+ if (text.length === 0) {
73
+ // A body-less 401/403 (e.g. a gateway/LB that strips bodies) is still an
74
+ // auth rejection — classify it BEFORE the generic empty return so the
75
+ // client gets the check-your-TINYFISH_API_KEY error, not a protocol one.
76
+ if (status === 401 || status === 403) {
77
+ throw new UpstreamAuthError(status, "");
78
+ }
79
+ return { kind: "empty", status };
80
+ }
81
+ let body;
82
+ try {
83
+ body = JSON.parse(text);
84
+ }
85
+ catch (err) {
86
+ // A 401/403 whose body is not JSON at all is an auth rejection
87
+ // from an intermediary or a non-MCP error page — classified so the
88
+ // adapter can shape the check-your-TINYFISH_API_KEY error. Any other
89
+ // status with a non-JSON body stays a protocol violation.
90
+ if (status === 401 || status === 403) {
91
+ throw new UpstreamAuthError(status, text, { cause: err });
92
+ }
93
+ throw new UpstreamProtocolError(`Upstream returned non-JSON body (HTTP ${status}, content-type "${contentType}")`, status, { cause: err });
94
+ }
95
+ // A 401/403 with a JSON body that is NOT a JSON-RPC message (e.g.
96
+ // {"error":"unauthorized"}) is also an auth rejection — only a genuine
97
+ // JSON-RPC error body forwards verbatim, preserving upstream's HTTP
98
+ // status.
99
+ if ((status === 401 || status === 403) && !isJsonRpcMessage(body)) {
100
+ throw new UpstreamAuthError(status, text);
101
+ }
102
+ return {
103
+ kind: "json",
104
+ status,
105
+ sessionId: response.headers.get("mcp-session-id"),
106
+ body,
107
+ contentType: response.headers.get("content-type"),
108
+ };
109
+ }
110
+ }
111
+ /**
112
+ * A parsed body that is a JSON-RPC 2.0 message (forwardable verbatim): the
113
+ * version marker plus at least one of the members every JSON-RPC message
114
+ * carries (`result`/`error` for responses, `method` for requests and
115
+ * notifications). Quasi-JSON-RPC junk like {"jsonrpc":"2.0","message":"no"}
116
+ * fails the gate, so at 401/403 it shapes as the -32001 auth error instead of
117
+ * forwarding.
118
+ *
119
+ * BATCH ARRAYS are deliberately outside this gate: MCP forbids JSON-RPC
120
+ * batching and upstream does not support it, so a 401/403 whose body is a
121
+ * batch(-error) array shapes as -32001 with the raw body preserved in
122
+ * `data.upstreamBody` rather than forwarding verbatim.
123
+ */
124
+ function isJsonRpcMessage(body) {
125
+ return (typeof body === "object" &&
126
+ body !== null &&
127
+ !Array.isArray(body) &&
128
+ body.jsonrpc === "2.0" &&
129
+ ("error" in body || "result" in body || "method" in body));
130
+ }
131
+ /**
132
+ * Map a fetch/stream failure to a typed transport error. Never includes the
133
+ * key. When `url` is given (the fetch call site knows it), the upstream host
134
+ * is attached so the error shaping can name it in the client-facing message.
135
+ */
136
+ export function toTransportError(err, url) {
137
+ if (isAbortError(err)) {
138
+ return new UpstreamAbortedError("Upstream request aborted", { cause: err });
139
+ }
140
+ // Undici wraps network failures as "TypeError: fetch failed" with the real
141
+ // error (ECONNREFUSED, ENOTFOUND, TLS) on err.cause — surface that detail.
142
+ let detail = err instanceof Error ? err.message : String(err);
143
+ const cause = causeDetail(err);
144
+ if (cause !== undefined) {
145
+ detail += ` (${cause})`;
146
+ }
147
+ const error = new UpstreamUnreachableError(`Upstream unreachable: ${detail}`, { cause: err });
148
+ if (url !== undefined) {
149
+ try {
150
+ error.host = new URL(url).host;
151
+ }
152
+ catch {
153
+ // Malformed URL: leave host unset; the shaping falls back to a generic name.
154
+ }
155
+ }
156
+ return error;
157
+ }
158
+ /** Extract the errno code (or message) from an error's cause chain, if any. */
159
+ function causeDetail(err) {
160
+ if (typeof err !== "object" || err === null || !("cause" in err))
161
+ return undefined;
162
+ const cause = err.cause;
163
+ if (typeof cause !== "object" || cause === null)
164
+ return undefined;
165
+ const code = cause.code;
166
+ if (typeof code === "string" && code.length > 0)
167
+ return code;
168
+ if (cause instanceof AggregateError) {
169
+ for (const inner of cause.errors) {
170
+ const innerCode = inner?.code;
171
+ if (typeof innerCode === "string" && innerCode.length > 0)
172
+ return innerCode;
173
+ }
174
+ }
175
+ if (cause instanceof Error && cause.message.length > 0)
176
+ return cause.message;
177
+ return undefined;
178
+ }
@@ -0,0 +1,3 @@
1
+ import { type ProxyCore } from "../core/proxy-core.js";
2
+ import type { RequestHandler } from "./index.js";
3
+ export declare function createMcpAdapter(core: ProxyCore): RequestHandler;
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Per-request MCP wiring over the transport-agnostic proxy core.
3
+ *
4
+ * Session bridging: the local client re-sends
5
+ * whatever Mcp-Session-Id upstream issued, so the client-sent header IS the
6
+ * core localKey. For `initialize` — where no upstream id exists yet — a
7
+ * locally generated key seeds the session entry, and the core aliases that
8
+ * entry under the upstream-issued id once the response arrives. `Mcp-Session-Id`
9
+ * is echoed on JSON responses exactly when upstream echoed it (the core hands
10
+ * back the echoed value; null on SSE — upstream's SSE path sets no session
11
+ * header, and neither do we).
12
+ *
13
+ * Local-hop auth is server-holds-key: inbound Authorization headers are
14
+ * ignored — the only client headers that influence the upstream call are
15
+ * Mcp-Session-Id and MCP-Protocol-Version; the core builds every outbound
16
+ * header itself.
17
+ *
18
+ * Streaming: when upstream answers SSE, frames are relayed through
19
+ * the core's onEvent into an SSE response using the ORIGINAL `data:` payload
20
+ * string (byte-verbatim, no re-serialization). Each write is awaited
21
+ * (backpressure), a local client disconnect aborts the upstream fetch via a
22
+ * per-request AbortSignal (no orphaned upstream streams), and mid-stream
23
+ * failures are classified: local write failures are never labeled upstream.
24
+ */
25
+ import { randomUUID } from "node:crypto";
26
+ import { JsonRpcErrorCodes, LocalWriteError, ProxyCoreError, toJsonRpcError, toStreamErrorFrame, } from "../core/errors.js";
27
+ import { requestIdOf } from "../core/proxy-core.js";
28
+ import { log } from "../log.js";
29
+ export function createMcpAdapter(core) {
30
+ return async (req, res) => {
31
+ const sessionId = headerValue(req, "mcp-session-id");
32
+ const protocolVersion = headerValue(req, "mcp-protocol-version");
33
+ const raw = await readBody(req);
34
+ let message;
35
+ try {
36
+ message = JSON.parse(raw.toString("utf8"));
37
+ }
38
+ catch {
39
+ // Local ParseError mirroring upstream's shape (client-error codes →
40
+ // HTTP 400; id -1 when no request id is known). The one case the proxy
41
+ // answers without forwarding — it cannot route what it cannot parse.
42
+ sendJson(res, 400, {
43
+ jsonrpc: "2.0",
44
+ error: { code: JsonRpcErrorCodes.ParseError, message: "Parse error: Invalid JSON" },
45
+ id: -1,
46
+ });
47
+ return;
48
+ }
49
+ try {
50
+ await route(core, res, message, sessionId, protocolVersion);
51
+ }
52
+ catch (err) {
53
+ // Every failure upstream never answered as JSON-RPC is shaped
54
+ // through the one function in core/errors.ts. Streamed requests handle
55
+ // their own mid-stream failures and only rethrow pre-stream ones, so
56
+ // headers are normally unsent here; the guard covers a write that died
57
+ // halfway through sending a response.
58
+ logFailure(err);
59
+ if (res.headersSent) {
60
+ res.end();
61
+ return;
62
+ }
63
+ const shaped = toJsonRpcError(err, requestIdOf(message));
64
+ sendJson(res, shaped.httpStatus, shaped.body);
65
+ }
66
+ };
67
+ }
68
+ async function route(core, res, message, sessionId, protocolVersion) {
69
+ // Notification (has method, no id): forward, answer 204 empty like upstream.
70
+ if (isNotification(message)) {
71
+ // localKey "" ⇒ the core sends no Mcp-Session-Id upstream (transparent
72
+ // for session-less notifications); a real client id replays verbatim.
73
+ await core.notify(sessionId ?? "", message, protocolVersion);
74
+ res.writeHead(204);
75
+ res.end();
76
+ return;
77
+ }
78
+ const method = methodOf(message);
79
+ if (method === "initialize") {
80
+ // No upstream session exists yet: a generated localKey seeds the entry
81
+ // unless the client is re-initializing with a session id it already
82
+ // holds. The client-sent id (if any) is passed separately so the core
83
+ // replays it upstream (upstream adopts client-sent header ids) without
84
+ // ever sending an adapter-invented key.
85
+ const localKey = sessionId ?? randomUUID();
86
+ const response = await core.initialize(localKey, message, protocolVersion, sessionId);
87
+ sendProxyResponse(res, response);
88
+ return;
89
+ }
90
+ if (method === "tools/call") {
91
+ await relayPossiblyStreaming(core, sessionId ?? "", message, protocolVersion, res);
92
+ return;
93
+ }
94
+ // Everything else — ping, tools/list, resources/*, unknown methods, and
95
+ // shapeless bodies — forwards generically; upstream's status and body
96
+ // (including MethodNotFound / InvalidRequest errors) pass through verbatim.
97
+ // JSON-RPC BATCH ARRAYS land here too (methodOf/isNotification treat an
98
+ // array as shapeless): MCP forbids batching and upstream does not support
99
+ // it, so a batch is not special-cased anywhere — it forwards as-is and
100
+ // upstream answers its own InvalidRequest. Same story on the response side:
101
+ // upstream.ts's isJsonRpcMessage gate excludes arrays by design.
102
+ const response = await core.forward(sessionId ?? "", message, protocolVersion);
103
+ sendProxyResponse(res, response);
104
+ }
105
+ /**
106
+ * Log a shaped failure to stderr. Classified core errors log their (key-free)
107
+ * message; anything else is a local proxy bug and logs its full stack — the
108
+ * client only ever sees the generic InternalError message.
109
+ */
110
+ function logFailure(err) {
111
+ if (err instanceof ProxyCoreError) {
112
+ log.warn(err.message);
113
+ return;
114
+ }
115
+ log.error(`proxy bug (client got a generic InternalError): ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
116
+ }
117
+ /**
118
+ * tools/call may answer JSON or SSE; the adapter cannot know which until the
119
+ * core either emits an event (⇒ SSE) or resolves. The SSE response is opened
120
+ * lazily on the first relayed frame; the resolved final frame is appended and
121
+ * the stream closed. Each write is awaited (OnEvent may return a promise), so
122
+ * socket backpressure propagates into the core's frame loop.
123
+ *
124
+ * Client-disconnect abort: if the local socket closes before the response is
125
+ * finished ('close' with writableEnded false — 'close' alone also fires on
126
+ * normal completion), the per-request AbortSignal fires and the core aborts
127
+ * the upstream fetch. That surfaces as an UpstreamAbortedError rejection,
128
+ * absorbed here as an ordinary disconnect (nobody is left to answer).
129
+ *
130
+ * Note: an SSE stream that carried ONLY the final frame still relays as a
131
+ * plain JSON response (streaming never turned true). Real upstream always
132
+ * emits progress first; if it ever mattered, the final frame's rawBody is the
133
+ * verbatim payload either way.
134
+ */
135
+ async function relayPossiblyStreaming(core, localKey, message, protocolVersion, res) {
136
+ let streaming = false;
137
+ // Last _meta.runId seen in a relayed progress frame — upstream names the
138
+ // run there so a mid-stream failure can hand the client a recovery handle.
139
+ let lastRunId;
140
+ const clientAbort = new AbortController();
141
+ const onClose = () => {
142
+ if (!res.writableEnded)
143
+ clientAbort.abort();
144
+ };
145
+ res.on("close", onClose);
146
+ const onEvent = async (event, rawData) => {
147
+ if (!streaming) {
148
+ streaming = true;
149
+ // Mirror upstream's SSE headers. Deliberately no Mcp-Session-Id:
150
+ // upstream's SSE path never sets one.
151
+ res.writeHead(200, {
152
+ "Content-Type": "text/event-stream",
153
+ "Cache-Control": "no-cache, no-transform",
154
+ Connection: "keep-alive",
155
+ });
156
+ }
157
+ lastRunId = runIdOf(event) ?? lastRunId;
158
+ await writeSseFrame(res, event, rawData);
159
+ };
160
+ try {
161
+ const response = await core.forwardStream(localKey, message, onEvent, protocolVersion, clientAbort.signal);
162
+ if (streaming) {
163
+ try {
164
+ await writeSseFrame(res, response.body, response.rawBody);
165
+ }
166
+ catch (err) {
167
+ // A final-frame write failure is a LOCAL socket condition, exactly
168
+ // like a progress-frame write failure inside onEvent — classify it the
169
+ // same way so it can never be mislabeled as an upstream stream failure.
170
+ throw new LocalWriteError(`Relaying the final SSE frame to the local client failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
171
+ }
172
+ res.end();
173
+ return;
174
+ }
175
+ // Plain JSON answer: written with upstream's status, body verbatim.
176
+ sendProxyResponse(res, response);
177
+ }
178
+ catch (err) {
179
+ if (clientAbort.signal.aborted) {
180
+ // The local client went away; the upstream fetch was aborted through the
181
+ // per-request signal. Not an upstream failure — log it as what it is.
182
+ log.warn("local client disconnected mid-tools/call; upstream request aborted");
183
+ res.destroy();
184
+ return;
185
+ }
186
+ if (err instanceof LocalWriteError) {
187
+ // Writing to the local client socket failed (client dying but 'close'
188
+ // not yet observed). Teardown already happened in the core (the throw
189
+ // exits the frame loop, canceling the upstream stream). Never labeled
190
+ // "Upstream unreachable".
191
+ log.warn(err.message);
192
+ res.destroy();
193
+ return;
194
+ }
195
+ if (streaming) {
196
+ // The stream broke after the local SSE response already started. Emit
197
+ // the final SSE-framed JSON-RPC error (-32000, "the run may still be
198
+ // executing", runId in data when seen) — never an unframed body into a
199
+ // started SSE stream. The log prefix names the actual culprit: a
200
+ // classified core error is an upstream-leg failure; anything else is a
201
+ // LOCAL proxy bug and must not be logged with an upstream-blaming label.
202
+ if (err instanceof ProxyCoreError) {
203
+ log.error(`upstream stream failed mid-relay: ${err.message}`);
204
+ }
205
+ else {
206
+ log.error(`proxy bug mid-stream (client got a framed InternalError): ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
207
+ }
208
+ const errorFrame = toStreamErrorFrame(err, requestIdOf(message), lastRunId);
209
+ await writeSseFrame(res, errorFrame).catch(() => undefined);
210
+ res.end();
211
+ return;
212
+ }
213
+ // Pre-stream failures (headers not sent) rethrow to the shaping catch in
214
+ // the handler (toJsonRpcError → -32000/-32001/-32603 with an HTTP status).
215
+ throw err;
216
+ }
217
+ finally {
218
+ res.removeListener("close", onClose);
219
+ }
220
+ }
221
+ /** Extract `params._meta.runId` from a relayed progress notification, if present. */
222
+ function runIdOf(event) {
223
+ if (typeof event !== "object" || event === null)
224
+ return undefined;
225
+ const params = event.params;
226
+ if (typeof params !== "object" || params === null)
227
+ return undefined;
228
+ const meta = params._meta;
229
+ if (typeof meta !== "object" || meta === null)
230
+ return undefined;
231
+ const runId = meta.runId;
232
+ return typeof runId === "string" && runId.length > 0 ? runId : undefined;
233
+ }
234
+ /** Write upstream's status + JSON-RPC body verbatim; echo the session header. */
235
+ function sendProxyResponse(res, response) {
236
+ // Copy upstream's Content-Type through; fall back for
237
+ // locally synthesized responses (e.g. an SSE final frame answered as JSON).
238
+ const headers = {
239
+ "Content-Type": response.contentType ?? "application/json",
240
+ };
241
+ if (response.sessionId !== null) {
242
+ headers["Mcp-Session-Id"] = response.sessionId;
243
+ }
244
+ sendJson(res, response.status, response.body, headers);
245
+ }
246
+ function sendJson(res, status, body, headers = { "Content-Type": "application/json" }) {
247
+ res.writeHead(status, headers);
248
+ res.end(JSON.stringify(body));
249
+ }
250
+ /**
251
+ * One SSE frame; resolves when the chunk is flushed. Relays the ORIGINAL
252
+ * upstream payload string when available (byte-verbatim);
253
+ * falls back to JSON.stringify for locally synthesized frames. A payload
254
+ * containing newlines (multi-line `data:` field) is re-split into one
255
+ * `data:` line per payload line, which reconstructs to identical bytes on the
256
+ * receiving parser.
257
+ */
258
+ function writeSseFrame(res, message, rawData) {
259
+ const payload = rawData ?? JSON.stringify(message);
260
+ const frame = payload
261
+ .split("\n")
262
+ .map((line) => `data: ${line}`)
263
+ .join("\n");
264
+ return new Promise((resolve, reject) => {
265
+ res.write(`${frame}\n\n`, (err) => (err ? reject(err) : resolve()));
266
+ });
267
+ }
268
+ function readBody(req) {
269
+ return new Promise((resolve, reject) => {
270
+ const chunks = [];
271
+ req.on("data", (chunk) => chunks.push(chunk));
272
+ req.on("end", () => resolve(Buffer.concat(chunks)));
273
+ req.on("error", reject);
274
+ });
275
+ }
276
+ /** Node folds duplicate headers into one comma-joined string; empty ⇒ absent. */
277
+ function headerValue(req, name) {
278
+ const value = req.headers[name];
279
+ return typeof value === "string" && value.length > 0 ? value : undefined;
280
+ }
281
+ /** JSON-RPC notification: an object with a method and no id key (upstream's rule). */
282
+ function isNotification(message) {
283
+ return (typeof message === "object" &&
284
+ message !== null &&
285
+ !Array.isArray(message) &&
286
+ typeof message.method === "string" &&
287
+ !("id" in message));
288
+ }
289
+ function methodOf(message) {
290
+ if (typeof message !== "object" || message === null)
291
+ return undefined;
292
+ const method = message.method;
293
+ return typeof method === "string" ? method : undefined;
294
+ }
@@ -0,0 +1,22 @@
1
+ import type { IncomingMessage, Server, ServerResponse } from "node:http";
2
+ /**
3
+ * Request handler contract for the HTTP layer. Handlers may be async: the
4
+ * server awaits the returned promise and converts a rejection into a 500
5
+ * JSON-RPC InternalError response (or just ends the response when headers are
6
+ * already out, e.g. mid-SSE), so async failures are never silently swallowed.
7
+ */
8
+ export type RequestHandler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
9
+ /**
10
+ * Routing shell around the MCP adapter:
11
+ * - Origin allowlist first, before the body is touched: deny → 403 plain text.
12
+ * - GET /healthz → 200 "ok" (client debugging; kept trivial).
13
+ * - POST /mcp → the MCP handler; other methods on /mcp → 405 with Allow: POST
14
+ * (mirrors upstream, where Next.js 405s methods the route does not export).
15
+ * - Everything else → 404.
16
+ */
17
+ export declare function createAppHandler(mcpHandler: RequestHandler): RequestHandler;
18
+ /**
19
+ * Starts an HTTP server bound to 127.0.0.1 (loopback only, never configurable).
20
+ * Resolves once listening; rejects with the bind error (e.g. EADDRINUSE) otherwise.
21
+ */
22
+ export declare function startHttpServer(port: number, handler?: RequestHandler): Promise<Server>;
@@ -0,0 +1,85 @@
1
+ import { createServer } from "node:http";
2
+ import { log } from "../log.js";
3
+ import { checkOrigin } from "./origin.js";
4
+ /** JSON-RPC InternalError, used for unhandled handler failures. */
5
+ const INTERNAL_ERROR = -32603;
6
+ const notFoundHandler = (_req, res) => {
7
+ res.writeHead(404, { "Content-Type": "text/plain" });
8
+ res.end("Not Found\n");
9
+ };
10
+ /**
11
+ * Routing shell around the MCP adapter:
12
+ * - Origin allowlist first, before the body is touched: deny → 403 plain text.
13
+ * - GET /healthz → 200 "ok" (client debugging; kept trivial).
14
+ * - POST /mcp → the MCP handler; other methods on /mcp → 405 with Allow: POST
15
+ * (mirrors upstream, where Next.js 405s methods the route does not export).
16
+ * - Everything else → 404.
17
+ */
18
+ export function createAppHandler(mcpHandler) {
19
+ return async (req, res) => {
20
+ if (!checkOrigin(req.headers.origin)) {
21
+ res.writeHead(403, { "Content-Type": "text/plain" });
22
+ res.end("Forbidden: Origin not allowed\n");
23
+ return;
24
+ }
25
+ const pathname = new URL(req.url ?? "/", "http://127.0.0.1").pathname;
26
+ if (pathname === "/mcp") {
27
+ if (req.method !== "POST") {
28
+ res.writeHead(405, { Allow: "POST" });
29
+ res.end();
30
+ return;
31
+ }
32
+ await mcpHandler(req, res);
33
+ return;
34
+ }
35
+ if (pathname === "/healthz" && req.method === "GET") {
36
+ res.writeHead(200, { "Content-Type": "text/plain" });
37
+ res.end("ok");
38
+ return;
39
+ }
40
+ notFoundHandler(req, res);
41
+ };
42
+ }
43
+ /**
44
+ * Starts an HTTP server bound to 127.0.0.1 (loopback only, never configurable).
45
+ * Resolves once listening; rejects with the bind error (e.g. EADDRINUSE) otherwise.
46
+ */
47
+ export function startHttpServer(port, handler = notFoundHandler) {
48
+ return new Promise((resolve, reject) => {
49
+ const server = createServer((req, res) => {
50
+ void invokeSafely(handler, req, res);
51
+ });
52
+ server.once("error", reject);
53
+ server.listen(port, "127.0.0.1", () => {
54
+ server.removeListener("error", reject);
55
+ resolve(server);
56
+ });
57
+ });
58
+ }
59
+ /**
60
+ * Await the handler; turn sync throws and async rejections into a 500. The
61
+ * MCP adapter shapes every classified failure itself, so anything that
62
+ * reaches this catch is a local proxy bug: full stack to stderr, generic
63
+ * -32603 InternalError to the client.
64
+ */
65
+ async function invokeSafely(handler, req, res) {
66
+ try {
67
+ await handler(req, res);
68
+ }
69
+ catch (err) {
70
+ // Core error messages never contain the API key, and the client-facing
71
+ // body is generic regardless — the key cannot leak.
72
+ log.error(`request failed: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
73
+ if (!res.headersSent) {
74
+ res.writeHead(500, { "Content-Type": "application/json" });
75
+ res.end(JSON.stringify({
76
+ jsonrpc: "2.0",
77
+ error: { code: INTERNAL_ERROR, message: "Internal error" },
78
+ id: null,
79
+ }));
80
+ }
81
+ else {
82
+ res.end();
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Pure Origin-header allowlist check — the anti-DNS-rebinding / anti-CSRF
3
+ * control for the loopback server. Evaluated before the request body is read;
4
+ * on deny the caller answers 403 without touching the body.
5
+ *
6
+ * Policy:
7
+ * - Absent Origin → allow (non-browser clients: curl, MCP SDKs, inspectors).
8
+ * - http/https origins on `127.0.0.1` or `localhost` → allow, any port
9
+ * (any port variant of loopback is fine, so no `port` parameter exists).
10
+ * - Everything else → deny, including the literal "null" Origin (sandboxed
11
+ * iframes, file://), IPv6 `[::1]` (the server binds IPv4 loopback only),
12
+ * and anything that does not parse as a URL.
13
+ */
14
+ export declare function checkOrigin(originHeader: string | undefined): boolean;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Pure Origin-header allowlist check — the anti-DNS-rebinding / anti-CSRF
3
+ * control for the loopback server. Evaluated before the request body is read;
4
+ * on deny the caller answers 403 without touching the body.
5
+ *
6
+ * Policy:
7
+ * - Absent Origin → allow (non-browser clients: curl, MCP SDKs, inspectors).
8
+ * - http/https origins on `127.0.0.1` or `localhost` → allow, any port
9
+ * (any port variant of loopback is fine, so no `port` parameter exists).
10
+ * - Everything else → deny, including the literal "null" Origin (sandboxed
11
+ * iframes, file://), IPv6 `[::1]` (the server binds IPv4 loopback only),
12
+ * and anything that does not parse as a URL.
13
+ */
14
+ export function checkOrigin(originHeader) {
15
+ if (originHeader === undefined)
16
+ return true;
17
+ let origin;
18
+ try {
19
+ origin = new URL(originHeader);
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ if (origin.protocol !== "http:" && origin.protocol !== "https:")
25
+ return false;
26
+ return origin.hostname === "127.0.0.1" || origin.hostname === "localhost";
27
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};