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,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>;
|
package/dist/core/sse.js
ADDED
|
@@ -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;
|