contextspin 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/.contextspin.example.json +72 -0
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/package.json +40 -0
- package/src/cli.js +492 -0
- package/src/config.js +232 -0
- package/src/daemon-entry.js +8 -0
- package/src/daemon.js +294 -0
- package/src/formatter.js +166 -0
- package/src/inject/patcher.js +757 -0
- package/src/inject/statusline.js +310 -0
- package/src/runner.js +69 -0
- package/src/sources/cli.js +148 -0
- package/src/sources/http.js +294 -0
- package/src/sources/mcp.js +586 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
// src/sources/mcp.js — MCP source: a hand-rolled minimal MCP stdio JSON-RPC client (no SDK).
|
|
2
|
+
|
|
3
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { CLAUDE_USER_CONFIG_PATH } from '../config.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Expand environment variable references inside a string.
|
|
10
|
+
*
|
|
11
|
+
* Supports three forms:
|
|
12
|
+
* - ${VAR} -> value of VAR (empty string if unset)
|
|
13
|
+
* - ${VAR:-DEFAULT} -> value of VAR, or DEFAULT when unset OR empty
|
|
14
|
+
* - $VAR -> bare reference (value of VAR, empty if unset)
|
|
15
|
+
*
|
|
16
|
+
* Non-string inputs are returned unchanged. Pure.
|
|
17
|
+
*
|
|
18
|
+
* @param {*} str - The value to expand (only strings are processed).
|
|
19
|
+
* @param {Object} [env] - Environment map (default process.env).
|
|
20
|
+
* @returns {*} The expanded string, or the original value if not a string.
|
|
21
|
+
*/
|
|
22
|
+
export function expandEnv(str, env = process.env) {
|
|
23
|
+
if (typeof str !== 'string') return str;
|
|
24
|
+
|
|
25
|
+
// ${VAR} and ${VAR:-DEFAULT}
|
|
26
|
+
let result = str.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_m, name, def) => {
|
|
27
|
+
const value = env[name];
|
|
28
|
+
if (def !== undefined) {
|
|
29
|
+
// :-DEFAULT applies when the var is unset OR empty.
|
|
30
|
+
return value === undefined || value === '' ? def : value;
|
|
31
|
+
}
|
|
32
|
+
return value === undefined ? '' : value;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Bare $VAR (not followed by a brace).
|
|
36
|
+
result = result.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_m, name) => {
|
|
37
|
+
const value = env[name];
|
|
38
|
+
return value === undefined ? '' : value;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read and JSON-parse a file, returning null on any error (missing/parse).
|
|
46
|
+
*
|
|
47
|
+
* @param {string} filePath
|
|
48
|
+
* @returns {Object|null}
|
|
49
|
+
*/
|
|
50
|
+
function readJsonSafe(filePath) {
|
|
51
|
+
try {
|
|
52
|
+
if (!existsSync(filePath)) return null;
|
|
53
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
54
|
+
return JSON.parse(raw);
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Apply expandEnv to a server definition's command, args, and env values.
|
|
62
|
+
*
|
|
63
|
+
* @param {Object} def - A raw server definition.
|
|
64
|
+
* @param {Object} env - Environment map.
|
|
65
|
+
* @returns {Object} A new, expanded server definition.
|
|
66
|
+
*/
|
|
67
|
+
function expandServerDef(def, env) {
|
|
68
|
+
if (!def || typeof def !== 'object') return def;
|
|
69
|
+
const out = { ...def };
|
|
70
|
+
if (typeof out.command === 'string') {
|
|
71
|
+
out.command = expandEnv(out.command, env);
|
|
72
|
+
}
|
|
73
|
+
if (Array.isArray(out.args)) {
|
|
74
|
+
out.args = out.args.map((arg) => expandEnv(arg, env));
|
|
75
|
+
}
|
|
76
|
+
if (out.env && typeof out.env === 'object') {
|
|
77
|
+
const expandedEnv = {};
|
|
78
|
+
for (const [key, value] of Object.entries(out.env)) {
|
|
79
|
+
expandedEnv[key] = typeof value === 'string' ? expandEnv(value, env) : value;
|
|
80
|
+
}
|
|
81
|
+
out.env = expandedEnv;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Discover MCP server definitions across the supported config scopes.
|
|
88
|
+
*
|
|
89
|
+
* Sources and precedence (later wins): user < project < local.
|
|
90
|
+
* - USER: ~/.claude.json -> .mcpServers
|
|
91
|
+
* - LOCAL: ~/.claude.json -> .projects["<abs-cwd>"].mcpServers
|
|
92
|
+
* - PROJECT: <cwd>/.mcp.json -> .mcpServers
|
|
93
|
+
*
|
|
94
|
+
* (Precedence local > project > user; we layer user first, then project,
|
|
95
|
+
* then local so local overwrites the others.) command, each arg, and each env
|
|
96
|
+
* value string have environment references expanded. Any unreadable or
|
|
97
|
+
* unparseable file is skipped silently. Plugin/managed scopes are ignored in
|
|
98
|
+
* Stage 1.
|
|
99
|
+
*
|
|
100
|
+
* @param {{ cwd?: string, env?: Object }} [opts]
|
|
101
|
+
* @returns {Object} Map of server name -> expanded server definition.
|
|
102
|
+
*/
|
|
103
|
+
export function discoverMcpServers(opts = {}) {
|
|
104
|
+
// Resolve to an absolute path once so the PROJECT (.mcp.json) and LOCAL
|
|
105
|
+
// (.projects["<abs-cwd>"]) scopes agree on the same cwd even if a relative
|
|
106
|
+
// cwd was passed; ~/.claude.json project keys are always absolute.
|
|
107
|
+
const cwd = path.resolve(opts.cwd || process.cwd());
|
|
108
|
+
const env = opts.env || process.env;
|
|
109
|
+
|
|
110
|
+
const merged = {};
|
|
111
|
+
|
|
112
|
+
// USER scope: ~/.claude.json .mcpServers
|
|
113
|
+
const userConfig = readJsonSafe(CLAUDE_USER_CONFIG_PATH);
|
|
114
|
+
const userServers = userConfig && userConfig.mcpServers;
|
|
115
|
+
if (userServers && typeof userServers === 'object') {
|
|
116
|
+
for (const [name, def] of Object.entries(userServers)) {
|
|
117
|
+
merged[name] = expandServerDef(def, env);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// PROJECT scope: <cwd>/.mcp.json .mcpServers
|
|
122
|
+
const projectConfig = readJsonSafe(path.join(cwd, '.mcp.json'));
|
|
123
|
+
const projectServers = projectConfig && projectConfig.mcpServers;
|
|
124
|
+
if (projectServers && typeof projectServers === 'object') {
|
|
125
|
+
for (const [name, def] of Object.entries(projectServers)) {
|
|
126
|
+
merged[name] = expandServerDef(def, env);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// LOCAL scope: ~/.claude.json .projects["<abs-cwd>"].mcpServers
|
|
131
|
+
const absCwd = path.resolve(cwd);
|
|
132
|
+
const projects = userConfig && userConfig.projects;
|
|
133
|
+
const localServers =
|
|
134
|
+
projects && projects[absCwd] && projects[absCwd].mcpServers;
|
|
135
|
+
if (localServers && typeof localServers === 'object') {
|
|
136
|
+
for (const [name, def] of Object.entries(localServers)) {
|
|
137
|
+
merged[name] = expandServerDef(def, env);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return merged;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Determine whether a server definition uses the stdio transport.
|
|
146
|
+
* A definition is stdio if it has a command, or type is "stdio"/undefined.
|
|
147
|
+
*
|
|
148
|
+
* @param {Object} def
|
|
149
|
+
* @returns {boolean}
|
|
150
|
+
*/
|
|
151
|
+
function isStdioServer(def) {
|
|
152
|
+
if (!def || typeof def !== 'object') return false;
|
|
153
|
+
if (typeof def.command === 'string' && def.command !== '') return true;
|
|
154
|
+
return def.type === undefined || def.type === 'stdio';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A minimal newline-delimited JSON-RPC 2.0 client over a child's stdio.
|
|
159
|
+
* Each instance owns one spawned server process for its lifetime.
|
|
160
|
+
*/
|
|
161
|
+
class StdioMcpClient {
|
|
162
|
+
/**
|
|
163
|
+
* @param {Object} serverDef - The (expanded) stdio server definition.
|
|
164
|
+
* @param {string} name - The server's name (for error messages).
|
|
165
|
+
*/
|
|
166
|
+
constructor(serverDef, name) {
|
|
167
|
+
this.serverDef = serverDef;
|
|
168
|
+
this.name = name;
|
|
169
|
+
this.child = null;
|
|
170
|
+
this.nextId = 1;
|
|
171
|
+
/** @type {Map<number, {resolve:Function, reject:Function}>} */
|
|
172
|
+
this.pending = new Map();
|
|
173
|
+
this.buffer = '';
|
|
174
|
+
this.closed = false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Spawn the server process and wire up a line-buffered stdout reader.
|
|
179
|
+
* Server stderr is treated as logs and ignored.
|
|
180
|
+
*/
|
|
181
|
+
start() {
|
|
182
|
+
const command = this.serverDef.command;
|
|
183
|
+
const args = Array.isArray(this.serverDef.args) ? this.serverDef.args : [];
|
|
184
|
+
const childEnv = { ...process.env, ...(this.serverDef.env || {}) };
|
|
185
|
+
|
|
186
|
+
this.child = spawn(command, args, {
|
|
187
|
+
env: childEnv,
|
|
188
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
this.child.stdout.setEncoding('utf8');
|
|
192
|
+
this.child.stdout.on('data', (chunk) => this._onStdout(chunk));
|
|
193
|
+
|
|
194
|
+
// Drain stderr so the child never blocks; treat it as logs.
|
|
195
|
+
if (this.child.stderr) {
|
|
196
|
+
this.child.stderr.setEncoding('utf8');
|
|
197
|
+
this.child.stderr.on('data', () => {});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
this.child.on('error', (err) => this._failAll(err));
|
|
201
|
+
this.child.on('close', () => {
|
|
202
|
+
this.closed = true;
|
|
203
|
+
this._failAll(new Error(`mcp server "${this.name}" exited unexpectedly`));
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Accumulate stdout and dispatch complete newline-delimited JSON messages.
|
|
209
|
+
*
|
|
210
|
+
* @param {string} chunk
|
|
211
|
+
*/
|
|
212
|
+
_onStdout(chunk) {
|
|
213
|
+
this.buffer += chunk;
|
|
214
|
+
let newlineIndex;
|
|
215
|
+
while ((newlineIndex = this.buffer.indexOf('\n')) !== -1) {
|
|
216
|
+
const line = this.buffer.slice(0, newlineIndex);
|
|
217
|
+
this.buffer = this.buffer.slice(newlineIndex + 1);
|
|
218
|
+
const trimmed = line.trim();
|
|
219
|
+
if (trimmed === '') continue;
|
|
220
|
+
let message;
|
|
221
|
+
try {
|
|
222
|
+
message = JSON.parse(trimmed);
|
|
223
|
+
} catch {
|
|
224
|
+
// Non-JSON line on stdout: ignore defensively.
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
this._dispatch(message);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Route a parsed message to its waiting request by id. Messages without an
|
|
233
|
+
* id (server notifications/requests) are ignored.
|
|
234
|
+
*
|
|
235
|
+
* @param {Object} message
|
|
236
|
+
*/
|
|
237
|
+
_dispatch(message) {
|
|
238
|
+
if (message === null || typeof message !== 'object') return;
|
|
239
|
+
if (message.id === undefined || message.id === null) {
|
|
240
|
+
// Server notification or request: not something we wait on.
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
// Key pending requests by String(id) on both send and dispatch so a server
|
|
244
|
+
// that echoes the id as a string ("1") still matches our numeric id (1).
|
|
245
|
+
const key = String(message.id);
|
|
246
|
+
const waiter = this.pending.get(key);
|
|
247
|
+
if (!waiter) return;
|
|
248
|
+
this.pending.delete(key);
|
|
249
|
+
waiter.resolve(message);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Reject every pending request (used on child error/close).
|
|
254
|
+
*
|
|
255
|
+
* @param {Error} err
|
|
256
|
+
*/
|
|
257
|
+
_failAll(err) {
|
|
258
|
+
for (const waiter of this.pending.values()) {
|
|
259
|
+
waiter.reject(err);
|
|
260
|
+
}
|
|
261
|
+
this.pending.clear();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Send a JSON-RPC request and resolve with the matching response message.
|
|
266
|
+
*
|
|
267
|
+
* @param {string} method
|
|
268
|
+
* @param {Object} params
|
|
269
|
+
* @returns {Promise<Object>} The full JSON-RPC response message.
|
|
270
|
+
*/
|
|
271
|
+
request(method, params) {
|
|
272
|
+
// Fail fast if the child already closed: otherwise the write is buffered to
|
|
273
|
+
// a dead pipe and the request would stall until the outer timeout fires.
|
|
274
|
+
if (this.closed) {
|
|
275
|
+
return Promise.reject(
|
|
276
|
+
new Error(`mcp server "${this.name}" is not connected`)
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
const id = this.nextId++;
|
|
280
|
+
const message = { jsonrpc: '2.0', id, method, params };
|
|
281
|
+
return new Promise((resolve, reject) => {
|
|
282
|
+
this.pending.set(String(id), { resolve, reject });
|
|
283
|
+
this._write(message, reject);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Send a JSON-RPC notification (no id, no response expected).
|
|
289
|
+
*
|
|
290
|
+
* @param {string} method
|
|
291
|
+
* @param {Object} [params]
|
|
292
|
+
*/
|
|
293
|
+
notify(method, params) {
|
|
294
|
+
const message = { jsonrpc: '2.0', method };
|
|
295
|
+
if (params !== undefined) message.params = params;
|
|
296
|
+
this._write(message);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Write a single newline-delimited JSON message to the child's stdin.
|
|
301
|
+
*
|
|
302
|
+
* @param {Object} message
|
|
303
|
+
* @param {Function} [reject] - Optional reject for a pending request.
|
|
304
|
+
*/
|
|
305
|
+
_write(message, reject) {
|
|
306
|
+
const line = JSON.stringify(message) + '\n';
|
|
307
|
+
try {
|
|
308
|
+
this.child.stdin.write(line);
|
|
309
|
+
} catch (err) {
|
|
310
|
+
if (reject) reject(err);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Perform the initialize handshake: initialize request, await result, then
|
|
316
|
+
* send the notifications/initialized notification.
|
|
317
|
+
*
|
|
318
|
+
* @returns {Promise<Object>} The initialize result.
|
|
319
|
+
*/
|
|
320
|
+
async initialize() {
|
|
321
|
+
const response = await this.request('initialize', {
|
|
322
|
+
protocolVersion: '2025-06-18',
|
|
323
|
+
capabilities: {},
|
|
324
|
+
clientInfo: { name: 'contextspin', version: '0.1.0' },
|
|
325
|
+
});
|
|
326
|
+
if (response.error) {
|
|
327
|
+
throw new Error(response.error.message || 'mcp initialize failed');
|
|
328
|
+
}
|
|
329
|
+
// We accept whatever protocolVersion the server echoes back.
|
|
330
|
+
this.notify('notifications/initialized');
|
|
331
|
+
return response.result;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Call tools/list and return the array of tools.
|
|
336
|
+
*
|
|
337
|
+
* @returns {Promise<Array<{name:string}>>}
|
|
338
|
+
*/
|
|
339
|
+
async listTools() {
|
|
340
|
+
const response = await this.request('tools/list', {});
|
|
341
|
+
if (response.error) {
|
|
342
|
+
throw new Error(response.error.message || 'mcp tools/list failed');
|
|
343
|
+
}
|
|
344
|
+
const result = response.result || {};
|
|
345
|
+
return Array.isArray(result.tools) ? result.tools : [];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Call a tool by its RAW name (not the mcp__server__tool form).
|
|
350
|
+
*
|
|
351
|
+
* @param {string} rawTool
|
|
352
|
+
* @param {Object} args
|
|
353
|
+
* @returns {Promise<Object>} The full JSON-RPC response message.
|
|
354
|
+
*/
|
|
355
|
+
callTool(rawTool, args) {
|
|
356
|
+
return this.request('tools/call', {
|
|
357
|
+
name: rawTool,
|
|
358
|
+
arguments: args || {},
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Terminate the child process (best effort).
|
|
364
|
+
*/
|
|
365
|
+
kill() {
|
|
366
|
+
if (this.child && !this.closed) {
|
|
367
|
+
try {
|
|
368
|
+
this.child.kill();
|
|
369
|
+
} catch {
|
|
370
|
+
// ignore
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Strip an mcp__<server>__<tool> prefix to recover the raw tool name and the
|
|
378
|
+
* server name it implies.
|
|
379
|
+
*
|
|
380
|
+
* @param {string} tool
|
|
381
|
+
* @returns {{ rawTool: string, serverName: string|null }}
|
|
382
|
+
*/
|
|
383
|
+
function parseToolName(tool) {
|
|
384
|
+
if (typeof tool === 'string' && tool.startsWith('mcp__')) {
|
|
385
|
+
const rest = tool.slice('mcp__'.length);
|
|
386
|
+
const sep = rest.indexOf('__');
|
|
387
|
+
if (sep !== -1) {
|
|
388
|
+
return {
|
|
389
|
+
serverName: rest.slice(0, sep),
|
|
390
|
+
rawTool: rest.slice(sep + 2),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
// Malformed prefix: treat the remainder as the raw tool.
|
|
394
|
+
return { serverName: null, rawTool: rest };
|
|
395
|
+
}
|
|
396
|
+
return { serverName: null, rawTool: tool };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Parse a tools/call response message into an array of record objects.
|
|
401
|
+
*
|
|
402
|
+
* Throws on a top-level JSON-RPC error or when result.isError is set.
|
|
403
|
+
* Prefers result.structuredContent when present; otherwise iterates the
|
|
404
|
+
* text content blocks (each parsed as JSON when possible).
|
|
405
|
+
*
|
|
406
|
+
* @param {Object} message - The JSON-RPC response message.
|
|
407
|
+
* @param {string} rawTool - The tool name (for error context).
|
|
408
|
+
* @returns {Array<object>}
|
|
409
|
+
*/
|
|
410
|
+
function parseToolResult(message, rawTool) {
|
|
411
|
+
if (message.error) {
|
|
412
|
+
throw new Error(message.error.message || `mcp tool "${rawTool}" error`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const result = message.result || {};
|
|
416
|
+
|
|
417
|
+
if (result.isError) {
|
|
418
|
+
const firstText = firstTextBlock(result.content);
|
|
419
|
+
throw new Error(firstText || `mcp tool "${rawTool}" returned an error`);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Prefer structured content when the server provides it.
|
|
423
|
+
if (result.structuredContent !== undefined && result.structuredContent !== null) {
|
|
424
|
+
const sc = result.structuredContent;
|
|
425
|
+
if (Array.isArray(sc)) return sc.slice();
|
|
426
|
+
if (typeof sc === 'object') return [sc];
|
|
427
|
+
// A primitive structuredContent: wrap it.
|
|
428
|
+
return [{ value: sc, text: String(sc) }];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Otherwise, walk the text content blocks.
|
|
432
|
+
const records = [];
|
|
433
|
+
const content = Array.isArray(result.content) ? result.content : [];
|
|
434
|
+
for (const block of content) {
|
|
435
|
+
if (!block || block.type !== 'text' || typeof block.text !== 'string') {
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const text = block.text;
|
|
439
|
+
let parsed;
|
|
440
|
+
try {
|
|
441
|
+
parsed = JSON.parse(text);
|
|
442
|
+
} catch {
|
|
443
|
+
records.push({ text });
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (Array.isArray(parsed)) {
|
|
447
|
+
records.push(...parsed);
|
|
448
|
+
} else if (parsed !== null && typeof parsed === 'object') {
|
|
449
|
+
records.push(parsed);
|
|
450
|
+
} else {
|
|
451
|
+
records.push({ text });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return records;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Return the text of the first text content block, if any.
|
|
460
|
+
*
|
|
461
|
+
* @param {Array} content
|
|
462
|
+
* @returns {string|null}
|
|
463
|
+
*/
|
|
464
|
+
function firstTextBlock(content) {
|
|
465
|
+
if (!Array.isArray(content)) return null;
|
|
466
|
+
for (const block of content) {
|
|
467
|
+
if (block && block.type === 'text' && typeof block.text === 'string') {
|
|
468
|
+
return block.text;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Fetch records from an MCP tool by acting as a minimal stdio JSON-RPC client.
|
|
476
|
+
*
|
|
477
|
+
* Resolution:
|
|
478
|
+
* - rawTool/serverName are derived from source.tool (stripping any
|
|
479
|
+
* mcp__<server>__ prefix); source.server overrides the inferred server.
|
|
480
|
+
* - If no server is known, every stdio server is connected and queried with
|
|
481
|
+
* tools/list; the first whose tool names include rawTool is used.
|
|
482
|
+
* - Remote transports (http/sse/ws) are unsupported in Stage 1 and throw.
|
|
483
|
+
*
|
|
484
|
+
* The chosen server's child process is ALWAYS killed in a finally block, and
|
|
485
|
+
* a timeoutMs guard kills the child and rejects if the call hangs.
|
|
486
|
+
*
|
|
487
|
+
* @param {{ tool: string, server?: string, args?: Object }} source
|
|
488
|
+
* @param {{ timeoutMs?: number, cwd?: string, env?: Object }} [opts]
|
|
489
|
+
* @returns {Promise<Array<object>>}
|
|
490
|
+
*/
|
|
491
|
+
export async function fetchMcp(source, opts = {}) {
|
|
492
|
+
const timeoutMs = opts.timeoutMs ?? 20000;
|
|
493
|
+
const cwd = opts.cwd || process.cwd();
|
|
494
|
+
|
|
495
|
+
const parsed = parseToolName(source.tool);
|
|
496
|
+
const rawTool = parsed.rawTool;
|
|
497
|
+
let serverName = source.server || parsed.serverName || null;
|
|
498
|
+
|
|
499
|
+
const servers = discoverMcpServers({ cwd, env: opts.env });
|
|
500
|
+
|
|
501
|
+
// If a specific server is named, validate its transport up front.
|
|
502
|
+
if (serverName) {
|
|
503
|
+
const def = servers[serverName];
|
|
504
|
+
if (def && !isStdioServer(def)) {
|
|
505
|
+
throw new Error(
|
|
506
|
+
`mcp ${def.type} transport not supported in Stage 1 (stdio only); ` +
|
|
507
|
+
`use a cli or http source instead. Server: ${serverName}`
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// The whole call runs against a single timeout; the active client is tracked
|
|
513
|
+
// so the timeout handler can kill it.
|
|
514
|
+
let activeClient = null;
|
|
515
|
+
|
|
516
|
+
const work = (async () => {
|
|
517
|
+
if (serverName) {
|
|
518
|
+
const def = servers[serverName];
|
|
519
|
+
if (!def) {
|
|
520
|
+
throw new Error(`mcp server "${serverName}" not found in any config`);
|
|
521
|
+
}
|
|
522
|
+
const client = new StdioMcpClient(def, serverName);
|
|
523
|
+
activeClient = client;
|
|
524
|
+
try {
|
|
525
|
+
client.start();
|
|
526
|
+
await client.initialize();
|
|
527
|
+
const message = await client.callTool(rawTool, source.args);
|
|
528
|
+
return parseToolResult(message, rawTool);
|
|
529
|
+
} finally {
|
|
530
|
+
client.kill();
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// No server named: probe stdio servers in turn for one exposing rawTool.
|
|
535
|
+
const stdioNames = Object.keys(servers).filter((name) =>
|
|
536
|
+
isStdioServer(servers[name])
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
for (const name of stdioNames) {
|
|
540
|
+
const def = servers[name];
|
|
541
|
+
const client = new StdioMcpClient(def, name);
|
|
542
|
+
activeClient = client;
|
|
543
|
+
let hasTool = false;
|
|
544
|
+
try {
|
|
545
|
+
client.start();
|
|
546
|
+
await client.initialize();
|
|
547
|
+
const tools = await client.listTools();
|
|
548
|
+
hasTool = tools.some((t) => t && t.name === rawTool);
|
|
549
|
+
if (hasTool) {
|
|
550
|
+
const message = await client.callTool(rawTool, source.args);
|
|
551
|
+
return parseToolResult(message, rawTool);
|
|
552
|
+
}
|
|
553
|
+
} catch (err) {
|
|
554
|
+
// If we already matched the tool on THIS server, a failure here is a
|
|
555
|
+
// genuine tool/protocol error from the matched server — surface it
|
|
556
|
+
// rather than masking it as "tool not found". Otherwise the server just
|
|
557
|
+
// failed to connect/list, so try the next candidate.
|
|
558
|
+
if (hasTool) throw err;
|
|
559
|
+
} finally {
|
|
560
|
+
client.kill();
|
|
561
|
+
activeClient = null;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
throw new Error(
|
|
566
|
+
`mcp tool "${rawTool}" not found on any stdio MCP server`
|
|
567
|
+
);
|
|
568
|
+
})();
|
|
569
|
+
|
|
570
|
+
let timer;
|
|
571
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
572
|
+
timer = setTimeout(() => {
|
|
573
|
+
if (activeClient) activeClient.kill();
|
|
574
|
+
reject(new Error(`mcp source timed out after ${timeoutMs}ms (tool: ${rawTool})`));
|
|
575
|
+
}, timeoutMs);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
try {
|
|
579
|
+
return await Promise.race([work, timeout]);
|
|
580
|
+
} finally {
|
|
581
|
+
clearTimeout(timer);
|
|
582
|
+
if (activeClient) activeClient.kill();
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export default fetchMcp;
|