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.
- package/LICENSE +21 -0
- package/README.md +326 -2
- package/dist/config.d.ts +17 -0
- package/dist/config.js +79 -0
- package/dist/core/errors.d.ts +126 -0
- package/dist/core/errors.js +214 -0
- package/dist/core/proxy-core.d.ts +104 -0
- package/dist/core/proxy-core.js +249 -0
- package/dist/core/session.d.ts +60 -0
- package/dist/core/session.js +89 -0
- package/dist/core/sse.d.ts +20 -0
- package/dist/core/sse.js +123 -0
- package/dist/core/tool-filter.d.ts +48 -0
- package/dist/core/tool-filter.js +213 -0
- package/dist/core/upstream.d.ts +58 -0
- package/dist/core/upstream.js +178 -0
- package/dist/http/adapter.d.ts +3 -0
- package/dist/http/adapter.js +294 -0
- package/dist/http/index.d.ts +22 -0
- package/dist/http/index.js +85 -0
- package/dist/http/origin.d.ts +14 -0
- package/dist/http/origin.js +27 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +80 -0
- package/dist/log.d.ts +10 -0
- package/dist/log.js +18 -0
- package/dist/shutdown.d.ts +2 -0
- package/dist/shutdown.js +2 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +6 -0
- package/package.json +75 -4
|
@@ -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,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
|
+
}
|
package/dist/index.d.ts
ADDED