tinyfish-mcp-lite 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
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 class SessionStore {
24
+ sessions = new Map();
25
+ get size() {
26
+ return this.sessions.size;
27
+ }
28
+ get(localKey) {
29
+ return this.sessions.get(localKey);
30
+ }
31
+ has(localKey) {
32
+ return this.sessions.has(localKey);
33
+ }
34
+ /** Create-on-first-use lookup. */
35
+ getOrCreate(localKey) {
36
+ let entry = this.sessions.get(localKey);
37
+ if (entry === undefined) {
38
+ entry = { inflight: new Set() };
39
+ this.sessions.set(localKey, entry);
40
+ }
41
+ return entry;
42
+ }
43
+ /**
44
+ * Map an additional key to an existing entry. Used by initialize() to make
45
+ * the upstream-issued Mcp-Session-Id resolve to the same session as the
46
+ * initialize-time local key: the client re-sends upstream's id, so later
47
+ * calls arrive keyed by that id.
48
+ */
49
+ alias(aliasKey, entry) {
50
+ this.sessions.set(aliasKey, entry);
51
+ }
52
+ /**
53
+ * Register a new in-flight upstream request for the session, creating the
54
+ * session on first use. Pair with endRequest once the request settles.
55
+ */
56
+ beginRequest(localKey) {
57
+ const controller = new AbortController();
58
+ this.getOrCreate(localKey).inflight.add(controller);
59
+ return controller;
60
+ }
61
+ /** Forget a settled request's controller (no-op if the session was closed). */
62
+ endRequest(localKey, controller) {
63
+ this.sessions.get(localKey)?.inflight.delete(controller);
64
+ }
65
+ /**
66
+ * Local-only teardown: abort every in-flight upstream fetch for the session
67
+ * and drop the entry — including every alias key that maps to the same
68
+ * entry. No upstream DELETE — the hosted server has no DELETE endpoint.
69
+ */
70
+ close(localKey) {
71
+ const entry = this.sessions.get(localKey);
72
+ if (entry === undefined)
73
+ return;
74
+ for (const [key, value] of this.sessions) {
75
+ if (value === entry)
76
+ this.sessions.delete(key);
77
+ }
78
+ for (const controller of entry.inflight) {
79
+ controller.abort();
80
+ }
81
+ entry.inflight.clear();
82
+ }
83
+ /** Close every session (shutdown path). */
84
+ closeAll() {
85
+ for (const localKey of [...this.sessions.keys()]) {
86
+ this.close(localKey);
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,20 @@
1
+ export interface SseEvent {
2
+ /** The data payload parsed as JSON (every upstream frame is one JSON-RPC message). */
3
+ message: unknown;
4
+ /**
5
+ * The exact data payload string: field values of every `data:` line in the
6
+ * block, joined with "\n". Relay this verbatim (re-split on "\n" into
7
+ * `data:` lines when re-framing) to preserve upstream's bytes.
8
+ */
9
+ rawData: string;
10
+ /** `event:` field value, if the block carried one (unused upstream). */
11
+ event?: string;
12
+ /** `id:` field value, if the block carried one (unused upstream). */
13
+ id?: string;
14
+ }
15
+ /**
16
+ * Parse an SSE byte stream into events. Throws UpstreamProtocolError when a
17
+ * data payload is not valid JSON; stream read failures propagate as-is (the
18
+ * consumer maps them to transport errors).
19
+ */
20
+ export declare function parseSseStream(stream: ReadableStream<Uint8Array>): AsyncGenerator<SseEvent, void, undefined>;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Incremental SSE parser over a ReadableStream<Uint8Array>.
3
+ *
4
+ * Handles chunk boundaries
5
+ * anywhere — mid-line, mid-event, mid-CRLF, even mid-UTF-8-codepoint (the
6
+ * streaming TextDecoder holds partial sequences) — per the SSE processing
7
+ * model:
8
+ *
9
+ * - Lines end with LF or CRLF (upstream sends LF; CRLF tolerated).
10
+ * - `data:` field values accumulate; multi-line data is joined with "\n".
11
+ * - `event:` / `id:` fields are tolerated and surfaced (currently unused
12
+ * upstream — every real frame is a bare `data:` line).
13
+ * - `:` comment lines are consumed, never forwarded.
14
+ * - `retry:` and unknown fields are ignored.
15
+ * - A blank line dispatches the pending event; blocks without any `data:`
16
+ * field dispatch nothing (spec behavior — comments/ids alone are dropped).
17
+ * - A stream that ends without a trailing blank line still dispatches its
18
+ * pending event.
19
+ *
20
+ * Each yielded event carries BOTH the parsed JSON and the raw joined data
21
+ * payload string, so a relaying transport can pipe upstream's original bytes
22
+ * through untouched instead of re-serializing.
23
+ *
24
+ * Teardown: breaking out of (or throwing from) a for-await over this
25
+ * generator runs its return path, which ends the inner for-await over the
26
+ * stream and cancels the ReadableStream — no orphaned upstream reads.
27
+ */
28
+ import { UpstreamProtocolError } from "./errors.js";
29
+ /**
30
+ * Parse an SSE byte stream into events. Throws UpstreamProtocolError when a
31
+ * data payload is not valid JSON; stream read failures propagate as-is (the
32
+ * consumer maps them to transport errors).
33
+ */
34
+ export async function* parseSseStream(stream) {
35
+ const decoder = new TextDecoder();
36
+ let buffer = "";
37
+ let dataLines = null;
38
+ let eventField;
39
+ let idField;
40
+ /** Fold one complete line into the pending event; return it when dispatched. */
41
+ const processLine = (line) => {
42
+ if (line === "") {
43
+ // Blank line: dispatch the pending event, if it carried any data.
44
+ const pending = dataLines;
45
+ const event = eventField;
46
+ const id = idField;
47
+ dataLines = null;
48
+ eventField = undefined;
49
+ idField = undefined;
50
+ if (pending === null)
51
+ return undefined;
52
+ const rawData = pending.join("\n");
53
+ return { message: parseJsonPayload(rawData), rawData, event, id };
54
+ }
55
+ if (line.startsWith(":"))
56
+ return undefined; // comment — consumed
57
+ const colon = line.indexOf(":");
58
+ const field = colon === -1 ? line : line.slice(0, colon);
59
+ let value = colon === -1 ? "" : line.slice(colon + 1);
60
+ if (value.startsWith(" "))
61
+ value = value.slice(1); // spec: strip one leading space
62
+ switch (field) {
63
+ case "data":
64
+ (dataLines ??= []).push(value);
65
+ break;
66
+ case "event":
67
+ eventField = value;
68
+ break;
69
+ case "id":
70
+ idField = value;
71
+ break;
72
+ default:
73
+ // retry: and unknown fields — ignored.
74
+ break;
75
+ }
76
+ return undefined;
77
+ };
78
+ for await (const chunk of stream) {
79
+ buffer += decoder.decode(chunk, { stream: true });
80
+ let newline;
81
+ while ((newline = buffer.indexOf("\n")) !== -1) {
82
+ let line = buffer.slice(0, newline);
83
+ buffer = buffer.slice(newline + 1);
84
+ // CRLF: the CR waits in the buffer until its LF arrives, so a CRLF pair
85
+ // split across chunks needs no special casing — just strip it here.
86
+ if (line.endsWith("\r"))
87
+ line = line.slice(0, -1);
88
+ const event = processLine(line);
89
+ if (event !== undefined)
90
+ yield event;
91
+ }
92
+ }
93
+ // Stream ended: flush the decoder's partial UTF-8 state and any final line
94
+ // without a trailing newline, then dispatch a pending event (tolerate a
95
+ // stream that ends without the final blank line).
96
+ buffer += decoder.decode();
97
+ if (buffer.length > 0) {
98
+ let line = buffer;
99
+ if (line.endsWith("\r"))
100
+ line = line.slice(0, -1);
101
+ const event = processLine(line);
102
+ if (event !== undefined)
103
+ yield event;
104
+ }
105
+ const flushed = processLine("");
106
+ if (flushed !== undefined)
107
+ yield flushed;
108
+ }
109
+ /**
110
+ * Every upstream frame must be a JSON-RPC message. An empty `data:` payload is
111
+ * therefore a protocol violation too (JSON.parse("") throws): it surfaces as
112
+ * UpstreamProtocolError, same as any other non-JSON payload.
113
+ */
114
+ function parseJsonPayload(rawData) {
115
+ try {
116
+ return JSON.parse(rawData);
117
+ }
118
+ catch (err) {
119
+ throw new UpstreamProtocolError("Upstream SSE frame is not valid JSON", undefined, {
120
+ cause: err,
121
+ });
122
+ }
123
+ }
@@ -0,0 +1,48 @@
1
+ import { type ProxyCore } from "./proxy-core.js";
2
+ /** The two tools the hosted server serves for free. */
3
+ export declare const DEFAULT_ALLOWED_TOOLS: readonly string[];
4
+ /** Env var: comma-separated tool allowlist, or "*" to disable filtering. */
5
+ export declare const TOOLS_ENV_VAR = "TINYFISH_TOOLS";
6
+ /**
7
+ * `allow` exposes only the listed tools (insertion-ordered for the startup
8
+ * log); `passthrough` disables the filter entirely (upstream verbatim).
9
+ */
10
+ export type ToolPolicy = {
11
+ mode: "allow";
12
+ allowed: ReadonlySet<string>;
13
+ } | {
14
+ mode: "passthrough";
15
+ };
16
+ /**
17
+ * Parse TINYFISH_TOOLS. Undefined → the free-tool default; "*" → passthrough;
18
+ * otherwise a strict comma-separated list (each entry trimmed, deduped,
19
+ * order-preserving). Throws ConfigError with an actionable message on
20
+ * malformed input — same contract as parseConfig in config.ts.
21
+ */
22
+ export declare function parseToolPolicy(raw: string | undefined): ToolPolicy;
23
+ /**
24
+ * Wrap a ProxyCore with the policy. Passthrough returns the core itself
25
+ * (identity — zero overhead, zero behavior change). Otherwise every
26
+ * tools/list response has non-allowed tools stripped (result key order and
27
+ * nextCursor preserved; error or unexpected shapes relayed untouched), and
28
+ * every tools/call naming a hidden tool is answered LOCALLY with the same
29
+ * JSON-RPC error the hosted server itself produces for an unknown tool —
30
+ * the request never reaches upstream, so it can never bill.
31
+ *
32
+ * Not intercepted, by design:
33
+ * - initialize/resources/* — delegated verbatim (fork policy is about tools
34
+ * only; anything else would diverge from upstream for no token win).
35
+ *
36
+ * Also closed (defense in depth — the no-paid-call guarantee must not
37
+ * depend on upstream behavior):
38
+ * - tools/call shaped as a NOTIFICATION (no id): the adapter routes
39
+ * notifications through notify(), which would otherwise forward verbatim;
40
+ * hidden-tool ones are swallowed locally (notifications have no response
41
+ * channel, so a silent drop is indistinguishable from upstream's 204).
42
+ * - JSON-RPC batch ARRAYS carrying a hidden tools/call: MCP forbids
43
+ * batching and the hosted server rejects batches as InvalidRequest, but
44
+ * JSON-RPC 2.0 permits them — a batch-capable upstream change must not
45
+ * silently widen the filter. Batches containing hidden-tool calls are
46
+ * rejected locally with the exact shape upstream produces today.
47
+ */
48
+ export declare function withToolFilter(core: ProxyCore, policy: ToolPolicy): ProxyCore;
@@ -0,0 +1,213 @@
1
+ /**
2
+ * FORK ADDITION (tinyfish-mcp-lite) — this module does not exist upstream.
3
+ *
4
+ * Free-tool allowlist for the fork: the hosted TinyFish server exposes 28
5
+ * tools, but only `search` and `fetch_content` are free — every other tool
6
+ * costs credits the account may not have, while its schema still lands in
7
+ * the client's context and burns tokens on each session (filtering to the
8
+ * two free tools cuts the tools/list payload by ~72%). This module hides
9
+ * everything else from the client AND blocks calls to hidden tools locally,
10
+ * so a hallucinated tool name can never reach the paid upstream path.
11
+ *
12
+ * Design constraints (see FORK.md):
13
+ * - Upstream files stay untouched except the two-line wiring in index.ts —
14
+ * all fork logic lives in this fork-owned module, so `git merge
15
+ * upstream/main` stays conflict-free by construction.
16
+ * - Implemented as a ProxyCore decorator, not an adapter change: the adapter
17
+ * routes every tools/call through forwardStream and every tools/list
18
+ * through forward, so decorating those two methods covers both directions
19
+ * (advertise and invoke) without touching request parsing or SSE relay.
20
+ * - TINYFISH_TOOLS="*" restores byte-identical upstream passthrough.
21
+ */
22
+ import { ConfigError } from "../config.js";
23
+ import { log } from "../log.js";
24
+ import { requestIdOf } from "./proxy-core.js";
25
+ /** The two tools the hosted server serves for free. */
26
+ export const DEFAULT_ALLOWED_TOOLS = ["search", "fetch_content"];
27
+ /** Env var: comma-separated tool allowlist, or "*" to disable filtering. */
28
+ export const TOOLS_ENV_VAR = "TINYFISH_TOOLS";
29
+ /** Tool names MCP permits: letters, digits, `_`, `-`, `.` (defensive superset). */
30
+ const TOOL_NAME_PATTERN = /^[A-Za-z0-9_.-]{1,128}$/;
31
+ /**
32
+ * Parse TINYFISH_TOOLS. Undefined → the free-tool default; "*" → passthrough;
33
+ * otherwise a strict comma-separated list (each entry trimmed, deduped,
34
+ * order-preserving). Throws ConfigError with an actionable message on
35
+ * malformed input — same contract as parseConfig in config.ts.
36
+ */
37
+ export function parseToolPolicy(raw) {
38
+ if (raw === undefined) {
39
+ return { mode: "allow", allowed: new Set(DEFAULT_ALLOWED_TOOLS) };
40
+ }
41
+ const value = raw.trim();
42
+ if (value === "*") {
43
+ return { mode: "passthrough" };
44
+ }
45
+ if (value === "") {
46
+ throw new ConfigError(`Invalid ${TOOLS_ENV_VAR} "${raw}" — list at least one tool name (comma-separated) or "*" for all upstream tools`);
47
+ }
48
+ const seen = new Set();
49
+ for (const entry of value.split(",")) {
50
+ const name = entry.trim();
51
+ if (!TOOL_NAME_PATTERN.test(name)) {
52
+ throw new ConfigError(`Invalid ${TOOLS_ENV_VAR} entry "${entry}" — tool names may contain only letters, digits, "_", "-", "."`);
53
+ }
54
+ seen.add(name);
55
+ }
56
+ return { mode: "allow", allowed: seen };
57
+ }
58
+ /**
59
+ * Wrap a ProxyCore with the policy. Passthrough returns the core itself
60
+ * (identity — zero overhead, zero behavior change). Otherwise every
61
+ * tools/list response has non-allowed tools stripped (result key order and
62
+ * nextCursor preserved; error or unexpected shapes relayed untouched), and
63
+ * every tools/call naming a hidden tool is answered LOCALLY with the same
64
+ * JSON-RPC error the hosted server itself produces for an unknown tool —
65
+ * the request never reaches upstream, so it can never bill.
66
+ *
67
+ * Not intercepted, by design:
68
+ * - initialize/resources/* — delegated verbatim (fork policy is about tools
69
+ * only; anything else would diverge from upstream for no token win).
70
+ *
71
+ * Also closed (defense in depth — the no-paid-call guarantee must not
72
+ * depend on upstream behavior):
73
+ * - tools/call shaped as a NOTIFICATION (no id): the adapter routes
74
+ * notifications through notify(), which would otherwise forward verbatim;
75
+ * hidden-tool ones are swallowed locally (notifications have no response
76
+ * channel, so a silent drop is indistinguishable from upstream's 204).
77
+ * - JSON-RPC batch ARRAYS carrying a hidden tools/call: MCP forbids
78
+ * batching and the hosted server rejects batches as InvalidRequest, but
79
+ * JSON-RPC 2.0 permits them — a batch-capable upstream change must not
80
+ * silently widen the filter. Batches containing hidden-tool calls are
81
+ * rejected locally with the exact shape upstream produces today.
82
+ */
83
+ export function withToolFilter(core, policy) {
84
+ if (policy.mode === "passthrough") {
85
+ return core;
86
+ }
87
+ const allowed = policy.allowed;
88
+ function filterToolsListBody(body) {
89
+ if (typeof body !== "object" || body === null)
90
+ return body;
91
+ const result = body.result;
92
+ if (typeof result !== "object" || result === null)
93
+ return body;
94
+ const tools = result.tools;
95
+ if (!Array.isArray(tools))
96
+ return body;
97
+ const kept = tools.filter((tool) => typeof tool === "object" &&
98
+ tool !== null &&
99
+ typeof tool.name === "string" &&
100
+ allowed.has(tool.name));
101
+ if (kept.length === tools.length)
102
+ return body;
103
+ // Spread re-assignment keeps every original key in place (including
104
+ // `tools` at its original position and any `nextCursor`), so the
105
+ // re-serialized response stays byte-stable apart from removed entries.
106
+ return {
107
+ ...body,
108
+ result: { ...result, tools: kept },
109
+ };
110
+ }
111
+ return {
112
+ // Delegated methods are copied by reference — the decorator overrides
113
+ // only the routing methods below. No `this` anywhere (the underlying
114
+ // core is a plain object literal, like the decorator).
115
+ ...core,
116
+ async forward(localKey, request, clientProtocolVersion) {
117
+ if (containsHiddenToolCall(request, allowed)) {
118
+ // Batch array with a hidden tools/call inside: answer locally with
119
+ // the same rejection the hosted server produces for batches today
120
+ // (HTTP 400 / -32600), so client behavior is unchanged while the
121
+ // fork — not upstream — owns the no-paid-call property.
122
+ return invalidBatchResponse();
123
+ }
124
+ const response = await core.forward(localKey, request, clientProtocolVersion);
125
+ if (requestMethodOf(request) !== "tools/list")
126
+ return response;
127
+ return { ...response, body: filterToolsListBody(response.body) };
128
+ },
129
+ async forwardStream(localKey, request, onEvent, clientProtocolVersion, signal) {
130
+ const name = calledToolNameOf(request);
131
+ if (name !== undefined && !allowed.has(name)) {
132
+ return blockedToolResponse(request, name);
133
+ }
134
+ return core.forwardStream(localKey, request, onEvent, clientProtocolVersion, signal);
135
+ },
136
+ async notify(localKey, notification, clientProtocolVersion) {
137
+ // A tools/call without an id is protocol-invalid but the adapter
138
+ // forwards ANY notification verbatim — close that path: hidden-tool
139
+ // calls are dropped locally (the adapter still answers the client
140
+ // 204, exactly like upstream's notification path).
141
+ const name = calledToolNameOf(notification);
142
+ if (name !== undefined && !allowed.has(name)) {
143
+ log.warn(`dropped tools/call notification for hidden tool "${name}" — notifications have no response channel`);
144
+ return;
145
+ }
146
+ return core.notify(localKey, notification, clientProtocolVersion);
147
+ },
148
+ };
149
+ }
150
+ /** The hosted server's batch rejection, mirrored byte-for-byte. */
151
+ function invalidBatchResponse() {
152
+ return {
153
+ status: 400,
154
+ body: {
155
+ jsonrpc: "2.0",
156
+ error: { code: -32600, message: "Invalid JSON-RPC 2.0 request format" },
157
+ id: -1,
158
+ },
159
+ sessionId: null,
160
+ contentType: "application/json",
161
+ };
162
+ }
163
+ /** True only for ARRAY requests (MCP-forbidden batches) hiding a blocked call. */
164
+ function containsHiddenToolCall(request, allowed) {
165
+ if (!Array.isArray(request))
166
+ return false;
167
+ return request.some((element) => {
168
+ const name = calledToolNameOf(element);
169
+ return name !== undefined && !allowed.has(name);
170
+ });
171
+ }
172
+ /**
173
+ * The local answer for a hidden tool. Mirrors the hosted server's own
174
+ * unknown-tool error — JSON-RPC -32602 "Unknown tool: <name>", client-error
175
+ * code mapped to HTTP 400 — so a client cannot tell the block apart from
176
+ * calling a genuinely nonexistent tool upstream, and no paid request is
177
+ * ever sent. Upstream echoes no session id on locally synthesized answers.
178
+ */
179
+ function blockedToolResponse(request, name) {
180
+ return {
181
+ status: 400,
182
+ body: {
183
+ jsonrpc: "2.0",
184
+ error: { code: -32602, message: `Unknown tool: ${name}` },
185
+ id: requestIdOf(request),
186
+ },
187
+ sessionId: null,
188
+ contentType: "application/json",
189
+ };
190
+ }
191
+ /** The JSON-RPC method of a request message, if it is a string. */
192
+ function requestMethodOf(request) {
193
+ if (typeof request !== "object" || request === null)
194
+ return undefined;
195
+ const method = request.method;
196
+ return typeof method === "string" ? method : undefined;
197
+ }
198
+ /**
199
+ * The tool name of a tools/call request. Undefined when the request is not
200
+ * a tools/call or carries no string name — those forward upstream and let
201
+ * the hosted server produce its own (authoritative) error.
202
+ */
203
+ function calledToolNameOf(request) {
204
+ if (requestMethodOf(request) !== "tools/call")
205
+ return undefined;
206
+ if (typeof request !== "object" || request === null)
207
+ return undefined;
208
+ const params = request.params;
209
+ if (typeof params !== "object" || params === null)
210
+ return undefined;
211
+ const name = params.name;
212
+ return typeof name === "string" && name !== "" ? name : undefined;
213
+ }
@@ -0,0 +1,58 @@
1
+ import { UpstreamAbortedError, UpstreamUnreachableError } from "./errors.js";
2
+ export type FetchLike = typeof globalThis.fetch;
3
+ export interface UpstreamClientOptions {
4
+ /** Full upstream MCP URL, e.g. https://agent.tinyfish.ai/mcp */
5
+ url: string;
6
+ /** Sent as X-API-Key. Never logged. */
7
+ apiKey: string;
8
+ /** Sent as X-TF-Client-Version; defaults to the package version. */
9
+ clientVersion?: string;
10
+ /** Injectable fetch for tests; defaults to globalThis.fetch. */
11
+ fetchFn?: FetchLike;
12
+ }
13
+ export interface UpstreamCallOptions {
14
+ /** Replayed as Mcp-Session-Id when known. */
15
+ sessionId?: string;
16
+ /** Client's MCP-Protocol-Version, passed through when the client sent one. */
17
+ protocolVersion?: string;
18
+ /** Aborts the request and any in-progress body read. */
19
+ signal?: AbortSignal;
20
+ }
21
+ /**
22
+ * Response classification. Transport failures are not a variant — they are
23
+ * thrown as typed errors (UpstreamUnreachableError / UpstreamAbortedError).
24
+ */
25
+ export type UpstreamResponse = {
26
+ kind: "json";
27
+ status: number;
28
+ /** Mcp-Session-Id echoed by upstream (JSON responses only). */
29
+ sessionId: string | null;
30
+ /** The parsed JSON-RPC response, verbatim — success or error object. */
31
+ body: unknown;
32
+ /** Upstream's Content-Type header, verbatim (null if absent). */
33
+ contentType: string | null;
34
+ } | {
35
+ kind: "sse";
36
+ status: number;
37
+ /** Raw upstream byte stream. Upstream SSE responses carry no session header. */
38
+ stream: ReadableStream<Uint8Array>;
39
+ } | {
40
+ /** 204/empty body — upstream's answer to notifications. */
41
+ kind: "empty";
42
+ status: number;
43
+ };
44
+ export declare class UpstreamClient {
45
+ private readonly url;
46
+ private readonly apiKey;
47
+ private readonly clientVersion;
48
+ private readonly fetchFn;
49
+ constructor(options: UpstreamClientOptions);
50
+ /** POST one JSON-RPC message upstream and classify the response. */
51
+ post(message: unknown, options?: UpstreamCallOptions): Promise<UpstreamResponse>;
52
+ }
53
+ /**
54
+ * Map a fetch/stream failure to a typed transport error. Never includes the
55
+ * key. When `url` is given (the fetch call site knows it), the upstream host
56
+ * is attached so the error shaping can name it in the client-facing message.
57
+ */
58
+ export declare function toTransportError(err: unknown, url?: string): UpstreamAbortedError | UpstreamUnreachableError;