tinyfish-mcp-lite 0.0.0-stage → 0.2.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,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
+ }