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,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed transport-level errors thrown by the proxy core, plus the only
|
|
3
|
+
* client-facing shaping functions: `toJsonRpcError` for pre-stream failures
|
|
4
|
+
* and `toStreamErrorFrame` for failures after an SSE relay started. Every
|
|
5
|
+
* adapter catch path routes through these two functions — no ad-hoc error
|
|
6
|
+
* bodies anywhere else, with exactly two deliberate exceptions: the adapter's
|
|
7
|
+
* ParseError reply (http/adapter.ts — built where the unparseable body is
|
|
8
|
+
* caught, since there is nothing to route), and the last-resort -32603
|
|
9
|
+
* backstop in http/index.ts's invokeSafely. Messages must never contain the
|
|
10
|
+
* API key (they describe network/protocol conditions only).
|
|
11
|
+
*
|
|
12
|
+
* HTTP status decision for locally shaped errors: the hosted server maps
|
|
13
|
+
* client-error JSON-RPC codes to HTTP 400 and everything else to 500. The
|
|
14
|
+
* proxy mirrors the 400 for client errors (-32700 ParseError) and picks
|
|
15
|
+
* **502 Bad Gateway** for the upstream-leg failures it shapes itself (-32000
|
|
16
|
+
* unreachable/stream-failed, -32001 auth rejection): the proxy is healthy,
|
|
17
|
+
* the upstream hop failed — distinguishing these from a genuine local proxy
|
|
18
|
+
* bug, which stays **500** with -32603 InternalError. Upstream-originated
|
|
19
|
+
* JSON-RPC errors are never shaped at all: they forward verbatim under
|
|
20
|
+
* upstream's own HTTP status.
|
|
21
|
+
*/
|
|
22
|
+
/** JSON-RPC error codes used by locally shaped errors. */
|
|
23
|
+
export const JsonRpcErrorCodes = {
|
|
24
|
+
/** Upstream unreachable / upstream stream failed (server-side, HTTP 502). */
|
|
25
|
+
UpstreamUnavailable: -32000,
|
|
26
|
+
/** Upstream rejected auth — check TINYFISH_API_KEY (HTTP 502). */
|
|
27
|
+
UpstreamAuth: -32001,
|
|
28
|
+
/** Local proxy bug (HTTP 500). */
|
|
29
|
+
InternalError: -32603,
|
|
30
|
+
/** Malformed client JSON (HTTP 400, id -1 — mirrors upstream). */
|
|
31
|
+
ParseError: -32700,
|
|
32
|
+
};
|
|
33
|
+
/** Base class for all proxy-core errors (transport level, not JSON-RPC). */
|
|
34
|
+
export class ProxyCoreError extends Error {
|
|
35
|
+
constructor(message, options) {
|
|
36
|
+
super(message, options);
|
|
37
|
+
this.name = new.target.name;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** The upstream server could not be reached (DNS, TLS, refused, reset). */
|
|
41
|
+
export class UpstreamUnreachableError extends ProxyCoreError {
|
|
42
|
+
/** Upstream "host[:port]" when known — used in the client-facing message. */
|
|
43
|
+
host;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Upstream answered 401/403 with a body that is NOT a JSON-RPC message (a
|
|
47
|
+
* JSON-RPC error body, whatever its HTTP status, forwards verbatim instead).
|
|
48
|
+
* Carries the upstream status and body text so the shaped
|
|
49
|
+
* client error can include them as diagnostics. The body text is truncated to
|
|
50
|
+
* ~2KB at construction; the Error message itself never includes it.
|
|
51
|
+
*/
|
|
52
|
+
export class UpstreamAuthError extends ProxyCoreError {
|
|
53
|
+
status;
|
|
54
|
+
/** Upstream response body text, truncated to AUTH_BODY_LIMIT chars. */
|
|
55
|
+
bodyText;
|
|
56
|
+
constructor(status, bodyText, options) {
|
|
57
|
+
super(`Upstream rejected the request as unauthorized (HTTP ${status})`, options);
|
|
58
|
+
this.status = status;
|
|
59
|
+
this.bodyText = bodyText.slice(0, AUTH_BODY_LIMIT);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* ~2KB cap on the upstream auth-failure body relayed in error data.
|
|
64
|
+
*
|
|
65
|
+
* Measured in UTF-16 code units (String.prototype.slice), not bytes: for
|
|
66
|
+
* multi-byte scripts the UTF-8 wire size can reach ~3× (≤ ~6KB) — bounded
|
|
67
|
+
* either way, which is all the "~2KB" contract promises. A slice boundary can
|
|
68
|
+
* split a surrogate pair; harmless, since Node's well-formed JSON.stringify
|
|
69
|
+
* escapes the lone surrogate and the response stays valid JSON.
|
|
70
|
+
*/
|
|
71
|
+
export const AUTH_BODY_LIMIT = 2048;
|
|
72
|
+
/**
|
|
73
|
+
* Delivering a relayed SSE frame to the LOCAL client failed (the transport's
|
|
74
|
+
* onEvent callback rejected — e.g. the client socket died mid-write). This is
|
|
75
|
+
* a client-side condition, never an upstream one: it must not be logged or
|
|
76
|
+
* classified as "Upstream unreachable".
|
|
77
|
+
*/
|
|
78
|
+
export class LocalWriteError extends ProxyCoreError {
|
|
79
|
+
}
|
|
80
|
+
/** An in-flight upstream request was aborted locally (session close / shutdown). */
|
|
81
|
+
export class UpstreamAbortedError extends ProxyCoreError {
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Upstream answered with something the core cannot interpret (non-JSON body,
|
|
85
|
+
* SSE stream that ends without a final response frame, unexpected empty body).
|
|
86
|
+
*/
|
|
87
|
+
export class UpstreamProtocolError extends ProxyCoreError {
|
|
88
|
+
/** Upstream HTTP status when one was received before the failure. */
|
|
89
|
+
status;
|
|
90
|
+
constructor(message, status, options) {
|
|
91
|
+
super(message, options);
|
|
92
|
+
this.status = status;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** True for the AbortError DOMException fetch throws when its signal fires. */
|
|
96
|
+
export function isAbortError(err) {
|
|
97
|
+
return (typeof err === "object" &&
|
|
98
|
+
err !== null &&
|
|
99
|
+
"name" in err &&
|
|
100
|
+
err.name === "AbortError");
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Map a failure the upstream never answered (or answered unusably) to the
|
|
104
|
+
* client-facing JSON-RPC error + local HTTP status.
|
|
105
|
+
* Only failures upstream never saw as JSON-RPC get shaped here —
|
|
106
|
+
* upstream JSON-RPC errors forward verbatim and never reach this function.
|
|
107
|
+
* Never includes the API key: transport-error messages describe network and
|
|
108
|
+
* protocol conditions only, and unexpected local errors get a generic message
|
|
109
|
+
* (their stack goes to stderr at the catch site, not to the client).
|
|
110
|
+
*/
|
|
111
|
+
export function toJsonRpcError(failure, requestId) {
|
|
112
|
+
const id = requestId ?? null;
|
|
113
|
+
if (failure instanceof UpstreamAuthError) {
|
|
114
|
+
return {
|
|
115
|
+
httpStatus: 502,
|
|
116
|
+
body: {
|
|
117
|
+
jsonrpc: "2.0",
|
|
118
|
+
error: {
|
|
119
|
+
code: JsonRpcErrorCodes.UpstreamAuth,
|
|
120
|
+
message: `Upstream rejected the request (HTTP ${failure.status}) — ` +
|
|
121
|
+
`check that TINYFISH_API_KEY is set to a valid TinyFish API key`,
|
|
122
|
+
data: { upstreamStatus: failure.status, upstreamBody: failure.bodyText },
|
|
123
|
+
},
|
|
124
|
+
id,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (failure instanceof UpstreamUnreachableError) {
|
|
129
|
+
const host = failure.host ?? "the upstream server";
|
|
130
|
+
return {
|
|
131
|
+
httpStatus: 502,
|
|
132
|
+
body: {
|
|
133
|
+
jsonrpc: "2.0",
|
|
134
|
+
error: {
|
|
135
|
+
code: JsonRpcErrorCodes.UpstreamUnavailable,
|
|
136
|
+
message: `cannot reach ${host} — check your network; ` +
|
|
137
|
+
`the hosted MCP server may also be temporarily unavailable`,
|
|
138
|
+
},
|
|
139
|
+
id,
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (failure instanceof ProxyCoreError) {
|
|
144
|
+
// Remaining core classifications (protocol violation, local abort): the
|
|
145
|
+
// upstream leg failed but the proxy is healthy — same 502 / -32000 shape,
|
|
146
|
+
// with the classified message (never contains the key or body internals).
|
|
147
|
+
return {
|
|
148
|
+
httpStatus: 502,
|
|
149
|
+
body: {
|
|
150
|
+
jsonrpc: "2.0",
|
|
151
|
+
error: { code: JsonRpcErrorCodes.UpstreamUnavailable, message: failure.message },
|
|
152
|
+
id,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
// Local proxy bug: generic message only; the stack goes to stderr.
|
|
157
|
+
return {
|
|
158
|
+
httpStatus: 500,
|
|
159
|
+
body: {
|
|
160
|
+
jsonrpc: "2.0",
|
|
161
|
+
error: { code: JsonRpcErrorCodes.InternalError, message: "Internal error" },
|
|
162
|
+
id,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Build the final SSE-framed JSON-RPC error for a failure AFTER the local SSE
|
|
168
|
+
* relay started (mid-stream upstream disconnect). A
|
|
169
|
+
* tools/call may have side effects, so the message warns that the run may
|
|
170
|
+
* still be executing and is never retried silently; when a run id was already
|
|
171
|
+
* seen in a progress frame's `_meta.runId` it is included in `data.runId`
|
|
172
|
+
* (camelCase, matching upstream's `_meta.runId` convention) and named in the
|
|
173
|
+
* message, else `data` is omitted entirely.
|
|
174
|
+
*
|
|
175
|
+
* The -32000 message differentiates the failure kind: a locally aborted
|
|
176
|
+
* upstream request (session close / shutdown mid-stream) reads differently
|
|
177
|
+
* from an upstream that died or broke protocol — but both keep the "run may
|
|
178
|
+
* still be executing" guidance, because in either case a live run could be
|
|
179
|
+
* left behind upstream.
|
|
180
|
+
*/
|
|
181
|
+
export function toStreamErrorFrame(failure, requestId, runId) {
|
|
182
|
+
const id = requestId ?? null;
|
|
183
|
+
if (failure instanceof ProxyCoreError) {
|
|
184
|
+
// Upstream died / broke protocol / was aborted mid-stream. Not -32603:
|
|
185
|
+
// this is an upstream-leg failure, mirrored to the pre-stream -32000.
|
|
186
|
+
const runHint = runId !== undefined
|
|
187
|
+
? ` Check the run status with get_run id "${runId}" instead of retrying.`
|
|
188
|
+
: ` Check the run status before retrying.`;
|
|
189
|
+
const condition = failure instanceof UpstreamAbortedError
|
|
190
|
+
? "The proxy aborted the upstream request mid-stream (local session closed or shutting down); "
|
|
191
|
+
: "Upstream stream ended unexpectedly before the final response; ";
|
|
192
|
+
return {
|
|
193
|
+
jsonrpc: "2.0",
|
|
194
|
+
error: {
|
|
195
|
+
code: JsonRpcErrorCodes.UpstreamUnavailable,
|
|
196
|
+
message: condition + "the run may still be executing — do not retry blindly." + runHint,
|
|
197
|
+
...(runId !== undefined ? { data: { runId } } : {}),
|
|
198
|
+
},
|
|
199
|
+
id,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
// Local proxy bug mid-stream: generic message (stack to stderr at the catch
|
|
203
|
+
// site); still include the run id when known — it is upstream-issued data
|
|
204
|
+
// the client already saw, and it is the only recovery handle left.
|
|
205
|
+
return {
|
|
206
|
+
jsonrpc: "2.0",
|
|
207
|
+
error: {
|
|
208
|
+
code: JsonRpcErrorCodes.InternalError,
|
|
209
|
+
message: "Internal error while relaying the upstream stream",
|
|
210
|
+
...(runId !== undefined ? { data: { runId } } : {}),
|
|
211
|
+
},
|
|
212
|
+
id,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { type FetchLike } from "./upstream.js";
|
|
2
|
+
/**
|
|
3
|
+
* Plain callback invoked per intermediate SSE frame (progress notifications).
|
|
4
|
+
* May return a promise; forwardStream awaits each emission before reading the
|
|
5
|
+
* next frame, so a relaying transport can propagate write backpressure.
|
|
6
|
+
* `rawData` is the frame's original `data:` payload string
|
|
7
|
+
* (multi-line values joined with "\n") — relay it verbatim when possible; the
|
|
8
|
+
* parsed `message` is a fallback for transports that must re-serialize. A
|
|
9
|
+
* rejection from onEvent surfaces as LocalWriteError (client-side condition),
|
|
10
|
+
* never as an upstream transport error.
|
|
11
|
+
*
|
|
12
|
+
* DELIBERATE DROP: SSE `event:` and
|
|
13
|
+
* `id:` fields are parsed by core/sse.ts but NOT carried through this
|
|
14
|
+
* callback — only the `data:` payload is relayed. The verified upstream sends
|
|
15
|
+
* bare `data:` frames exclusively, so threading them through would be dead
|
|
16
|
+
* plumbing today; if upstream ever starts emitting these fields, extend
|
|
17
|
+
* OnEvent (SseEvent already surfaces them) and the adapter's writeSseFrame.
|
|
18
|
+
*/
|
|
19
|
+
export type OnEvent = (message: unknown, rawData?: string) => void | Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* A completed upstream exchange. `body` is the raw JSON-RPC response object,
|
|
22
|
+
* verbatim — success or error, never unwrapped. `status` is upstream's HTTP
|
|
23
|
+
* status (it must survive to the client). `sessionId` is the
|
|
24
|
+
* Mcp-Session-Id upstream echoed on JSON responses (null on SSE — upstream's
|
|
25
|
+
* SSE path sets no session header).
|
|
26
|
+
*/
|
|
27
|
+
export interface ProxyResponse {
|
|
28
|
+
status: number;
|
|
29
|
+
body: unknown;
|
|
30
|
+
sessionId: string | null;
|
|
31
|
+
/**
|
|
32
|
+
* Upstream's Content-Type header for JSON responses (adapter copies it
|
|
33
|
+
* through rather than hardcoding its own). Null when synthesized locally
|
|
34
|
+
* (e.g. the final frame of an SSE stream).
|
|
35
|
+
*/
|
|
36
|
+
contentType: string | null;
|
|
37
|
+
/**
|
|
38
|
+
* SSE path only: the final frame's original `data:` payload string, so a
|
|
39
|
+
* relaying transport can emit upstream's bytes verbatim. Absent on plain
|
|
40
|
+
* JSON responses.
|
|
41
|
+
*/
|
|
42
|
+
rawBody?: string;
|
|
43
|
+
}
|
|
44
|
+
export interface ProxyCore {
|
|
45
|
+
/**
|
|
46
|
+
* POST the client's initialize request upstream verbatim; capture
|
|
47
|
+
* Mcp-Session-Id from the response headers into the session map; return
|
|
48
|
+
* upstream's raw JSON-RPC response (upstream always answers
|
|
49
|
+
* protocolVersion 2025-11-25).
|
|
50
|
+
*/
|
|
51
|
+
initialize(localKey: string, initializeRequest: unknown, clientProtocolVersion?: string,
|
|
52
|
+
/**
|
|
53
|
+
* Mcp-Session-Id the CLIENT sent on this initialize, if any. The hosted
|
|
54
|
+
* server adopts a client-sent header id instead of minting one, so a
|
|
55
|
+
* re-initialize after a proxy restart must replay it. Never an
|
|
56
|
+
* adapter-invented key.
|
|
57
|
+
*/
|
|
58
|
+
clientSessionId?: string): Promise<ProxyResponse>;
|
|
59
|
+
/**
|
|
60
|
+
* Forward a JSON-RPC notification; upstream answers 204; resolves void.
|
|
61
|
+
* `clientProtocolVersion` is the MCP-Protocol-Version the client sent on
|
|
62
|
+
* THIS call (headers are per-request on the wire); falls back to the value
|
|
63
|
+
* captured at initialize when omitted.
|
|
64
|
+
*/
|
|
65
|
+
notify(localKey: string, notification: unknown, clientProtocolVersion?: string): Promise<void>;
|
|
66
|
+
/** Non-streaming forward with the stored session id; response verbatim. */
|
|
67
|
+
forward(localKey: string, request: unknown, clientProtocolVersion?: string): Promise<ProxyResponse>;
|
|
68
|
+
/**
|
|
69
|
+
* Forward a request that may stream. If upstream answers text/event-stream,
|
|
70
|
+
* onEvent is called per SSE frame that is a notification and the promise
|
|
71
|
+
* resolves with the frame that is the final JSON-RPC response (matching the
|
|
72
|
+
* request id). If upstream answers plain JSON, resolves with it directly.
|
|
73
|
+
*
|
|
74
|
+
* `signal` (optional) additionally aborts THIS request's upstream fetch —
|
|
75
|
+
* the transport fires it on local client disconnect so no upstream stream
|
|
76
|
+
* is orphaned. Distinct from close(), which tears down the whole session.
|
|
77
|
+
*/
|
|
78
|
+
forwardStream(localKey: string, request: unknown, onEvent: OnEvent, clientProtocolVersion?: string, signal?: AbortSignal): Promise<ProxyResponse>;
|
|
79
|
+
/** Abort in-flight upstream fetches for the session, drop the mapping. Local-only. */
|
|
80
|
+
close(localKey: string): void;
|
|
81
|
+
/** Close every session (shutdown). */
|
|
82
|
+
closeAll(): void;
|
|
83
|
+
}
|
|
84
|
+
export interface ProxyCoreOptions {
|
|
85
|
+
upstreamUrl: string;
|
|
86
|
+
/** Never logged. */
|
|
87
|
+
apiKey: string;
|
|
88
|
+
/** X-TF-Client-Version override; defaults to the package version. */
|
|
89
|
+
clientVersion?: string;
|
|
90
|
+
/** Injectable fetch so unit tests need no network. */
|
|
91
|
+
fetchFn?: FetchLike;
|
|
92
|
+
/**
|
|
93
|
+
* Where to register session cleanup for process shutdown. Defaults to the
|
|
94
|
+
* process-wide shutdownHooks array; pass null to skip registration (tests).
|
|
95
|
+
*/
|
|
96
|
+
hooks?: Array<() => void | Promise<void>> | null;
|
|
97
|
+
}
|
|
98
|
+
export declare function createProxyCore(options: ProxyCoreOptions): ProxyCore;
|
|
99
|
+
/**
|
|
100
|
+
* The JSON-RPC id of a request message, undefined when absent. Single copy —
|
|
101
|
+
* the HTTP adapter imports this too; the shaping functions in core/errors.ts
|
|
102
|
+
* normalize undefined to null themselves.
|
|
103
|
+
*/
|
|
104
|
+
export declare function requestIdOf(request: unknown): unknown;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport-agnostic proxy core — the one module every transport wraps.
|
|
3
|
+
*
|
|
4
|
+
* Composes the upstream client (single fetch call site) with the session
|
|
5
|
+
* store (abort tracking + session/protocol-version bridging). Routes on
|
|
6
|
+
* JSON-RPC shape and response content type only; never inspects tool names or
|
|
7
|
+
* schemas. All signatures use web-standard types and plain callbacks — no
|
|
8
|
+
* node:http anywhere.
|
|
9
|
+
*
|
|
10
|
+
* SSE frames are parsed by the incremental parser in core/sse.ts and carry
|
|
11
|
+
* both the parsed JSON and the raw data payload string, so a relaying
|
|
12
|
+
* transport can pipe upstream's bytes through verbatim.
|
|
13
|
+
*/
|
|
14
|
+
import { log } from "../log.js";
|
|
15
|
+
import { shutdownHooks } from "../shutdown.js";
|
|
16
|
+
import { LocalWriteError, ProxyCoreError, UpstreamProtocolError } from "./errors.js";
|
|
17
|
+
import { SessionStore } from "./session.js";
|
|
18
|
+
import { parseSseStream } from "./sse.js";
|
|
19
|
+
import { toTransportError, UpstreamClient } from "./upstream.js";
|
|
20
|
+
export function createProxyCore(options) {
|
|
21
|
+
const upstream = new UpstreamClient({
|
|
22
|
+
url: options.upstreamUrl,
|
|
23
|
+
apiKey: options.apiKey,
|
|
24
|
+
clientVersion: options.clientVersion,
|
|
25
|
+
fetchFn: options.fetchFn,
|
|
26
|
+
});
|
|
27
|
+
const sessions = new SessionStore();
|
|
28
|
+
/**
|
|
29
|
+
* Run an upstream exchange with an abort controller tracked on the session.
|
|
30
|
+
* An optional external signal (per-request, e.g. local client disconnect)
|
|
31
|
+
* also fires the tracked controller, so session close and client disconnect
|
|
32
|
+
* abort through the same path.
|
|
33
|
+
*/
|
|
34
|
+
async function withInflight(localKey, fn, externalSignal) {
|
|
35
|
+
const controller = sessions.beginRequest(localKey);
|
|
36
|
+
const onExternalAbort = () => controller.abort();
|
|
37
|
+
if (externalSignal !== undefined) {
|
|
38
|
+
if (externalSignal.aborted)
|
|
39
|
+
controller.abort();
|
|
40
|
+
else
|
|
41
|
+
externalSignal.addEventListener("abort", onExternalAbort, { once: true });
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
return await fn(controller.signal);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
externalSignal?.removeEventListener("abort", onExternalAbort);
|
|
48
|
+
sessions.endRequest(localKey, controller);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const core = {
|
|
52
|
+
async initialize(localKey, initializeRequest, clientProtocolVersion, clientSessionId) {
|
|
53
|
+
const entry = sessions.getOrCreate(localKey);
|
|
54
|
+
if (clientProtocolVersion !== undefined) {
|
|
55
|
+
entry.protocolVersion = clientProtocolVersion;
|
|
56
|
+
}
|
|
57
|
+
const response = await withInflight(localKey, (signal) => upstream.post(initializeRequest, {
|
|
58
|
+
// No localKey fallback here: upstream adopts any header id verbatim,
|
|
59
|
+
// so sending an adapter-invented key on initialize would prevent
|
|
60
|
+
// upstream from minting the session id. A CLIENT-sent id, however,
|
|
61
|
+
// must replay (upstream honors it — proxy-restart transparency).
|
|
62
|
+
sessionId: entry.upstreamSessionId ?? clientSessionId,
|
|
63
|
+
protocolVersion: entry.protocolVersion,
|
|
64
|
+
signal,
|
|
65
|
+
}));
|
|
66
|
+
if (response.kind !== "json") {
|
|
67
|
+
throw new UpstreamProtocolError(`Unexpected ${response.kind} response to initialize (HTTP ${response.status})`, response.status);
|
|
68
|
+
}
|
|
69
|
+
// Re-fetch: the session may have been closed while the fetch was in flight.
|
|
70
|
+
const live = sessions.get(localKey);
|
|
71
|
+
if (live !== undefined && response.sessionId !== null) {
|
|
72
|
+
live.upstreamSessionId = response.sessionId;
|
|
73
|
+
// Alias the entry under the upstream-issued id: the client re-sends
|
|
74
|
+
// upstream's id, so later calls arrive keyed by it.
|
|
75
|
+
sessions.alias(response.sessionId, live);
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
status: response.status,
|
|
79
|
+
body: response.body,
|
|
80
|
+
sessionId: response.sessionId,
|
|
81
|
+
contentType: response.contentType,
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
async notify(localKey, notification, clientProtocolVersion) {
|
|
85
|
+
const entry = sessions.getOrCreate(localKey);
|
|
86
|
+
const response = await withInflight(localKey, (signal) => upstream.post(notification, {
|
|
87
|
+
// No captured id means the client is re-sending upstream's own id as
|
|
88
|
+
// the local key — replay the key itself.
|
|
89
|
+
sessionId: entry.upstreamSessionId ?? localKey,
|
|
90
|
+
protocolVersion: clientProtocolVersion ?? entry.protocolVersion,
|
|
91
|
+
signal,
|
|
92
|
+
}));
|
|
93
|
+
// Upstream's contract for notifications is HTTP 204, empty body.
|
|
94
|
+
// Deliberate: any other successful-fetch answer —
|
|
95
|
+
// including a JSON-RPC error body — is deliberately swallowed after a
|
|
96
|
+
// stderr warning, because a JSON-RPC notification has no response
|
|
97
|
+
// channel to relay it on (the local client still gets its 204).
|
|
98
|
+
// Transport/auth failures that THROW (unreachable, 401 with a
|
|
99
|
+
// non-JSON-RPC body) still propagate and are shaped by the adapter.
|
|
100
|
+
// Unreachable against today's real upstream, which 204s notifications
|
|
101
|
+
// before auth runs — revisit if the API-key branch changes that.
|
|
102
|
+
if (response.kind !== "empty") {
|
|
103
|
+
const errorCode = response.kind === "json" ? jsonRpcErrorCodeOf(response.body) : undefined;
|
|
104
|
+
const codeSuffix = errorCode !== undefined ? `, JSON-RPC error ${errorCode}` : "";
|
|
105
|
+
log.warn(`notification got unexpected ${response.kind} response ` +
|
|
106
|
+
`(HTTP ${response.status}${codeSuffix}) — dropped; ` +
|
|
107
|
+
`notifications have no response channel`);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
async forward(localKey, request, clientProtocolVersion) {
|
|
111
|
+
const entry = sessions.getOrCreate(localKey);
|
|
112
|
+
const response = await withInflight(localKey, (signal) => upstream.post(request, {
|
|
113
|
+
sessionId: entry.upstreamSessionId ?? localKey,
|
|
114
|
+
protocolVersion: clientProtocolVersion ?? entry.protocolVersion,
|
|
115
|
+
signal,
|
|
116
|
+
}));
|
|
117
|
+
if (response.kind !== "json") {
|
|
118
|
+
throw new UpstreamProtocolError(`Unexpected ${response.kind} response to non-streaming request (HTTP ${response.status})`, response.status);
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
status: response.status,
|
|
122
|
+
body: response.body,
|
|
123
|
+
sessionId: response.sessionId,
|
|
124
|
+
contentType: response.contentType,
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
async forwardStream(localKey, request, onEvent, clientProtocolVersion, abortSignal) {
|
|
128
|
+
const entry = sessions.getOrCreate(localKey);
|
|
129
|
+
return withInflight(localKey, async (signal) => {
|
|
130
|
+
const response = await upstream.post(request, {
|
|
131
|
+
sessionId: entry.upstreamSessionId ?? localKey,
|
|
132
|
+
protocolVersion: clientProtocolVersion ?? entry.protocolVersion,
|
|
133
|
+
signal,
|
|
134
|
+
});
|
|
135
|
+
if (response.kind === "json") {
|
|
136
|
+
return {
|
|
137
|
+
status: response.status,
|
|
138
|
+
body: response.body,
|
|
139
|
+
sessionId: response.sessionId,
|
|
140
|
+
contentType: response.contentType,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (response.kind === "sse") {
|
|
144
|
+
const finalFrame = await readSseUntilFinal(response.stream, requestIdOf(request), onEvent);
|
|
145
|
+
// Upstream SSE responses never carry Mcp-Session-Id; the final frame
|
|
146
|
+
// is synthesized from the stream, so no upstream Content-Type applies.
|
|
147
|
+
return {
|
|
148
|
+
status: response.status,
|
|
149
|
+
body: finalFrame.message,
|
|
150
|
+
sessionId: null,
|
|
151
|
+
contentType: null,
|
|
152
|
+
rawBody: finalFrame.rawData,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
throw new UpstreamProtocolError(`Unexpected empty response to a request (HTTP ${response.status})`, response.status);
|
|
156
|
+
}, abortSignal);
|
|
157
|
+
},
|
|
158
|
+
close(localKey) {
|
|
159
|
+
sessions.close(localKey);
|
|
160
|
+
},
|
|
161
|
+
closeAll() {
|
|
162
|
+
sessions.closeAll();
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
const hooks = options.hooks === undefined ? shutdownHooks : options.hooks;
|
|
166
|
+
if (hooks !== null) {
|
|
167
|
+
hooks.push(() => core.closeAll());
|
|
168
|
+
}
|
|
169
|
+
return core;
|
|
170
|
+
}
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// SSE stream consumption (parser lives in core/sse.ts)
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
/** The JSON-RPC error code of a response body, if it is an error response. */
|
|
175
|
+
function jsonRpcErrorCodeOf(body) {
|
|
176
|
+
if (typeof body !== "object" || body === null)
|
|
177
|
+
return undefined;
|
|
178
|
+
const error = body.error;
|
|
179
|
+
if (typeof error !== "object" || error === null)
|
|
180
|
+
return undefined;
|
|
181
|
+
const code = error.code;
|
|
182
|
+
return typeof code === "number" ? code : undefined;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The JSON-RPC id of a request message, undefined when absent. Single copy —
|
|
186
|
+
* the HTTP adapter imports this too; the shaping functions in core/errors.ts
|
|
187
|
+
* normalize undefined to null themselves.
|
|
188
|
+
*/
|
|
189
|
+
export function requestIdOf(request) {
|
|
190
|
+
if (typeof request === "object" && request !== null && "id" in request) {
|
|
191
|
+
return request.id;
|
|
192
|
+
}
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
/** A JSON-RPC response frame: has result/error, no method; id must match. */
|
|
196
|
+
function isFinalResponse(message, requestId) {
|
|
197
|
+
if (typeof message !== "object" || message === null)
|
|
198
|
+
return false;
|
|
199
|
+
const frame = message;
|
|
200
|
+
if ("method" in frame)
|
|
201
|
+
return false;
|
|
202
|
+
if (!("result" in frame) && !("error" in frame))
|
|
203
|
+
return false;
|
|
204
|
+
return requestId === undefined || frame.id === requestId;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Consume the SSE stream via parseSseStream, emitting notification frames via
|
|
208
|
+
* onEvent (each emission awaited — backpressure) and returning the final
|
|
209
|
+
* JSON-RPC response frame with its raw payload string. Reading stops at the
|
|
210
|
+
* final frame: breaking out of the for-await runs the generator's return
|
|
211
|
+
* path, which cancels the upstream stream — no waiting on upstream to close,
|
|
212
|
+
* and any spec-violating post-final frames never reach onEvent.
|
|
213
|
+
*
|
|
214
|
+
* Error classification: onEvent rejections (local client write failures) are
|
|
215
|
+
* wrapped as LocalWriteError; stream/read failures map to transport errors
|
|
216
|
+
* (abort → UpstreamAbortedError, network → UpstreamUnreachableError); parser
|
|
217
|
+
* failures are UpstreamProtocolError. Only genuinely unclassified errors go
|
|
218
|
+
* through toTransportError.
|
|
219
|
+
*/
|
|
220
|
+
async function readSseUntilFinal(stream, requestId, onEvent) {
|
|
221
|
+
let finalFrame;
|
|
222
|
+
try {
|
|
223
|
+
for await (const event of parseSseStream(stream)) {
|
|
224
|
+
if (isFinalResponse(event.message, requestId)) {
|
|
225
|
+
finalFrame = { message: event.message, rawData: event.rawData };
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
// Tolerate and relay any notification method verbatim (future-proofing);
|
|
229
|
+
// a rejected write is a LOCAL failure, never an upstream one.
|
|
230
|
+
try {
|
|
231
|
+
await onEvent(event.message, event.rawData);
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
throw new LocalWriteError(`Relaying SSE frame to the local client failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
// Already-classified errors (LocalWriteError, UpstreamProtocolError from
|
|
240
|
+
// the parser, typed transport errors) pass through untouched.
|
|
241
|
+
if (err instanceof ProxyCoreError)
|
|
242
|
+
throw err;
|
|
243
|
+
throw toTransportError(err);
|
|
244
|
+
}
|
|
245
|
+
if (finalFrame === undefined) {
|
|
246
|
+
throw new UpstreamProtocolError("Upstream SSE stream ended without a final response frame");
|
|
247
|
+
}
|
|
248
|
+
return finalFrame;
|
|
249
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session bridging state.
|
|
3
|
+
*
|
|
4
|
+
* The proxy relays session headers verbatim: the client re-sends upstream's
|
|
5
|
+
* own Mcp-Session-Id, so localKey is normally the upstream-issued id itself
|
|
6
|
+
* and the map's main job is abort tracking for in-flight upstream fetches.
|
|
7
|
+
* The upstreamSessionId / protocolVersion fields cost nothing and keep the
|
|
8
|
+
* door open for a future stdio transport that needs real id bridging.
|
|
9
|
+
*
|
|
10
|
+
* Close is local-only cleanup: the hosted server has no DELETE endpoint, so
|
|
11
|
+
* teardown just aborts in-flight fetches and drops the entry.
|
|
12
|
+
*
|
|
13
|
+
* Growth characteristic (deliberate): entries are created on first use and
|
|
14
|
+
* removed only by close()/closeAll(). The HTTP surface has no client-driven
|
|
15
|
+
* teardown signal (clients cannot DELETE), so a long-running proxy
|
|
16
|
+
* accumulates one small entry per distinct session key until process shutdown
|
|
17
|
+
* runs closeAll() via the shutdown hook. That is acceptable for a local
|
|
18
|
+
* single-user proxy — entries are a few strings plus an empty Set. Call
|
|
19
|
+
* core.close(localKey) wherever a transport does learn of a session's end
|
|
20
|
+
* (e.g. a future stdio transport's disconnect); an idle TTL can be added
|
|
21
|
+
* later if a real leak ever materializes.
|
|
22
|
+
*/
|
|
23
|
+
export interface SessionEntry {
|
|
24
|
+
/** Captured from the initialize response's Mcp-Session-Id header. */
|
|
25
|
+
upstreamSessionId?: string;
|
|
26
|
+
/** The client's MCP-Protocol-Version, replayed on subsequent calls. */
|
|
27
|
+
protocolVersion?: string;
|
|
28
|
+
/** AbortControllers for upstream fetches currently in flight. */
|
|
29
|
+
readonly inflight: Set<AbortController>;
|
|
30
|
+
}
|
|
31
|
+
export declare class SessionStore {
|
|
32
|
+
private readonly sessions;
|
|
33
|
+
get size(): number;
|
|
34
|
+
get(localKey: string): SessionEntry | undefined;
|
|
35
|
+
has(localKey: string): boolean;
|
|
36
|
+
/** Create-on-first-use lookup. */
|
|
37
|
+
getOrCreate(localKey: string): SessionEntry;
|
|
38
|
+
/**
|
|
39
|
+
* Map an additional key to an existing entry. Used by initialize() to make
|
|
40
|
+
* the upstream-issued Mcp-Session-Id resolve to the same session as the
|
|
41
|
+
* initialize-time local key: the client re-sends upstream's id, so later
|
|
42
|
+
* calls arrive keyed by that id.
|
|
43
|
+
*/
|
|
44
|
+
alias(aliasKey: string, entry: SessionEntry): void;
|
|
45
|
+
/**
|
|
46
|
+
* Register a new in-flight upstream request for the session, creating the
|
|
47
|
+
* session on first use. Pair with endRequest once the request settles.
|
|
48
|
+
*/
|
|
49
|
+
beginRequest(localKey: string): AbortController;
|
|
50
|
+
/** Forget a settled request's controller (no-op if the session was closed). */
|
|
51
|
+
endRequest(localKey: string, controller: AbortController): void;
|
|
52
|
+
/**
|
|
53
|
+
* Local-only teardown: abort every in-flight upstream fetch for the session
|
|
54
|
+
* and drop the entry — including every alias key that maps to the same
|
|
55
|
+
* entry. No upstream DELETE — the hosted server has no DELETE endpoint.
|
|
56
|
+
*/
|
|
57
|
+
close(localKey: string): void;
|
|
58
|
+
/** Close every session (shutdown path). */
|
|
59
|
+
closeAll(): void;
|
|
60
|
+
}
|