tinyfish-mcp-lite 0.2.0 → 0.2.1

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/dist/index.js CHANGED
@@ -11,14 +11,30 @@ import { VERSION } from "./version.js";
11
11
  function isErrnoException(err) {
12
12
  return err instanceof Error && "code" in err;
13
13
  }
14
+ // FORK(lite): stdio shutdown hook runner — mirrors the HTTP branch's shutdown
15
+ // path (core.closeAll aborts in-flight upstream fetches).
16
+ async function runShutdownHooks() {
17
+ for (const hook of shutdownHooks) {
18
+ try {
19
+ await hook();
20
+ }
21
+ catch (err) {
22
+ log.error(`shutdown hook failed: ${err instanceof Error ? err.message : String(err)}`);
23
+ }
24
+ }
25
+ }
14
26
  async function main() {
27
+ // FORK(lite): stdio owns no port — drop PORT from the environment the
28
+ // config parses, so a stray PORT (e.g. copied from another server's client
29
+ // config block) cannot abort a transport that never listens.
30
+ const env = process.argv.includes("--stdio") ? { ...process.env, PORT: undefined } : process.env;
15
31
  let config;
16
32
  let toolPolicy; // FORK(lite)
17
33
  try {
18
- config = parseConfig(process.env);
34
+ config = parseConfig(env);
19
35
  // FORK(lite): TINYFISH_TOOLS allowlist (default: the free search +
20
36
  // fetch_content; "*" restores unfiltered upstream passthrough).
21
- toolPolicy = parseToolPolicy(process.env.TINYFISH_TOOLS);
37
+ toolPolicy = parseToolPolicy(env.TINYFISH_TOOLS);
22
38
  }
23
39
  catch (err) {
24
40
  if (err instanceof ConfigError) {
@@ -47,15 +63,16 @@ async function main() {
47
63
  else {
48
64
  log.info("tool filter: off (TINYFISH_TOOLS=*) — exposing every upstream tool");
49
65
  }
66
+ // The client normally shuts us down by closing stdin; a signal (client
67
+ // killed, terminal gone) must still run the hooks — closeAll aborts
68
+ // in-flight upstream fetches — instead of the default hard exit.
69
+ const onSignal = () => {
70
+ void runShutdownHooks().then(() => process.exit(0));
71
+ };
72
+ process.on("SIGINT", onSignal);
73
+ process.on("SIGTERM", onSignal);
50
74
  await runStdioTransport(core);
51
- for (const hook of shutdownHooks) {
52
- try {
53
- await hook();
54
- }
55
- catch (err) {
56
- log.error(`shutdown hook failed: ${err instanceof Error ? err.message : String(err)}`);
57
- }
58
- }
75
+ await runShutdownHooks();
59
76
  process.exit(0);
60
77
  }
61
78
  const handler = createAppHandler(createMcpAdapter(core));
@@ -5,9 +5,9 @@ export interface StdioStreams {
5
5
  output: Writable;
6
6
  }
7
7
  /**
8
- * Run the transport until `streams.input` ends (the client closed its pipe —
9
- * MCP stdio shutdown) or `streams.output` errors (EPIPE: the client died
10
- * without closing stdin). Resolves void; the caller runs shutdown hooks and
11
- * exits.
8
+ * Run the transport until `streams.input` ends or errors (the client closed
9
+ * or lost its pipe — MCP stdio shutdown) or `streams.output` errors/closes
10
+ * (EPIPE: the client died without closing stdin). Resolves void; the caller
11
+ * runs shutdown hooks and exits.
12
12
  */
13
13
  export declare function runStdioTransport(core: ProxyCore, streams?: StdioStreams): Promise<void>;
@@ -20,35 +20,47 @@
20
20
  * - No status codes, no headers: upstream's JSON-RPC body is the message;
21
21
  * a JSON-RPC error body relays verbatim, and failures upstream never
22
22
  * answered as JSON-RPC are shaped through toJsonRpcError (same as HTTP).
23
- * - One client, one session: the localKey is the constant "stdio" — the
24
- * core stores the upstream session id on that entry at initialize and
25
- * replays it on every later call. No Mcp-Session-Id plumbing exists here.
26
- * - A parse error answers with id null (JSON-RPC rule for unparseable
27
- * input) rather than the HTTP hop's -1: there is no upstream shape to
28
- * stay byte-compatible with on this transport.
23
+ * A notification whose upstream hop fails writes NOTHING — JSON-RPC forbids
24
+ * replying to notifications, and an id-null error line would be protocol
25
+ * garbage to an id-dispatching client; the failure is logged to stderr.
26
+ * - One client, one session: the localKey is the empty string — the same
27
+ * session-less shape the HTTP hop uses — so before initialize bridges the
28
+ * real upstream session id, upstream.post omits Mcp-Session-Id entirely and
29
+ * no adapter-invented id can reach the wire. While an initialize round-trip
30
+ * is outstanding, further lines are queued (JSON-RPC pipelining is legal,
31
+ * and the MCP lifecycle even allows pings in this window) and dispatched in
32
+ * arrival order once it settles, so they ride the bridged upstream session.
33
+ * - A parse error answers with id null (JSON-RPC rule for unparseable input)
34
+ * rather than the HTTP hop's -1: there is no upstream shape to stay
35
+ * byte-compatible with on this transport.
29
36
  * - Progress notifications from a streamed tools/call relay to stdout as
30
37
  * ordinary JSON-RPC notification lines (MCP stdio allows server → client
31
- * notifications any time), serialized through the same write queue as
32
- * responses.
38
+ * notifications any time). onEvent returns the write promise so the core's
39
+ * frame loop awaits each relayed frame — stdout backpressure propagates to
40
+ * the upstream stream (the OnEvent contract) and a dead stdout surfaces as
41
+ * LocalWriteError instead of being silently swallowed.
33
42
  *
34
43
  * Writes are queued (requests dispatch concurrently — a slow search must not
35
44
  * stall pings — so responses may become ready out of order, which JSON-RPC
36
- * ids disambiguate), and each write respects pipe backpressure: a full
37
- * tools/list line is far larger than PIPE_BUF, so writes must never overlap
38
- * mid-line. stdin end drains the queue before the run promise resolves, so
39
- * the caller's exit never truncates a half-written response.
45
+ * ids disambiguate; the queue serializes the write() calls, so lines can
46
+ * never interleave mid-line) and each write respects pipe backpressure.
47
+ * Shutdown (stdin close, stdin error, stdout error or close) aborts in-flight
48
+ * upstream fetches via core.close, waits for the outstanding handlers so
49
+ * their final (often error) responses still reach a client that is draining
50
+ * stdout, then drains the write queue before resolving — the caller's exit
51
+ * never truncates a half-written line.
40
52
  */
41
53
  import { createInterface } from "node:readline";
42
54
  import { ProxyCoreError, toJsonRpcError } from "../core/errors.js";
43
55
  import { requestIdOf } from "../core/proxy-core.js";
44
56
  import { log } from "../log.js";
45
- /** stdio serves exactly one client, so the local session key is a constant. */
46
- const SESSION_KEY = "stdio";
57
+ /** stdio serves exactly one client; "" matches the HTTP hop's session-less shape. */
58
+ const SESSION_KEY = "";
47
59
  /**
48
- * Run the transport until `streams.input` ends (the client closed its pipe —
49
- * MCP stdio shutdown) or `streams.output` errors (EPIPE: the client died
50
- * without closing stdin). Resolves void; the caller runs shutdown hooks and
51
- * exits.
60
+ * Run the transport until `streams.input` ends or errors (the client closed
61
+ * or lost its pipe — MCP stdio shutdown) or `streams.output` errors/closes
62
+ * (EPIPE: the client died without closing stdin). Resolves void; the caller
63
+ * runs shutdown hooks and exits.
52
64
  */
53
65
  export function runStdioTransport(core, streams = { input: process.stdin, output: process.stdout }) {
54
66
  let resolveRun;
@@ -57,46 +69,61 @@ export function runStdioTransport(core, streams = { input: process.stdin, output
57
69
  });
58
70
  let writeChain = Promise.resolve();
59
71
  let finished = false;
72
+ let inflight = 0;
73
+ let inflightSettled = null;
74
+ let initGate = null;
75
+ const gatedLines = [];
76
+ /**
77
+ * Chain a write onto the queue and hand the promise back — onEvent returns
78
+ * it so upstream SSE frames are relayed one awaited write at a time.
79
+ */
80
+ const enqueue = (message) => {
81
+ const write = writeChain.then(() => writeLine(streams.output, message));
82
+ // Keep the queue alive after a failed write (dying client); `write`
83
+ // itself still rejects, preserving the LocalWriteError contract.
84
+ writeChain = write.then(() => undefined, () => undefined);
85
+ return write;
86
+ };
87
+ /** Fire-and-forget enqueue for responses; a failed stdout write is logged. */
88
+ const emit = (message) => {
89
+ void enqueue(message).catch((err) => {
90
+ log.warn(`writing to stdout failed: ${err instanceof Error ? err.message : String(err)}`);
91
+ });
92
+ };
60
93
  const finish = () => {
61
94
  if (finished)
62
95
  return;
63
96
  finished = true;
64
97
  input.close();
65
- // Drain pending writes before resolving: the caller exits right after,
66
- // and process.exit would truncate an in-flight line.
67
- void writeChain.finally(() => resolveRun());
98
+ void (async () => {
99
+ // Abort in-flight upstream fetches for this session so the handlers
100
+ // below settle promptly (idempotent — the caller's shutdown hooks run
101
+ // core.closeAll() on top, covering the rest of the session store).
102
+ core.close(SESSION_KEY);
103
+ // Let the outstanding handlers land their final responses before the
104
+ // drain below captures the queue, so a client still draining stdout
105
+ // receives them instead of a truncated stream.
106
+ if (inflight > 0) {
107
+ await new Promise((resolve) => {
108
+ inflightSettled = resolve;
109
+ });
110
+ }
111
+ await writeChain;
112
+ resolveRun();
113
+ })();
68
114
  };
69
- const enqueue = (message) => {
70
- writeChain = writeChain
71
- .then(() => writeLine(streams.output, message))
72
- .catch((err) => {
73
- // A failed stdout write means the local client is dying (EPIPE);
74
- // the output 'error' handler finishes the transport.
75
- log.warn(`writing to stdout failed: ${err instanceof Error ? err.message : String(err)}`);
76
- });
115
+ const settleInflight = () => {
116
+ inflight -= 1;
117
+ if (inflight === 0 && inflightSettled !== null) {
118
+ const settle = inflightSettled;
119
+ inflightSettled = null;
120
+ settle();
121
+ }
77
122
  };
78
123
  // Relayed SSE progress frames (free tools never stream today, but the
79
124
  // relay must exist so the stdio path can never orphan upstream progress).
80
- const onEvent = (event) => {
81
- enqueue(event);
82
- };
83
- async function handleLine(line) {
84
- const trimmed = line.trim();
85
- if (trimmed === "")
86
- return;
87
- let message;
88
- try {
89
- message = JSON.parse(trimmed);
90
- }
91
- catch {
92
- // Unparseable input: the one message answered without forwarding.
93
- enqueue({
94
- jsonrpc: "2.0",
95
- error: { code: -32700, message: "Parse error: Invalid JSON" },
96
- id: null,
97
- });
98
- return;
99
- }
125
+ const onEvent = (event) => enqueue(event);
126
+ async function routeMessage(message) {
100
127
  try {
101
128
  if (isNotification(message)) {
102
129
  await core.notify(SESSION_KEY, message);
@@ -105,19 +132,19 @@ export function runStdioTransport(core, streams = { input: process.stdin, output
105
132
  const method = methodOf(message);
106
133
  if (method === "initialize") {
107
134
  const response = await core.initialize(SESSION_KEY, message, protocolVersionOf(message));
108
- enqueue(response.body);
135
+ emit(response.body);
109
136
  return;
110
137
  }
111
138
  if (method === "tools/call") {
112
139
  const response = await core.forwardStream(SESSION_KEY, message, onEvent);
113
- enqueue(response.body);
140
+ emit(response.body);
114
141
  return;
115
142
  }
116
143
  // Everything else — ping, tools/list, resources/*, client responses to
117
144
  // (never-initiated) server requests, unknown methods, and batch arrays —
118
145
  // forwards generically, byte-for-byte the HTTP adapter's fallthrough.
119
146
  const response = await core.forward(SESSION_KEY, message);
120
- enqueue(response.body);
147
+ emit(response.body);
121
148
  }
122
149
  catch (err) {
123
150
  // Same classification as the HTTP adapter: classified core errors log
@@ -129,18 +156,81 @@ export function runStdioTransport(core, streams = { input: process.stdin, output
129
156
  else {
130
157
  log.error(`proxy bug (client got a generic InternalError): ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
131
158
  }
132
- enqueue(toJsonRpcError(err, requestIdOf(message)).body);
159
+ // A notification has no response channel — JSON-RPC forbids replying
160
+ // to one, so its upstream failure only logs (see module comment).
161
+ if (!isNotification(message)) {
162
+ emit(toJsonRpcError(err, requestIdOf(message)).body);
163
+ }
164
+ }
165
+ }
166
+ async function dispatchMessage(message) {
167
+ inflight += 1;
168
+ try {
169
+ await routeMessage(message);
170
+ }
171
+ finally {
172
+ settleInflight();
173
+ }
174
+ }
175
+ function handleLine(line) {
176
+ if (finished)
177
+ return;
178
+ const trimmed = line.trim();
179
+ if (trimmed === "")
180
+ return;
181
+ if (initGate !== null) {
182
+ // Pipelined behind an in-flight initialize: queued, dispatched in
183
+ // arrival order once it settles (see module comment).
184
+ gatedLines.push(trimmed);
185
+ return;
186
+ }
187
+ let message;
188
+ try {
189
+ message = JSON.parse(trimmed);
190
+ }
191
+ catch {
192
+ // Unparseable input: the one message answered without forwarding.
193
+ emit({
194
+ jsonrpc: "2.0",
195
+ error: { code: -32700, message: "Parse error: Invalid JSON" },
196
+ id: null,
197
+ });
198
+ return;
133
199
  }
200
+ if (!isNotification(message) && methodOf(message) === "initialize") {
201
+ // Install the gate before any await so lines arriving during the
202
+ // round-trip hit the queue above, then flush it in order.
203
+ const gate = dispatchMessage(message);
204
+ initGate = gate;
205
+ void gate.finally(() => {
206
+ initGate = null;
207
+ if (!finished) {
208
+ for (const queued of gatedLines.splice(0))
209
+ handleLine(queued);
210
+ }
211
+ });
212
+ return;
213
+ }
214
+ void dispatchMessage(message);
134
215
  }
135
216
  const input = createInterface({ input: streams.input });
136
217
  input.on("line", (line) => {
137
- void handleLine(line);
218
+ handleLine(line);
219
+ });
220
+ // readline re-emits an input-stream error here; without this listener the
221
+ // 'error' event is unhandled (crash, hooks never run) and 'close' never
222
+ // fires afterwards — hence the explicit finish().
223
+ input.on("error", (err) => {
224
+ log.warn(`stdin error: ${err instanceof Error ? err.message : String(err)}`);
225
+ finish();
138
226
  });
139
227
  input.on("close", finish);
140
228
  streams.output.on("error", (err) => {
141
229
  log.warn(`stdout error (client went away?): ${err instanceof Error ? err.message : String(err)}`);
142
230
  finish();
143
231
  });
232
+ // stdout destroyed without an error event (silent destroy): still shut down.
233
+ streams.output.on("close", finish);
144
234
  return run;
145
235
  }
146
236
  /** One JSON-RPC message per line; resolves on kernel-buffer flush or drain. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinyfish-mcp-lite",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Fork of @tiny-fish/mcp exposing only the free TinyFish tools (search, fetch_content) — local reverse proxy to agent.tinyfish.ai/mcp",
5
5
  "mcpName": "io.github.ByronFinn/tinyfish-mcp-lite",
6
6
  "repository": {