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.
@@ -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;