mcp-client-general 0.0.1 → 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Antal Mátyás Nagy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,215 @@
1
+ # mcp-client-general
2
+
3
+ General, streaming-friendly MCP (Model Context Protocol) client for Node.js.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/mcp-client-general.svg)](https://npmjs.com/package/mcp-client-general)
6
+ ![npm downloads](https://img.shields.io/npm/dm/mcp-client-general.svg)
7
+ ![license](https://img.shields.io/badge/license-MIT-blue.svg)
8
+ ![node version](https://img.shields.io/node/v/mcp-client-general.svg)
9
+
10
+ - Spawns an MCP-compatible server as a child process
11
+ - Speaks JSON-RPC 2.0 over stdin / stdout
12
+ - Ignores fragile `Content-Length` headers and uses a robust JSON object scanner
13
+ - Supports multiple requests per process (piped line-by-line)
14
+ - CLI + programmatic TypeScript API
15
+
16
+ > This package is designed to be a generic, open-source MCP client. It works with any MCP-compliant server implementation, including the reference mcp-server-general package.
17
+
18
+ ---
19
+
20
+ ## Features
21
+
22
+ - **Zero-config profiles** – run built-in MCP stacks without manual wiring
23
+ - **Child process orchestration** – monitors stderr, exit, error
24
+ - **Handshake detection** – first valid JSON object = handshake
25
+ - **Framing-agnostic parsing** – safely ignores `Content-Length`
26
+ - **JSON-RPC 2.0 support** – id-based pending map, timeouts
27
+ - **CLI & Library** – usable from terminal or TypeScript
28
+
29
+ ---
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ npm install -g mcp-client-general
35
+ # or
36
+ npm install mcp-client-general --save-dev
37
+ ```
38
+
39
+ ---
40
+
41
+ ## CLI Usage
42
+
43
+ ### Run an MCP server
44
+ ```bash
45
+ mcp run "node dist/server.js"
46
+ ```
47
+
48
+ ### Send a single JSON-RPC request
49
+ ```bash
50
+ echo '{"jsonrpc":"2.0","id":1,"method":"providers.list"}' \
51
+ | mcp run "node ../mcp-server-general/dist/server.js"
52
+ ```
53
+
54
+ ### Example output
55
+ ```json
56
+ {
57
+ "jsonrpc": "2.0",
58
+ "id": 1,
59
+ "result": {
60
+ "providers": [
61
+ {
62
+ "provider": "openai",
63
+ "model": "gpt-4o-mini"
64
+ }
65
+ ]
66
+ }
67
+ }
68
+ ```
69
+
70
+ ---
71
+
72
+ ### Multiple requests in a single run
73
+ ```bash
74
+ printf '%s\n%s\n' \
75
+ '{"jsonrpc":"2.0","id":1,"method":"providers.list"}' \
76
+ '{"jsonrpc":"2.0","id":2,"method":"steps.list"}' \
77
+ | mcp run "node ../mcp-server-general/dist/server.js"
78
+ ```
79
+
80
+ ### Example output
81
+ ```json
82
+ {
83
+ "jsonrpc": "2.0",
84
+ "id": 1,
85
+ "result": { "providers": [ /* ... */ ] }
86
+ }
87
+ {
88
+ "jsonrpc": "2.0",
89
+ "id": 2,
90
+ "result": { "steps": [ /* ... */ ] }
91
+ }
92
+ ```
93
+
94
+ ---
95
+
96
+ ### Error handling (JSON-RPC native)
97
+
98
+ ```bash
99
+ echo '{"jsonrpc":"2.0","id":3,"method":"scoring.schema"}' \
100
+ | mcp run "node ../mcp-server-general/dist/server.js"
101
+ ```
102
+
103
+ ```json
104
+ {
105
+ "jsonrpc": "2.0",
106
+ "id": 3,
107
+ "error": {
108
+ "code": -32601,
109
+ "message": "Method not found: scoring.schema"
110
+ }
111
+ }
112
+ ```
113
+
114
+ ---
115
+
116
+ ## Programmatic Usage (TypeScript)
117
+
118
+ ```ts
119
+ import { MCPProcess } from "mcp-client-general";
120
+ import type { JSONRPCRequest } from "mcp-client-general/jsonrpc";
121
+
122
+ async function main() {
123
+ const proc = new MCPProcess({
124
+ command: "node",
125
+ args: ["../mcp-server-general/dist/server.js"],
126
+ startupTimeoutMs: 4000,
127
+ shutdownTimeoutMs: 3000
128
+ });
129
+
130
+ proc.on("stderr", (msg) => process.stderr.write(String(msg)));
131
+
132
+ await proc.start();
133
+
134
+ const req: JSONRPCRequest = {
135
+ jsonrpc: "2.0",
136
+ id: 1,
137
+ method: "providers.list",
138
+ params: {}
139
+ };
140
+
141
+ const response = await proc.send(req);
142
+ console.log(JSON.stringify(response, null, 2));
143
+
144
+ await proc.close();
145
+ }
146
+
147
+ main().catch((err) => {
148
+ console.error(err);
149
+ process.exit(1);
150
+ });
151
+ ```
152
+
153
+ ---
154
+
155
+ ## Design Notes
156
+
157
+ ### Handshake detection
158
+ The first valid JSON object received from stdout is treated as the handshake.
159
+ If the server delays or prints logs first, the client still proceeds safely.
160
+
161
+ ### Framing strategy
162
+ Many servers emit:
163
+
164
+ ```
165
+ Content-Length: 2888\r\n\r\n{ ... JSON ... }
166
+ ```
167
+
168
+ But `Content-Length` is often wrong or mixed with logs.
169
+
170
+ This client instead:
171
+
172
+ - **ignores Content-Length**
173
+ - uses a **streaming JSON scanner**:
174
+ - finds `{`
175
+ - tracks nested `{` / `}`
176
+ - handles JSON strings & escapes
177
+ - extracts full JSON frames
178
+
179
+ Works even with imperfect/experimental MCP servers.
180
+
181
+ ### Pending RPC requests
182
+ - Requests stored in `Map<id, PendingEntry>`
183
+ - Responses resolve Promises
184
+ - Timeouts reject automatically
185
+
186
+ ---
187
+
188
+ ## Environment Variables
189
+
190
+ Enable verbose debugging:
191
+
192
+ ```bash
193
+ MCP_DEBUG=1 mcp run "node dist/server.js"
194
+ ```
195
+
196
+ Shows:
197
+ - handshake detection
198
+ - scan events
199
+ - JSON parse errors
200
+ - child process exits
201
+ - forwarded stderr
202
+
203
+ ---
204
+
205
+ ## Limitations
206
+
207
+ - Server must output valid JSON frames
208
+ - First JSON object is always treated as handshake
209
+ - JSON-like logs printed before handshake may be misinterpreted
210
+
211
+ ---
212
+
213
+ ## License
214
+
215
+ MIT – see LICENSE.
@@ -0,0 +1,31 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ function hasLocalBin(command) {
4
+ const binPath = path.join(process.cwd(), "node_modules", ".bin", command);
5
+ return fs.existsSync(binPath);
6
+ }
7
+ export async function planExecution(profile) {
8
+ const { command, autoInstall } = profile.server;
9
+ // 1) local node_modules/.bin
10
+ if (hasLocalBin(command)) {
11
+ return {
12
+ command,
13
+ args: [],
14
+ source: "local-bin",
15
+ };
16
+ }
17
+ // 2) npm exec (preferred auto-install path)
18
+ if (autoInstall) {
19
+ return {
20
+ command: "npm",
21
+ args: ["exec", command],
22
+ source: "npm-exec",
23
+ };
24
+ }
25
+ // 3) fallback npx
26
+ return {
27
+ command: "npx",
28
+ args: [command],
29
+ source: "npx",
30
+ };
31
+ }
package/dist/index.js ADDED
@@ -0,0 +1,393 @@
1
+ #!/usr/bin/env node
2
+ // src/index.ts
3
+ import { MCPProcess } from "./runner.js";
4
+ import { createRequest } from "./jsonrpc.js";
5
+ import { getProfile, listProfiles } from "./profiles/index.js";
6
+ import { planExecution } from "./execution/planner.js";
7
+ function printUsage() {
8
+ console.error(`
9
+ General MCP Client
10
+ ------------------
11
+
12
+ Usage:
13
+ mcp run "<serverCommand>"
14
+ mcp run --profile web-dev
15
+ mcp run --profile <profile>
16
+ mcp list profiles
17
+ mcp list profiles --json
18
+ mcp describe profile web-dev
19
+ mcp describe profile <profile>
20
+
21
+ Examples:
22
+ # Run against any MCP-compliant server
23
+ echo '{"jsonrpc":"2.0","id":1,"method":"providers.list"}' |
24
+ mcp run "node dist/server.js"
25
+
26
+ # Run with a built-in profile
27
+ echo '{"jsonrpc":"2.0","id":1,"method":"providers.list"}' |
28
+ mcp run --profile web-dev
29
+
30
+ Environment Variables:
31
+ MCP_DEBUG=1 Enables verbose debug output (framing, handshake, events)
32
+ MCP_PROFILE_SERVER Override profile server command (advanced)
33
+
34
+ Debug example:
35
+ MCP_DEBUG=1 mcp run "node dist/server.js"
36
+
37
+ Compatible with:
38
+ - Any MCP-compliant server
39
+ - The General MCP Server (reference implementation)
40
+
41
+ More documentation:
42
+ https://dapo.run/mcp
43
+ `);
44
+ }
45
+ async function readStdin() {
46
+ return new Promise((resolve) => {
47
+ let data = "";
48
+ process.stdin.setEncoding("utf8");
49
+ process.stdin.on("data", (chunk) => {
50
+ data += chunk;
51
+ });
52
+ process.stdin.on("end", () => resolve(data));
53
+ });
54
+ }
55
+ async function main() {
56
+ const args = process.argv.slice(2);
57
+ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
58
+ printUsage();
59
+ return;
60
+ }
61
+ let profileId;
62
+ // very simple flag parsing (no dependency)
63
+ for (let i = 0; i < args.length; i++) {
64
+ if (args[i] === "--profile") {
65
+ if (!args[i + 1]) {
66
+ console.error("--profile requires a value");
67
+ process.exitCode = 1;
68
+ return;
69
+ }
70
+ profileId = args[i + 1];
71
+ args.splice(i, 2);
72
+ break;
73
+ }
74
+ }
75
+ let dryRun = false;
76
+ let jsonOutput = false;
77
+ let explain = false;
78
+ for (let i = 0; i < args.length; i++) {
79
+ if (args[i] === "--dry-run") {
80
+ dryRun = true;
81
+ args.splice(i, 1);
82
+ i--;
83
+ continue;
84
+ }
85
+ if (args[i] === "--json") {
86
+ jsonOutput = true;
87
+ args.splice(i, 1);
88
+ i--;
89
+ continue;
90
+ }
91
+ if (args[i] === "--explain") {
92
+ explain = true;
93
+ args.splice(i, 1);
94
+ i--;
95
+ continue;
96
+ }
97
+ }
98
+ const explainLines = [];
99
+ // ---------------------------------------
100
+ // COMMAND: list profiles
101
+ // ---------------------------------------
102
+ if (args[0] === "list") {
103
+ if (args[1] === "profiles") {
104
+ const profiles = listProfiles();
105
+ if (jsonOutput) {
106
+ console.log(JSON.stringify(profiles, null, 2));
107
+ return;
108
+ }
109
+ if (profiles.length === 0) {
110
+ console.log("No profiles available.");
111
+ return;
112
+ }
113
+ console.log("Available profiles:\n");
114
+ for (const p of profiles) {
115
+ console.log(` ${p.id.padEnd(8)} ${p.description}`);
116
+ }
117
+ return;
118
+ }
119
+ console.error(`Unknown list target: ${args[1] ?? ""}
120
+
121
+ Usage:
122
+ mcp list profiles
123
+ mcp list profiles --json
124
+ `);
125
+ process.exitCode = 1;
126
+ return;
127
+ }
128
+ // ---------------------------------------
129
+ // COMMAND: describe profile
130
+ // ---------------------------------------
131
+ if (args[0] === "describe") {
132
+ if (args[1] === "profile") {
133
+ const profileId = args[2];
134
+ if (!profileId) {
135
+ console.error(`Missing profile id.
136
+
137
+ Usage:
138
+ mcp describe profile <profile>
139
+ `);
140
+ process.exitCode = 1;
141
+ return;
142
+ }
143
+ const profile = getProfile(profileId);
144
+ if (!profile) {
145
+ console.error(`Unknown profile: ${profileId}`);
146
+ process.exitCode = 1;
147
+ return;
148
+ }
149
+ if (jsonOutput) {
150
+ console.log(JSON.stringify(profile, null, 2));
151
+ return;
152
+ }
153
+ console.log(`Profile: ${profile.id}\n`);
154
+ console.log("Description:");
155
+ console.log(` ${profile.description}\n`);
156
+ console.log("Server:");
157
+ console.log(` command: ${profile.server.command}`);
158
+ console.log(` kind: ${profile.server.kind}`);
159
+ if (profile.server.autoInstall) {
160
+ console.log(` auto-install: yes`);
161
+ }
162
+ if (profile.server.package) {
163
+ console.log(` package: ${profile.server.package}`);
164
+ }
165
+ console.log("");
166
+ if (profile.ui?.enabled) {
167
+ console.log("UI:");
168
+ console.log(" enabled: yes");
169
+ if (profile.ui.hint) {
170
+ console.log(` hint: ${profile.ui.hint}`);
171
+ }
172
+ console.log("");
173
+ }
174
+ if (profile.plugins.length > 0) {
175
+ console.log("Plugins:");
176
+ for (const p of profile.plugins) {
177
+ console.log(` - ${p.name} (${p.entry})`);
178
+ }
179
+ }
180
+ else {
181
+ console.log("Plugins:");
182
+ console.log(" (none)");
183
+ }
184
+ if (profile.notes && profile.notes.length > 0) {
185
+ console.log("\nNotes:");
186
+ for (const note of profile.notes) {
187
+ console.log(` - ${note}`);
188
+ }
189
+ }
190
+ return;
191
+ }
192
+ console.error(`Unknown describe target: ${args[1] ?? ""}
193
+
194
+ Usage:
195
+ mcp describe profile <profile>
196
+ `);
197
+ process.exitCode = 1;
198
+ return;
199
+ }
200
+ if (args[0] !== "run") {
201
+ console.error(`Unknown command: ${args[0]}
202
+
203
+ Usage:
204
+ mcp run "node dist/server.js"
205
+ mcp run --profile web-dev
206
+ mcp run --profile <profile>
207
+ mcp list profiles
208
+
209
+ More documentation:
210
+ https://dapo.run/mcp
211
+ `);
212
+ process.exitCode = 1;
213
+ return;
214
+ }
215
+ let execution;
216
+ // 1) If a profile was provided, ALWAYS prefer profile execution.
217
+ if (profileId) {
218
+ const profile = getProfile(profileId);
219
+ explainLines.push(`Mode: profile (${profileId})`);
220
+ if (!profile) {
221
+ console.error(`Unknown profile: ${profileId}`);
222
+ process.exitCode = 1;
223
+ return;
224
+ }
225
+ execution = {
226
+ kind: "profile",
227
+ profile,
228
+ command: process.env.MCP_PROFILE_SERVER ?? profile.server.command,
229
+ };
230
+ }
231
+ else {
232
+ explainLines.push(`Mode: explicit`);
233
+ // 2) Otherwise try to treat the first non-flag token after "run" as explicit command.
234
+ const maybeCmd = args
235
+ .slice(1) // tokens after "run"
236
+ .find((t) => !t.startsWith("-"));
237
+ if (maybeCmd) {
238
+ execution = {
239
+ kind: "explicit",
240
+ command: maybeCmd,
241
+ };
242
+ }
243
+ else {
244
+ console.error(`Missing server command.
245
+
246
+ Examples:
247
+ mcp run "node dist/server.js"
248
+ mcp run --profile web-dev
249
+ mcp run --profile <profile>
250
+
251
+ More documentation:
252
+ https://dapo.run/mcp
253
+ `);
254
+ process.exitCode = 1;
255
+ return;
256
+ }
257
+ }
258
+ const hasProfileServerOverride = execution.kind === "profile" &&
259
+ process.env.MCP_PROFILE_SERVER !== undefined;
260
+ let command;
261
+ let cmdArgs;
262
+ let executionSource;
263
+ if (execution.kind === "profile" &&
264
+ execution.profile.server.kind === "builtin" &&
265
+ !hasProfileServerOverride) {
266
+ explainLines.push("Server kind: builtin");
267
+ explainLines.push("Execution planner: enabled");
268
+ const plan = await planExecution(execution.profile);
269
+ if (plan.source === "local-bin") {
270
+ explainLines.push("Local binary: found");
271
+ }
272
+ else {
273
+ explainLines.push("Local binary: not found");
274
+ explainLines.push("Auto-install: enabled");
275
+ }
276
+ explainLines.push(`Selected execution: ${plan.source} (${[plan.command, ...plan.args].join(" ")})`);
277
+ command = plan.command;
278
+ cmdArgs = plan.args;
279
+ executionSource = plan.source;
280
+ if (process.env.MCP_DEBUG) {
281
+ process.stderr.write(`[CLIENT] Using ${plan.source} execution\n`);
282
+ }
283
+ }
284
+ else {
285
+ explainLines.push("Execution planner: skipped");
286
+ explainLines.push(`Selected execution: explicit (${execution.command})`);
287
+ const cmd = execution.command.split(" ");
288
+ command = cmd[0];
289
+ cmdArgs = cmd.slice(1);
290
+ executionSource = "explicit";
291
+ }
292
+ if (dryRun) {
293
+ if (jsonOutput) {
294
+ console.log(JSON.stringify({
295
+ mode: execution.kind,
296
+ profile: execution.kind === "profile"
297
+ ? execution.profile.id
298
+ : undefined,
299
+ explain: explain ? explainLines : undefined,
300
+ plan: {
301
+ command,
302
+ args: cmdArgs,
303
+ source: executionSource,
304
+ },
305
+ }, null, 2));
306
+ }
307
+ else {
308
+ if (explain && !jsonOutput) {
309
+ console.log("Execution explanation:");
310
+ for (const line of explainLines) {
311
+ console.log(`- ${line}`);
312
+ }
313
+ console.log("");
314
+ }
315
+ console.log("Execution plan:");
316
+ console.log(` resolver: ${executionSource}`);
317
+ console.log(` command: ${[command, ...cmdArgs].join(" ")}`);
318
+ }
319
+ return;
320
+ }
321
+ const proc = new MCPProcess({
322
+ command,
323
+ args: cmdArgs
324
+ });
325
+ // optional debug logs
326
+ proc.on("stderr", (msg) => process.stderr.write(String(msg)));
327
+ if (process.env.MCP_DEBUG) {
328
+ proc.on("exit", ({ code, signal }) => {
329
+ process.stderr.write(`[CLIENT] Child process exit code=${code} signal=${String(signal)}\n`);
330
+ });
331
+ proc.on("error", (err) => {
332
+ process.stderr.write(`[CLIENT] Child process error: ${String(err)}\n`);
333
+ });
334
+ }
335
+ // staring server + handshake
336
+ await proc.start();
337
+ if (process.stdin.isTTY) {
338
+ if (process.env.MCP_DEBUG) {
339
+ process.stderr.write("[CLIENT] No stdin detected (TTY). Server started successfully.\n");
340
+ }
341
+ await proc.close();
342
+ return;
343
+ }
344
+ const stdinData = await readStdin();
345
+ if (!stdinData.trim()) {
346
+ if (process.env.MCP_DEBUG) {
347
+ process.stderr.write("[CLIENT] No stdin data, nothing to send.\n");
348
+ }
349
+ await proc.close();
350
+ return;
351
+ }
352
+ let payloads;
353
+ // 1) trying by whole JSON
354
+ try {
355
+ const parsed = JSON.parse(stdinData);
356
+ payloads = Array.isArray(parsed) ? parsed : [parsed];
357
+ }
358
+ catch {
359
+ // 2) trying by lines
360
+ const lines = stdinData
361
+ .split("\n")
362
+ .map((x) => x.trim())
363
+ .filter((x) => x.length > 0);
364
+ payloads = lines.map((line) => JSON.parse(line));
365
+ }
366
+ try {
367
+ for (const payload of payloads) {
368
+ const base = payload;
369
+ const isFullJsonRpc = base.id !== undefined &&
370
+ typeof base.jsonrpc === "string" &&
371
+ base.jsonrpc.length > 0;
372
+ const req = isFullJsonRpc
373
+ ? base // JSONRPCRequest match
374
+ : createRequest(base.method ?? "", base.params);
375
+ const response = await proc.send(req);
376
+ process.stdout.write(JSON.stringify(response, null, 2) + "\n");
377
+ }
378
+ }
379
+ finally {
380
+ await proc.close();
381
+ }
382
+ }
383
+ main()
384
+ .then(() => {
385
+ // success here
386
+ if (process.exitCode === undefined) {
387
+ process.exitCode = 0;
388
+ }
389
+ })
390
+ .catch((err) => {
391
+ console.error(err instanceof Error ? err.message : String(err));
392
+ process.exitCode = 1;
393
+ });
@@ -0,0 +1,10 @@
1
+ // src/jsonrpc.ts
2
+ let idCounter = 1;
3
+ export function createRequest(method, params) {
4
+ return {
5
+ jsonrpc: "2.0",
6
+ id: idCounter++,
7
+ method,
8
+ params
9
+ };
10
+ }
@@ -0,0 +1,10 @@
1
+ import { webDevProfile } from "./web-dev.profile.js";
2
+ const profiles = new Map([
3
+ [webDevProfile.id, webDevProfile],
4
+ ]);
5
+ export function getProfile(id) {
6
+ return profiles.get(id);
7
+ }
8
+ export function listProfiles() {
9
+ return [...profiles.values()];
10
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ export const webDevProfile = {
2
+ id: "web-dev",
3
+ description: "Zero-config web development MCP stack",
4
+ server: {
5
+ kind: "builtin",
6
+ command: "mcp-server-general",
7
+ autoInstall: true,
8
+ package: "mcp-server-general",
9
+ },
10
+ plugins: [
11
+ {
12
+ name: "web-tools",
13
+ entry: "@mcp/plugin-web-tools",
14
+ },
15
+ ],
16
+ ui: {
17
+ enabled: true,
18
+ hint: "Launches browser-based MCP UI for web development",
19
+ },
20
+ notes: [
21
+ "Automatically installs and runs the reference MCP server",
22
+ "Designed for zero-config onboarding",
23
+ ],
24
+ };
package/dist/prompt.js ADDED
@@ -0,0 +1,32 @@
1
+ import readline from "node:readline";
2
+ import { createRequest } from "./jsonrpc.js";
3
+ export function startPrompt(proc) {
4
+ const rl = readline.createInterface({
5
+ input: process.stdin,
6
+ output: process.stdout,
7
+ historySize: 200
8
+ });
9
+ console.error("MCP Prompt Ready");
10
+ proc.on("rpc-response", (msg) => {
11
+ console.error("RESPONSE:", JSON.stringify(msg, null, 2));
12
+ });
13
+ rl.setPrompt("mcp> ");
14
+ rl.prompt();
15
+ rl.on("line", (line) => {
16
+ const trimmed = line.trim();
17
+ if (trimmed === "exit") {
18
+ rl.close();
19
+ proc.close();
20
+ return;
21
+ }
22
+ try {
23
+ const json = JSON.parse(trimmed);
24
+ const req = createRequest(json.method, json.params);
25
+ proc.send(req);
26
+ }
27
+ catch {
28
+ console.error("Invalid JSON input");
29
+ }
30
+ rl.prompt();
31
+ });
32
+ }
package/dist/runner.js ADDED
@@ -0,0 +1,234 @@
1
+ // src/runner.ts
2
+ import { spawn } from "node:child_process";
3
+ import { EventEmitter, once } from "node:events";
4
+ /**
5
+ * MCPProcess
6
+ * - Spawns the provider server
7
+ * - Handles Content-Length framed JSON RPC
8
+ * - Waits for handshake before allowing requests
9
+ */
10
+ export class MCPProcess extends EventEmitter {
11
+ proc = null;
12
+ opts;
13
+ startupTimeout;
14
+ shutdownTimeout;
15
+ closed = false;
16
+ pending = new Map();
17
+ handshakeEmitted = false;
18
+ constructor(opts) {
19
+ super();
20
+ this.opts = opts;
21
+ this.startupTimeout = opts.startupTimeoutMs ?? 4000;
22
+ this.shutdownTimeout = opts.shutdownTimeoutMs ?? 3000;
23
+ }
24
+ // ---------------------------------------------------------------------
25
+ // START
26
+ // ---------------------------------------------------------------------
27
+ async start() {
28
+ if (this.proc) {
29
+ throw new Error("Process already running.");
30
+ }
31
+ const { command, args = [], cwd, env } = this.opts;
32
+ this.proc = spawn(command, args, {
33
+ cwd,
34
+ env,
35
+ stdio: ["pipe", "pipe", "pipe"]
36
+ });
37
+ const spawned = this.proc;
38
+ this.closed = false;
39
+ this.attachRouter();
40
+ spawned.stderr.on("data", (chunk) => {
41
+ this.emit("stderr", chunk.toString());
42
+ });
43
+ spawned.on("exit", (code, signal) => {
44
+ this.closed = true;
45
+ this.emit("exit", { code, signal });
46
+ for (const [id, entry] of this.pending.entries()) {
47
+ clearTimeout(entry.timer);
48
+ entry.reject(new Error(`Process exited before response ${id}`));
49
+ }
50
+ this.pending.clear();
51
+ });
52
+ spawned.on("error", (err) => this.emit("error", err));
53
+ // waiting for handshake but handling the timeout
54
+ try {
55
+ await Promise.race([
56
+ this.waitForHandshake(),
57
+ this.timeout(this.startupTimeout, "Handshake timeout")
58
+ ]);
59
+ }
60
+ catch (err) {
61
+ if (process.env.MCP_DEBUG) {
62
+ this.emit("stderr", `[CLIENT] Handshake wait failed (continuing anyway): ${err instanceof Error ? err.message : String(err)}\n`);
63
+ }
64
+ }
65
+ return { pid: spawned.pid };
66
+ }
67
+ // ---------------------------------------------------------------------
68
+ // SEND REQUEST
69
+ // ---------------------------------------------------------------------
70
+ send(req, timeoutMs = 5000) {
71
+ const proc = this.proc;
72
+ if (!proc || this.closed) {
73
+ return Promise.reject(new Error("Process not running."));
74
+ }
75
+ const id = req.id;
76
+ return new Promise((resolve, reject) => {
77
+ const timer = setTimeout(() => {
78
+ this.pending.delete(id);
79
+ reject(new Error(`Request ${id} timed out after ${timeoutMs}ms`));
80
+ }, timeoutMs);
81
+ this.pending.set(id, { resolve, reject, timer });
82
+ try {
83
+ const body = JSON.stringify(req);
84
+ const header = `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n`;
85
+ proc.stdin.write(header + body, "utf8");
86
+ }
87
+ catch (err) {
88
+ clearTimeout(timer);
89
+ this.pending.delete(id);
90
+ reject(new Error(`Failed to write request ${id}: ${err.message}`));
91
+ }
92
+ });
93
+ }
94
+ // ---------------------------------------------------------------------
95
+ // CLOSE
96
+ // ---------------------------------------------------------------------
97
+ async close() {
98
+ if (this.closed || !this.proc)
99
+ return;
100
+ this.closed = true;
101
+ // Reject all pending
102
+ for (const [id, entry] of this.pending.entries()) {
103
+ clearTimeout(entry.timer);
104
+ entry.reject(new Error(`Process closed before response ${id}`));
105
+ }
106
+ this.pending.clear();
107
+ const p = this.proc;
108
+ try {
109
+ p.kill("SIGTERM");
110
+ }
111
+ catch {
112
+ // ignore
113
+ }
114
+ try {
115
+ await Promise.race([
116
+ once(p, "exit"),
117
+ this.timeout(this.shutdownTimeout, "Shutdown timeout")
118
+ ]);
119
+ }
120
+ catch {
121
+ try {
122
+ p.kill("SIGKILL");
123
+ }
124
+ catch {
125
+ // ignore
126
+ }
127
+ await once(p, "exit");
128
+ }
129
+ }
130
+ // ---------------------------------------------------------------------
131
+ // ROUTER – streaming JSON object scanner (ignores Content-Length)
132
+ // ---------------------------------------------------------------------
133
+ attachRouter() {
134
+ if (!this.proc)
135
+ return;
136
+ let buffer = "";
137
+ this.proc.stdout.on("data", (chunk) => {
138
+ buffer += chunk.toString("utf8");
139
+ parseLoop: while (true) {
140
+ // Find first '{' – skip logs, headers, etc.
141
+ const start = buffer.indexOf("{");
142
+ if (start === -1) {
143
+ // no JSON start yet, wait for more data
144
+ return;
145
+ }
146
+ let depth = 0;
147
+ let inString = false;
148
+ let escape = false;
149
+ let end = -1;
150
+ // Scan forward and find matching '}' that closes the outermost object
151
+ for (let i = start; i < buffer.length; i++) {
152
+ const ch = buffer[i];
153
+ if (escape) {
154
+ // current char is escaped, just skip
155
+ escape = false;
156
+ continue;
157
+ }
158
+ if (ch === "\\") {
159
+ if (inString) {
160
+ escape = true;
161
+ }
162
+ continue;
163
+ }
164
+ if (ch === "\"") {
165
+ inString = !inString;
166
+ continue;
167
+ }
168
+ if (!inString) {
169
+ if (ch === "{") {
170
+ depth++;
171
+ }
172
+ else if (ch === "}") {
173
+ depth--;
174
+ if (depth === 0) {
175
+ end = i;
176
+ break;
177
+ }
178
+ }
179
+ }
180
+ }
181
+ // If depth != 0, JSON is incomplete; wait for more data
182
+ if (depth !== 0 || end === -1) {
183
+ return;
184
+ }
185
+ const jsonStr = buffer.slice(start, end + 1);
186
+ // Drop everything up to the end of this JSON object
187
+ buffer = buffer.slice(end + 1);
188
+ let msg;
189
+ try {
190
+ msg = JSON.parse(jsonStr);
191
+ }
192
+ catch (err) {
193
+ this.emit("stderr", `Invalid JSON from server (scanner): ${err.message}\n`);
194
+ // Try to continue from the next possible JSON in the buffer
195
+ continue parseLoop;
196
+ }
197
+ // First successfully parsed JSON -> treat as handshake
198
+ if (!this.handshakeEmitted) {
199
+ this.handshakeEmitted = true;
200
+ if (process.env.MCP_DEBUG) {
201
+ this.emit("stderr", "[CLIENT] First JSON object parsed, treating as handshake\n");
202
+ }
203
+ this.emit("handshake", msg);
204
+ // Do not treat this as a normal RPC response
205
+ continue parseLoop;
206
+ }
207
+ // Normal RPC response from here on
208
+ this.emit("rpc-response", msg);
209
+ const maybeId = msg.id;
210
+ if (maybeId != null && this.pending.has(Number(maybeId))) {
211
+ const entry = this.pending.get(Number(maybeId));
212
+ clearTimeout(entry.timer);
213
+ this.pending.delete(Number(maybeId));
214
+ entry.resolve(msg);
215
+ }
216
+ // loop to see if another JSON object is already in buffer
217
+ }
218
+ });
219
+ }
220
+ // ---------------------------------------------------------------------
221
+ // WAIT FOR HANDSHAKE EVENT
222
+ // ---------------------------------------------------------------------
223
+ waitForHandshake() {
224
+ return new Promise((resolve) => this.once("handshake", () => resolve()));
225
+ }
226
+ timeout(ms, message) {
227
+ return new Promise((_resolve, reject) => {
228
+ const t = setTimeout(() => {
229
+ clearTimeout(t);
230
+ reject(new Error(message));
231
+ }, ms);
232
+ });
233
+ }
234
+ }
package/package.json CHANGED
@@ -1,8 +1,63 @@
1
1
  {
2
2
  "name": "mcp-client-general",
3
- "version": "0.0.1",
4
- "description": "Reserved package name for the General MCP Client.",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "General-purpose MCP Client and cross-platform CLI for the Model Context Protocol (MCP). Supports JSON-RPC over stdio with robust framing, handshake detection, and multi-request workflows.",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "mcp": "dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc -p tsconfig.json",
12
+ "clean": "rm -rf dist",
13
+ "dev": "tsx src/index.ts",
14
+ "test": "vitest --run",
15
+ "prepare": "npm run build",
16
+ "start": "node dist/index.js",
17
+ "link": "npm link",
18
+ "unlink": "npm unlink -g mcp-client-general || true",
19
+ "lint": "eslint . --ext .ts",
20
+ "lint:fix": "eslint . --ext .ts --fix",
21
+ "format": "prettier --check ."
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/daporun/mcp-client-general.git"
26
+ },
27
+ "keywords": [
28
+ "mcp client",
29
+ "mcp",
30
+ "model-context-protocol",
31
+ "jsonrpc",
32
+ "cli",
33
+ "client",
34
+ "stdio",
35
+ "agent",
36
+ "protocol",
37
+ "framework"
38
+ ],
39
+ "author": "DAPO / daporun <info@dapo.run>",
5
40
  "license": "MIT",
6
- "main": "index.js",
7
- "files": ["index.js"]
41
+ "bugs": {
42
+ "url": "https://github.com/daporun/mcp-client-general/issues"
43
+ },
44
+ "homepage": "https://github.com/daporun/mcp-client-general#readme",
45
+ "devDependencies": {
46
+ "@types/node": "^25.0.0",
47
+ "@typescript-eslint/eslint-plugin": "^8.49.0",
48
+ "@typescript-eslint/parser": "^8.49.0",
49
+ "eslint": "^9.39.1",
50
+ "eslint-config-prettier": "^10.1.8",
51
+ "eslint-plugin-prettier": "^5.5.4",
52
+ "prettier": "^3.7.4",
53
+ "tsx": "^4.21.0",
54
+ "typescript": "^5.9.3",
55
+ "vitest": "^4.0.15"
56
+ },
57
+ "files": [
58
+ "dist",
59
+ "docs",
60
+ "README.md",
61
+ "LICENSE"
62
+ ]
8
63
  }
package/index.js DELETED
@@ -1 +0,0 @@
1
- module.exports = {};