tinyfish-mcp-lite 0.1.0 → 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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # @tiny-fish/mcp
1
+ # tinyfish-mcp-lite
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/@tiny-fish/mcp)](https://www.npmjs.com/package/@tiny-fish/mcp)
3
+ [![npm version](https://img.shields.io/npm/v/tinyfish-mcp-lite)](https://www.npmjs.com/package/tinyfish-mcp-lite)
4
4
 
5
5
  > **⚠️ You are on `tinyfish-mcp-lite`** — a fork that exposes **only the two
6
6
  > free hosted tools** (`search`, `fetch_content`) and hides the 26 paid ones,
@@ -51,31 +51,59 @@ this fork too — with exactly these differences:
51
51
 
52
52
  ### Install (this fork)
53
53
 
54
- The npm package name below belongs to **upstream** — installing it gets the
55
- unfiltered server. To use this fork, install it from GitHub (requires
56
- Node.js >= 22):
54
+ The fork ships on npm as **`tinyfish-mcp-lite`** (requires Node.js >= 22).
55
+ Two transports are available:
56
+
57
+ **stdio — one command, the client manages the process.** Most clients that
58
+ launch MCP servers as commands (Claude Code with a `command` entry, Cursor,
59
+ Zed, many launcher UIs) want this:
57
60
 
58
61
  ```sh
59
- # one-liner (the fork's prepare script builds dist/ during install)
60
- npm install -g github:ByronFinn/tinyfish-mcp-lite
61
- TINYFISH_API_KEY=tf_... tinyfish-mcp
62
+ # the client spawns it; add TINYFISH_API_KEY to the command's environment
63
+ npx -y tinyfish-mcp-lite --stdio
64
+
65
+ # or with the global install (`npm i -g tinyfish-mcp-lite`):
66
+ tinyfish-mcp --stdio
67
+ ```
68
+
69
+ In a client config that takes a command + env vars, that is:
62
70
 
71
+ ```json
72
+ {
73
+ "command": "npx",
74
+ "args": ["-y", "tinyfish-mcp-lite", "--stdio"],
75
+ "env": { "TINYFISH_API_KEY": "tf_..." }
76
+ }
77
+ ```
78
+
79
+ **HTTP — one shared server for several clients.** Start it once (terminal,
80
+ systemd, pm2) and point every client at the same URL:
81
+
82
+ ```sh
83
+ TINYFISH_API_KEY=tf_... npx tinyfish-mcp-lite # no --stdio flag
84
+ ```
85
+
86
+ ```sh
63
87
  # or from a clone
64
88
  git clone https://github.com/ByronFinn/tinyfish-mcp-lite.git
65
89
  cd tinyfish-mcp-lite
66
90
  npm ci && npm run build
67
- TINYFISH_API_KEY=tf_... node dist/index.js
91
+ TINYFISH_API_KEY=tf_... node dist/index.js # HTTP
92
+ # TINYFISH_API_KEY=tf_... node dist/index.js --stdio # stdio
68
93
  ```
69
94
 
70
- > **npm ≥ 11.17 with the install-script policy enabled** (an `allow-scripts`
71
- > entry in your `~/.npmrc`): git-based installs (`npx github:…`,
72
- > `npm install -g github:…`) fail with `EALLOWSCRIPTS` — a known npm quirk
73
- > where the policy reaches the inner git-dep build via the environment. Use
74
- > the clone route instead, then `npm install -g .` from the clone if you
75
- > want the `tinyfish-mcp` command on your PATH.
95
+ Both transports expose the same filtered proxy; stdio just has no port and
96
+ no manual startup — closing the client's pipe shuts the process down.
97
+
98
+ > **Note on git-based installs** (`npm i -g github:ByronFinn/tinyfish-mcp-lite`):
99
+ > npm ≥ 11.17 with the install-script policy enabled (an `allow-scripts`
100
+ > entry in your `~/.npmrc`) rejects them with `EALLOWSCRIPTS` — the policy
101
+ > leaks into the inner git-dep build via the environment. Use the npm or
102
+ > clone routes instead; registry installs (`npx tinyfish-mcp-lite`) are
103
+ > unaffected.
76
104
 
77
- Then point your MCP client at `http://127.0.0.1:3711/mcp` exactly as
78
- described in [Client configuration](#client-configuration) below.
105
+ For the HTTP transport, point your MCP client at `http://127.0.0.1:3711/mcp`
106
+ as described in [Client configuration](#client-configuration) below.
79
107
 
80
108
  Why a fork at all: the hosted server's paid tools (automation, browsers,
81
109
  monitors…) are unusable without wallet funds, but their schemas still land
package/dist/config.js CHANGED
@@ -6,9 +6,7 @@ export const API_KEY_GUIDANCE = "Set TINYFISH_API_KEY — get a key at https://a
6
6
  export class ConfigError extends Error {
7
7
  }
8
8
  const envSchema = z.object({
9
- TINYFISH_API_KEY: z
10
- .string({ error: API_KEY_GUIDANCE })
11
- .min(1, { error: API_KEY_GUIDANCE }),
9
+ TINYFISH_API_KEY: z.string({ error: API_KEY_GUIDANCE }).min(1, { error: API_KEY_GUIDANCE }),
12
10
  PORT: z
13
11
  .string()
14
12
  .optional()
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import { randomUUID } from "node:crypto";
26
26
  import { JsonRpcErrorCodes, LocalWriteError, ProxyCoreError, toJsonRpcError, toStreamErrorFrame, } from "../core/errors.js";
27
- import { requestIdOf } from "../core/proxy-core.js";
27
+ import { requestIdOf, } from "../core/proxy-core.js";
28
28
  import { log } from "../log.js";
29
29
  export function createMcpAdapter(core) {
30
30
  return async (req, res) => {
package/dist/index.js CHANGED
@@ -33,6 +33,31 @@ async function main() {
33
33
  // answers tools/call for them locally — paid tools are never advertised
34
34
  // and never invoked upstream.
35
35
  const core = withToolFilter(createProxyCore({ upstreamUrl: config.upstreamUrl, apiKey: config.apiKey }), toolPolicy);
36
+ // FORK(lite): stdio transport — spawn-and-forget mode for clients that
37
+ // launch their MCP servers as commands (`tinyfish-mcp --stdio`, or
38
+ // `npx -y tinyfish-mcp-lite --stdio`). Same core, same filter; the client
39
+ // owns the lifecycle (stdin closed ⇒ exit), so no PORT and no long-running
40
+ // server. See src/stdio/adapter.ts and FORK.md.
41
+ if (process.argv.includes("--stdio")) {
42
+ const { runStdioTransport } = await import("./stdio/adapter.js");
43
+ log.info(`stdio transport — upstream ${config.upstreamUrl} — v${VERSION}`);
44
+ if (toolPolicy.mode === "allow") {
45
+ log.info(`tool filter: exposing [${[...toolPolicy.allowed].join(", ")}] — set TINYFISH_TOOLS=* to expose every upstream tool`);
46
+ }
47
+ else {
48
+ log.info("tool filter: off (TINYFISH_TOOLS=*) — exposing every upstream tool");
49
+ }
50
+ 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
+ }
59
+ process.exit(0);
60
+ }
36
61
  const handler = createAppHandler(createMcpAdapter(core));
37
62
  let server;
38
63
  try {
@@ -0,0 +1,13 @@
1
+ import type { Readable, Writable } from "node:stream";
2
+ import { type ProxyCore } from "../core/proxy-core.js";
3
+ export interface StdioStreams {
4
+ input: Readable;
5
+ output: Writable;
6
+ }
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.
12
+ */
13
+ export declare function runStdioTransport(core: ProxyCore, streams?: StdioStreams): Promise<void>;
@@ -0,0 +1,177 @@
1
+ /**
2
+ * FORK ADDITION (tinyfish-mcp-lite) — this module does not exist upstream.
3
+ *
4
+ * stdio transport: spawn-and-forget mode for MCP clients that launch their
5
+ * servers as commands. The client owns the process lifecycle (stdin closed ⇒
6
+ * shutdown), no port to manage, no server to start by hand. Newline-delimited
7
+ * JSON-RPC on stdin/stdout per the MCP stdio spec; every log goes to stderr
8
+ * (log.ts) so stdout stays protocol-pure — log.ts documents exactly this
9
+ * contract ("stdout stays clean for a future stdio transport").
10
+ *
11
+ * Routing mirrors the HTTP adapter's route() one-to-one (notifications →
12
+ * notify BEFORE any tools/call check; initialize → initialize; tools/call →
13
+ * forwardStream; everything else, including forbidden batch arrays and
14
+ * shapeless bodies, → forward), so the withToolFilter decorator covers both
15
+ * transports identically: tools/list filtered, hidden tools/call answered
16
+ * locally, notification-shaped calls dropped, batches rejected — see
17
+ * tests/stdio.test.ts and tests/tool-filter.test.ts.
18
+ *
19
+ * Differences from the HTTP hop that are intrinsic to stdio:
20
+ * - No status codes, no headers: upstream's JSON-RPC body is the message;
21
+ * a JSON-RPC error body relays verbatim, and failures upstream never
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.
29
+ * - Progress notifications from a streamed tools/call relay to stdout as
30
+ * ordinary JSON-RPC notification lines (MCP stdio allows server → client
31
+ * notifications any time), serialized through the same write queue as
32
+ * responses.
33
+ *
34
+ * Writes are queued (requests dispatch concurrently — a slow search must not
35
+ * 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.
40
+ */
41
+ import { createInterface } from "node:readline";
42
+ import { ProxyCoreError, toJsonRpcError } from "../core/errors.js";
43
+ import { requestIdOf } from "../core/proxy-core.js";
44
+ import { log } from "../log.js";
45
+ /** stdio serves exactly one client, so the local session key is a constant. */
46
+ const SESSION_KEY = "stdio";
47
+ /**
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.
52
+ */
53
+ export function runStdioTransport(core, streams = { input: process.stdin, output: process.stdout }) {
54
+ let resolveRun;
55
+ const run = new Promise((resolve) => {
56
+ resolveRun = resolve;
57
+ });
58
+ let writeChain = Promise.resolve();
59
+ let finished = false;
60
+ const finish = () => {
61
+ if (finished)
62
+ return;
63
+ finished = true;
64
+ 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());
68
+ };
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
+ });
77
+ };
78
+ // Relayed SSE progress frames (free tools never stream today, but the
79
+ // 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
+ }
100
+ try {
101
+ if (isNotification(message)) {
102
+ await core.notify(SESSION_KEY, message);
103
+ return;
104
+ }
105
+ const method = methodOf(message);
106
+ if (method === "initialize") {
107
+ const response = await core.initialize(SESSION_KEY, message, protocolVersionOf(message));
108
+ enqueue(response.body);
109
+ return;
110
+ }
111
+ if (method === "tools/call") {
112
+ const response = await core.forwardStream(SESSION_KEY, message, onEvent);
113
+ enqueue(response.body);
114
+ return;
115
+ }
116
+ // Everything else — ping, tools/list, resources/*, client responses to
117
+ // (never-initiated) server requests, unknown methods, and batch arrays —
118
+ // forwards generically, byte-for-byte the HTTP adapter's fallthrough.
119
+ const response = await core.forward(SESSION_KEY, message);
120
+ enqueue(response.body);
121
+ }
122
+ catch (err) {
123
+ // Same classification as the HTTP adapter: classified core errors log
124
+ // their message; anything else is a proxy bug (full stack, generic
125
+ // InternalError to the client).
126
+ if (err instanceof ProxyCoreError) {
127
+ log.warn(err.message);
128
+ }
129
+ else {
130
+ log.error(`proxy bug (client got a generic InternalError): ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
131
+ }
132
+ enqueue(toJsonRpcError(err, requestIdOf(message)).body);
133
+ }
134
+ }
135
+ const input = createInterface({ input: streams.input });
136
+ input.on("line", (line) => {
137
+ void handleLine(line);
138
+ });
139
+ input.on("close", finish);
140
+ streams.output.on("error", (err) => {
141
+ log.warn(`stdout error (client went away?): ${err instanceof Error ? err.message : String(err)}`);
142
+ finish();
143
+ });
144
+ return run;
145
+ }
146
+ /** One JSON-RPC message per line; resolves on kernel-buffer flush or drain. */
147
+ function writeLine(output, message) {
148
+ return new Promise((resolve, reject) => {
149
+ const ok = output.write(`${JSON.stringify(message)}\n`, (err) => err ? reject(err) : resolve());
150
+ if (!ok)
151
+ output.once("drain", () => resolve());
152
+ });
153
+ }
154
+ /** JSON-RPC notification: an object with a method and no id key (upstream's rule). */
155
+ function isNotification(message) {
156
+ return (typeof message === "object" &&
157
+ message !== null &&
158
+ !Array.isArray(message) &&
159
+ typeof message.method === "string" &&
160
+ !("id" in message));
161
+ }
162
+ function methodOf(message) {
163
+ if (typeof message !== "object" || message === null)
164
+ return undefined;
165
+ const method = message.method;
166
+ return typeof method === "string" ? method : undefined;
167
+ }
168
+ /** `initialize.params.protocolVersion`, when the client sent one. */
169
+ function protocolVersionOf(message) {
170
+ if (typeof message !== "object" || message === null)
171
+ return undefined;
172
+ const params = message.params;
173
+ if (typeof params !== "object" || params === null)
174
+ return undefined;
175
+ const version = params.protocolVersion;
176
+ return typeof version === "string" ? version : undefined;
177
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tinyfish-mcp-lite",
3
- "version": "0.1.0",
4
- "description": "Fork of @tiny-fish/mcp exposing only the free TinyFish tools (search, fetch_content) \u2014 local reverse proxy to agent.tinyfish.ai/mcp",
3
+ "version": "0.2.0",
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": {
7
7
  "type": "git",