@sdsrs/code-graph 0.84.0 → 0.85.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.
|
@@ -11,6 +11,7 @@ const { spawn, spawnSync } = require('child_process');
|
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const fs = require('fs');
|
|
13
13
|
const { isNonProjectCwd } = require('./project-detect');
|
|
14
|
+
const { serveEmptyMcpStub } = require('./mcp-stub');
|
|
14
15
|
|
|
15
16
|
// Set plugin root so find-binary.js can locate bundled/dev binaries
|
|
16
17
|
// Always derive from __dirname — CLAUDE_PLUGIN_ROOT can leak from other plugins
|
|
@@ -38,46 +39,8 @@ function projectHasLocalCodeGraphMcp(cwd) {
|
|
|
38
39
|
} catch { return false; }
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
process.stdin.setEncoding('utf8');
|
|
44
|
-
process.stdin.on('data', (chunk) => {
|
|
45
|
-
buf += chunk;
|
|
46
|
-
let nl;
|
|
47
|
-
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
48
|
-
const line = buf.slice(0, nl).trim();
|
|
49
|
-
buf = buf.slice(nl + 1);
|
|
50
|
-
if (!line) continue;
|
|
51
|
-
let req;
|
|
52
|
-
try { req = JSON.parse(line); } catch { continue; }
|
|
53
|
-
if (!req || typeof req.method !== 'string') continue;
|
|
54
|
-
// JSON-RPC notifications (id missing) get no response.
|
|
55
|
-
if (typeof req.id === 'undefined') continue;
|
|
56
|
-
const method = req.method;
|
|
57
|
-
let result, error;
|
|
58
|
-
if (method === 'initialize') {
|
|
59
|
-
result = {
|
|
60
|
-
protocolVersion: '2024-11-05',
|
|
61
|
-
capabilities: { tools: { listChanged: false } },
|
|
62
|
-
serverInfo: { name: 'code-graph-mcp (plugin stub, dedup)', version: '0.31.1' },
|
|
63
|
-
};
|
|
64
|
-
} else if (method === 'tools/list') {
|
|
65
|
-
result = { tools: [] };
|
|
66
|
-
} else if (method === 'resources/list') {
|
|
67
|
-
result = { resources: [] };
|
|
68
|
-
} else if (method === 'prompts/list') {
|
|
69
|
-
result = { prompts: [] };
|
|
70
|
-
} else {
|
|
71
|
-
error = { code: -32601, message: 'method not found (plugin MCP is in dedup stub mode)' };
|
|
72
|
-
}
|
|
73
|
-
const resp = error
|
|
74
|
-
? { jsonrpc: '2.0', id: req.id, error }
|
|
75
|
-
: { jsonrpc: '2.0', id: req.id, result };
|
|
76
|
-
process.stdout.write(JSON.stringify(resp) + '\n');
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
process.stdin.on('end', () => process.exit(0));
|
|
80
|
-
}
|
|
42
|
+
// serveEmptyMcpStub lives in ./mcp-stub.js — a permanent 0-tool stub by default,
|
|
43
|
+
// or an in-place upgrading stub when passed { upgrade } (see the non-project gate).
|
|
81
44
|
|
|
82
45
|
if (process.env.CODE_GRAPH_FORCE_PLUGIN_MCP !== '1' && projectHasLocalCodeGraphMcp(process.cwd())) {
|
|
83
46
|
process.stderr.write(
|
|
@@ -98,10 +61,27 @@ if (process.env.CODE_GRAPH_FORCE_PLUGIN_MCP !== '1' && projectHasLocalCodeGraphM
|
|
|
98
61
|
// CODE_GRAPH_FORCE_PLUGIN_MCP=1 override as the dedup gate above.
|
|
99
62
|
if (process.env.CODE_GRAPH_FORCE_PLUGIN_MCP !== '1' && isNonProjectCwd(process.cwd())) {
|
|
100
63
|
process.stderr.write(
|
|
101
|
-
'[code-graph] non-project cwd (no .git/manifest); plugin MCP serving 0 tools
|
|
102
|
-
'
|
|
64
|
+
'[code-graph] non-project cwd (no .git/manifest); plugin MCP serving 0 tools ' +
|
|
65
|
+
'(auto-upgrades to real tools if this dir becomes a project — no restart). ' +
|
|
66
|
+
'Set CODE_GRAPH_FORCE_PLUGIN_MCP=1 to override.\n'
|
|
103
67
|
);
|
|
104
|
-
|
|
68
|
+
// Upgradeable stub: the non-project verdict is re-checked on a poll. If the
|
|
69
|
+
// cwd becomes a real project (git init / scaffold) with no local code-graph
|
|
70
|
+
// server, spawn the real binary and hand the live MCP connection over to it —
|
|
71
|
+
// fixes the "stub latched at launch" gap without a Claude Code restart.
|
|
72
|
+
serveEmptyMcpStub({
|
|
73
|
+
upgrade: {
|
|
74
|
+
shouldUpgrade: () =>
|
|
75
|
+
!isNonProjectCwd(process.cwd()) && !projectHasLocalCodeGraphMcp(process.cwd()),
|
|
76
|
+
spawnReal: () => {
|
|
77
|
+
const { findBinary } = require('./find-binary');
|
|
78
|
+
const bin = findBinary();
|
|
79
|
+
if (!bin) return null;
|
|
80
|
+
process.stderr.write(`[code-graph] cwd became a project — upgrading plugin MCP to real tools via ${bin} (restart Claude Code for full tool steering)\n`);
|
|
81
|
+
return spawn(bin, ['serve'], { stdio: ['pipe', 'pipe', 'inherit'], env: process.env });
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
});
|
|
105
85
|
return;
|
|
106
86
|
}
|
|
107
87
|
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Minimal MCP server used by mcp-launcher.js when the plugin should NOT run the
|
|
4
|
+
* real binary — either the project already registers its own code-graph server
|
|
5
|
+
* (dedup) or the cwd is not a project (e.g. /tmp headless calls).
|
|
6
|
+
*
|
|
7
|
+
* Two modes:
|
|
8
|
+
* serveEmptyMcpStub() permanent 0-tool stub (dedup / genuine /tmp)
|
|
9
|
+
* serveEmptyMcpStub({ upgrade }) 0-tool stub that UPGRADES in place
|
|
10
|
+
*
|
|
11
|
+
* The upgrade path closes the "stub latch" gap: the non-project gate is
|
|
12
|
+
* evaluated once at launcher start, so a directory that becomes a project
|
|
13
|
+
* mid-session (bare dir → `git init` + scaffold) would otherwise stay toolless
|
|
14
|
+
* until a full Claude Code restart. With { upgrade } the stub advertises
|
|
15
|
+
* `tools.listChanged:true`, polls `shouldUpgrade()`, and when the cwd becomes a
|
|
16
|
+
* project spawns the real binary, proxies JSON-RPC to it, and emits
|
|
17
|
+
* `notifications/tools/list_changed` so the client re-fetches the (now real)
|
|
18
|
+
* tool list — no restart. Genuinely non-project /tmp callers never satisfy
|
|
19
|
+
* shouldUpgrade(), so they stay cheap (never spawn the binary).
|
|
20
|
+
*
|
|
21
|
+
* Known limitation: the child's ENTIRE `initialize` result is swallowed (the
|
|
22
|
+
* client keeps the stub's) — not just the code-graph `instructions` block but
|
|
23
|
+
* all negotiated server capabilities and the protocolVersion. For a tools-only
|
|
24
|
+
* server that's fine; tools work immediately, but the instructions steering and
|
|
25
|
+
* any non-tool capability only appear after a normal restart. Acceptable: tools
|
|
26
|
+
* are the point, and MCP has no post-init way to re-deliver `instructions`.
|
|
27
|
+
*
|
|
28
|
+
* Deps (input / output / spawn timing / exit) are injectable so the proxy
|
|
29
|
+
* handoff is unit-testable without spawning a real server.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const DEFAULT_POLL_MS = 4000;
|
|
33
|
+
// Bound the retry loop: a persistently unresolvable/broken binary would
|
|
34
|
+
// otherwise re-spawn every pollMs for the whole session (~60s at the default).
|
|
35
|
+
const MAX_UPGRADE_FAILURES = 15;
|
|
36
|
+
// Distinct from any id Claude Code uses (it increments from 1) so the child's
|
|
37
|
+
// reply to our replayed initialize is unambiguous to swallow.
|
|
38
|
+
const SENTINEL_ID = 2147483646;
|
|
39
|
+
|
|
40
|
+
function serveEmptyMcpStub(opts = {}) {
|
|
41
|
+
const input = opts.input || process.stdin;
|
|
42
|
+
const output = opts.output || process.stdout;
|
|
43
|
+
const upgrade = opts.upgrade || null;
|
|
44
|
+
const setIv = opts.setInterval || setInterval;
|
|
45
|
+
const clearIv = opts.clearInterval || clearInterval;
|
|
46
|
+
const exit = opts.exit || ((code) => process.exit(code));
|
|
47
|
+
const canUpgrade = !!upgrade;
|
|
48
|
+
|
|
49
|
+
let savedInitialize = null; // client's initialize request, replayed to the child
|
|
50
|
+
let child = null; // real binary, once upgraded
|
|
51
|
+
let childReady = false; // child finished its (replayed) handshake
|
|
52
|
+
const queuedForChild = []; // client lines seen after spawn, before child is ready
|
|
53
|
+
let poller = null;
|
|
54
|
+
let upgradeFailures = 0; // consecutive failed upgrade attempts (see noteUpgradeFailure)
|
|
55
|
+
|
|
56
|
+
function writeCc(obj) { output.write(JSON.stringify(obj) + '\n'); }
|
|
57
|
+
|
|
58
|
+
function stubInitializeResult() {
|
|
59
|
+
if (canUpgrade) {
|
|
60
|
+
// Only tools.listChanged is load-bearing (it lets the client honor our
|
|
61
|
+
// later notifications/tools/list_changed). Deliberately NOT advertising
|
|
62
|
+
// resources/prompts here so a genuine /tmp caller that never upgrades
|
|
63
|
+
// stays as cheap as the permanent stub (no extra resources/prompts probes).
|
|
64
|
+
return {
|
|
65
|
+
protocolVersion: '2024-11-05',
|
|
66
|
+
capabilities: { tools: { listChanged: true } },
|
|
67
|
+
serverInfo: { name: 'code-graph-mcp (plugin stub, upgrading)', version: '0.31.1' },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
protocolVersion: '2024-11-05',
|
|
72
|
+
capabilities: { tools: { listChanged: false } },
|
|
73
|
+
serverInfo: { name: 'code-graph-mcp (plugin stub, dedup)', version: '0.31.1' },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function answerAsStub(req) {
|
|
78
|
+
if (typeof req.id === 'undefined') return; // JSON-RPC notification → no response
|
|
79
|
+
const m = req.method;
|
|
80
|
+
let result, error;
|
|
81
|
+
if (m === 'initialize') result = stubInitializeResult();
|
|
82
|
+
else if (m === 'tools/list') result = { tools: [] };
|
|
83
|
+
else if (m === 'resources/list') result = { resources: [] };
|
|
84
|
+
else if (m === 'prompts/list') result = { prompts: [] };
|
|
85
|
+
else error = {
|
|
86
|
+
code: -32601,
|
|
87
|
+
message: canUpgrade
|
|
88
|
+
? 'method not found (plugin MCP stub; upgrades when cwd becomes a project)'
|
|
89
|
+
: 'method not found (plugin MCP is in dedup stub mode)',
|
|
90
|
+
};
|
|
91
|
+
writeCc(error ? { jsonrpc: '2.0', id: req.id, error } : { jsonrpc: '2.0', id: req.id, result });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- client → stub (or → child once proxying) ----
|
|
95
|
+
let buf = '';
|
|
96
|
+
input.setEncoding('utf8');
|
|
97
|
+
input.on('data', (chunk) => {
|
|
98
|
+
buf += chunk;
|
|
99
|
+
let nl;
|
|
100
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
101
|
+
const line = buf.slice(0, nl).trim();
|
|
102
|
+
buf = buf.slice(nl + 1);
|
|
103
|
+
if (!line) continue;
|
|
104
|
+
if (child) { // proxy mode
|
|
105
|
+
if (childReady) {
|
|
106
|
+
try { child.stdin.write(line + '\n'); }
|
|
107
|
+
catch { /* child stream gone; 'error'/'exit' handler cleans up */ }
|
|
108
|
+
} else {
|
|
109
|
+
queuedForChild.push(line);
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
let req;
|
|
114
|
+
try { req = JSON.parse(line); } catch { continue; }
|
|
115
|
+
if (!req || typeof req.method !== 'string') continue;
|
|
116
|
+
if (req.method === 'initialize') savedInitialize = req;
|
|
117
|
+
answerAsStub(req);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
input.on('end', () => {
|
|
121
|
+
if (child) { try { child.stdin.end(); } catch { /* ok */ } }
|
|
122
|
+
else exit(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ---- upgrade: poll, then hand the live connection to the real binary ----
|
|
126
|
+
function noteUpgradeFailure(reason) {
|
|
127
|
+
// After the cap, stop polling and surface a one-time actionable hint instead
|
|
128
|
+
// of re-spawning a doomed binary forever.
|
|
129
|
+
if (++upgradeFailures < MAX_UPGRADE_FAILURES) return;
|
|
130
|
+
if (poller) { clearIv(poller); poller = null; }
|
|
131
|
+
process.stderr.write(`[code-graph] plugin MCP could not upgrade after ${upgradeFailures} attempts (${reason}); restart Claude Code once this project is set up. Staying in 0-tool stub.\n`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function attemptUpgrade() {
|
|
135
|
+
if (child || !upgrade) return;
|
|
136
|
+
if (!upgrade.shouldUpgrade()) return; // not a project yet — not a failure
|
|
137
|
+
const spawned = upgrade.spawnReal();
|
|
138
|
+
if (!spawned) { noteUpgradeFailure('binary-unresolved'); return; }
|
|
139
|
+
if (poller) { clearIv(poller); poller = null; }
|
|
140
|
+
child = spawned;
|
|
141
|
+
beginProxy();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function fallBackToStub(reason) {
|
|
145
|
+
// Child spawned but died/errored before it was ready: answer anything the
|
|
146
|
+
// client queued (so it doesn't hang on those ids), resume polling, and count
|
|
147
|
+
// the failure toward the retry cap.
|
|
148
|
+
process.stderr.write(`[code-graph] plugin MCP upgrade aborted (${reason}); staying in 0-tool stub, will retry\n`);
|
|
149
|
+
const requeued = queuedForChild.splice(0);
|
|
150
|
+
child = null;
|
|
151
|
+
childReady = false;
|
|
152
|
+
for (const line of requeued) {
|
|
153
|
+
try { const req = JSON.parse(line); if (req && typeof req.method === 'string') answerAsStub(req); }
|
|
154
|
+
catch { /* ignore */ }
|
|
155
|
+
}
|
|
156
|
+
if (upgrade && !poller) poller = setIv(attemptUpgrade, upgrade.pollMs || DEFAULT_POLL_MS);
|
|
157
|
+
noteUpgradeFailure(reason);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function beginProxy() {
|
|
161
|
+
const thisChild = child; // guard against terminal events from a superseded spawn
|
|
162
|
+
thisChild.stdin.on('error', () => { /* EPIPE if the child died — 'error'/'exit' handle it */ });
|
|
163
|
+
thisChild.stdout.on('error', () => {});
|
|
164
|
+
|
|
165
|
+
const params = (savedInitialize && savedInitialize.params) || {
|
|
166
|
+
protocolVersion: '2024-11-05', capabilities: {},
|
|
167
|
+
clientInfo: { name: 'code-graph-plugin-launcher', version: '0' },
|
|
168
|
+
};
|
|
169
|
+
// Replay initialize under a sentinel id; the child's reply is swallowed
|
|
170
|
+
// (the client already received OUR initialize result).
|
|
171
|
+
thisChild.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: SENTINEL_ID, method: 'initialize', params }) + '\n');
|
|
172
|
+
|
|
173
|
+
let cbuf = '';
|
|
174
|
+
thisChild.stdout.setEncoding('utf8');
|
|
175
|
+
thisChild.stdout.on('data', (chunk) => {
|
|
176
|
+
cbuf += chunk;
|
|
177
|
+
let nl;
|
|
178
|
+
while ((nl = cbuf.indexOf('\n')) >= 0) {
|
|
179
|
+
const line = cbuf.slice(0, nl);
|
|
180
|
+
cbuf = cbuf.slice(nl + 1);
|
|
181
|
+
if (!line.trim()) continue;
|
|
182
|
+
let msg = null;
|
|
183
|
+
try { msg = JSON.parse(line); } catch { /* forward non-JSON verbatim */ }
|
|
184
|
+
if (msg && msg.id === SENTINEL_ID) {
|
|
185
|
+
// Child handshake complete: finish MCP init, flush queued client
|
|
186
|
+
// requests, and tell the client its tool list changed.
|
|
187
|
+
thisChild.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
|
|
188
|
+
childReady = true;
|
|
189
|
+
for (const l of queuedForChild.splice(0)) thisChild.stdin.write(l + '\n');
|
|
190
|
+
writeCc({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' });
|
|
191
|
+
continue; // swallow the sentinel reply
|
|
192
|
+
}
|
|
193
|
+
output.write(line + '\n'); // forward child → client verbatim
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
thisChild.on('error', () => {
|
|
197
|
+
if (child !== thisChild) return; // superseded spawn — ignore
|
|
198
|
+
if (childReady) { exit(1); return; } // a ready child broke → die like the binary died (avoid a flapping stub)
|
|
199
|
+
fallBackToStub('spawn-error');
|
|
200
|
+
});
|
|
201
|
+
thisChild.on('exit', (code, signal) => {
|
|
202
|
+
if (child !== thisChild) return;
|
|
203
|
+
if (!childReady) { fallBackToStub('early-exit'); return; }
|
|
204
|
+
if (signal) process.kill(process.pid, signal);
|
|
205
|
+
else exit(code == null ? 1 : code);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (upgrade) poller = setIv(attemptUpgrade, upgrade.pollMs || DEFAULT_POLL_MS);
|
|
210
|
+
|
|
211
|
+
return { attemptUpgrade, _state: () => ({ hasChild: !!child, childReady }) };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
module.exports = { serveEmptyMcpStub, SENTINEL_ID, DEFAULT_POLL_MS, MAX_UPGRADE_FAILURES };
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test = require('node:test');
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const { PassThrough } = require('stream');
|
|
5
|
+
const { EventEmitter } = require('events');
|
|
6
|
+
const { serveEmptyMcpStub, SENTINEL_ID } = require('./mcp-stub');
|
|
7
|
+
|
|
8
|
+
const tick = () => new Promise((r) => setImmediate(r));
|
|
9
|
+
|
|
10
|
+
// A fake child_process: collect what the launcher writes to child.stdin, and
|
|
11
|
+
// let the test push child.stdout lines + emit exit/error.
|
|
12
|
+
function makeFakeChild() {
|
|
13
|
+
const stdin = new PassThrough();
|
|
14
|
+
const stdout = new PassThrough();
|
|
15
|
+
const ee = new EventEmitter();
|
|
16
|
+
const linesToChild = [];
|
|
17
|
+
let sbuf = '';
|
|
18
|
+
stdin.setEncoding('utf8');
|
|
19
|
+
stdin.on('data', (c) => {
|
|
20
|
+
sbuf += c;
|
|
21
|
+
let nl;
|
|
22
|
+
while ((nl = sbuf.indexOf('\n')) >= 0) { linesToChild.push(sbuf.slice(0, nl)); sbuf = sbuf.slice(nl + 1); }
|
|
23
|
+
});
|
|
24
|
+
return {
|
|
25
|
+
stdin, stdout,
|
|
26
|
+
on: (e, cb) => ee.on(e, cb),
|
|
27
|
+
emit: (e, ...a) => ee.emit(e, ...a),
|
|
28
|
+
linesToChild,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeRig(upgrade) {
|
|
33
|
+
const input = new PassThrough();
|
|
34
|
+
const out = [];
|
|
35
|
+
const output = { write: (s) => { out.push(s); return true; } };
|
|
36
|
+
let exitCode = null;
|
|
37
|
+
const handle = serveEmptyMcpStub({
|
|
38
|
+
input, output,
|
|
39
|
+
setInterval: () => 0, clearInterval: () => {}, // no real timer; drive attemptUpgrade() manually
|
|
40
|
+
exit: (c) => { exitCode = c; },
|
|
41
|
+
upgrade,
|
|
42
|
+
});
|
|
43
|
+
const send = (obj) => input.write(JSON.stringify(obj) + '\n');
|
|
44
|
+
return { input, out, handle, send, getExit: () => exitCode };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const INIT = { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'cc', version: '1' } } };
|
|
48
|
+
|
|
49
|
+
test('permanent stub: 0 tools, listChanged:false, unknown method → -32601', async () => {
|
|
50
|
+
const { out, send } = makeRig(null);
|
|
51
|
+
send(INIT);
|
|
52
|
+
send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
53
|
+
send({ jsonrpc: '2.0', id: 3, method: 'nope/nope', params: {} });
|
|
54
|
+
await tick();
|
|
55
|
+
const initResp = JSON.parse(out[0]);
|
|
56
|
+
assert.equal(initResp.result.capabilities.tools.listChanged, false);
|
|
57
|
+
assert.match(initResp.result.serverInfo.name, /dedup/);
|
|
58
|
+
assert.deepEqual(JSON.parse(out[1]).result.tools, []);
|
|
59
|
+
assert.equal(JSON.parse(out[2]).error.code, -32601);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('upgradeable stub advertises tools.listChanged:true', async () => {
|
|
63
|
+
const { out, send } = makeRig({ shouldUpgrade: () => false, spawnReal: () => null });
|
|
64
|
+
send(INIT);
|
|
65
|
+
await tick();
|
|
66
|
+
assert.equal(JSON.parse(out[0]).result.capabilities.tools.listChanged, true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('upgradeable stub does NOT spawn while shouldUpgrade() is false', async () => {
|
|
70
|
+
let spawnCalls = 0;
|
|
71
|
+
const { handle } = makeRig({ shouldUpgrade: () => false, spawnReal: () => { spawnCalls++; return null; } });
|
|
72
|
+
handle.attemptUpgrade();
|
|
73
|
+
await tick();
|
|
74
|
+
assert.equal(spawnCalls, 0);
|
|
75
|
+
assert.equal(handle._state().hasChild, false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('upgrade handoff: proxies to real binary + emits tools/list_changed + forwards real tools', async () => {
|
|
79
|
+
const child = makeFakeChild();
|
|
80
|
+
let allow = false;
|
|
81
|
+
const { out, send, handle } = makeRig({ shouldUpgrade: () => allow, spawnReal: () => child });
|
|
82
|
+
|
|
83
|
+
// 1. client initialize + tools/list while still a non-project stub
|
|
84
|
+
send(INIT);
|
|
85
|
+
send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
86
|
+
await tick();
|
|
87
|
+
assert.equal(JSON.parse(out[0]).result.capabilities.tools.listChanged, true);
|
|
88
|
+
assert.deepEqual(JSON.parse(out[1]).result.tools, []); // stub → empty
|
|
89
|
+
const outLenBeforeUpgrade = out.length;
|
|
90
|
+
|
|
91
|
+
// 2. cwd becomes a project → upgrade fires
|
|
92
|
+
allow = true;
|
|
93
|
+
handle.attemptUpgrade();
|
|
94
|
+
await tick();
|
|
95
|
+
assert.equal(handle._state().hasChild, true);
|
|
96
|
+
// launcher replayed initialize to the child under the sentinel id
|
|
97
|
+
const replay = JSON.parse(child.linesToChild[0]);
|
|
98
|
+
assert.equal(replay.method, 'initialize');
|
|
99
|
+
assert.equal(replay.id, SENTINEL_ID);
|
|
100
|
+
|
|
101
|
+
// 3. child answers the sentinel initialize
|
|
102
|
+
child.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: SENTINEL_ID, result: { capabilities: { tools: { listChanged: false } }, instructions: 'real server' } }) + '\n');
|
|
103
|
+
await tick();
|
|
104
|
+
// launcher must: send initialized to child, and tools/list_changed to client
|
|
105
|
+
assert.ok(child.linesToChild.some((l) => JSON.parse(l).method === 'notifications/initialized'));
|
|
106
|
+
const newClientMsgs = out.slice(outLenBeforeUpgrade).map((s) => JSON.parse(s));
|
|
107
|
+
assert.ok(newClientMsgs.some((m) => m.method === 'notifications/tools/list_changed'),
|
|
108
|
+
'client must be told tools changed');
|
|
109
|
+
// the sentinel init reply must NOT leak to the client
|
|
110
|
+
assert.ok(!newClientMsgs.some((m) => m.id === SENTINEL_ID), 'sentinel reply must be swallowed');
|
|
111
|
+
|
|
112
|
+
// 4. client re-requests tools/list → forwarded to child (not answered empty)
|
|
113
|
+
const childLinesBefore = child.linesToChild.length;
|
|
114
|
+
send({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} });
|
|
115
|
+
await tick();
|
|
116
|
+
const forwarded = child.linesToChild.slice(childLinesBefore).map((l) => JSON.parse(l));
|
|
117
|
+
assert.ok(forwarded.some((m) => m.id === 3 && m.method === 'tools/list'), 'tools/list forwarded to real binary');
|
|
118
|
+
|
|
119
|
+
// 5. child returns real tools → forwarded verbatim to client
|
|
120
|
+
child.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: 3, result: { tools: [{ name: 'get_call_graph' }, { name: 'semantic_code_search' }] } }) + '\n');
|
|
121
|
+
await tick();
|
|
122
|
+
const toolResp = out.map((s) => JSON.parse(s)).find((m) => m.id === 3);
|
|
123
|
+
assert.ok(toolResp, 'client received a tools/list response');
|
|
124
|
+
assert.deepEqual(toolResp.result.tools.map((t) => t.name), ['get_call_graph', 'semantic_code_search']);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('upgrade with unresolvable binary stays a stub and keeps answering', async () => {
|
|
128
|
+
let allow = true;
|
|
129
|
+
const { out, send, handle } = makeRig({ shouldUpgrade: () => allow, spawnReal: () => null });
|
|
130
|
+
send(INIT);
|
|
131
|
+
await tick();
|
|
132
|
+
handle.attemptUpgrade(); // spawnReal returns null → no child
|
|
133
|
+
await tick();
|
|
134
|
+
assert.equal(handle._state().hasChild, false);
|
|
135
|
+
send({ jsonrpc: '2.0', id: 9, method: 'tools/list', params: {} });
|
|
136
|
+
await tick();
|
|
137
|
+
const resp = out.map((s) => JSON.parse(s)).find((m) => m.id === 9);
|
|
138
|
+
assert.deepEqual(resp.result.tools, []); // still served by the stub
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('upgrade: a request in the handoff window is queued then flushed to the child in order', async () => {
|
|
142
|
+
const child = makeFakeChild();
|
|
143
|
+
const { send, handle } = makeRig({ shouldUpgrade: () => true, spawnReal: () => child });
|
|
144
|
+
send(INIT);
|
|
145
|
+
await tick();
|
|
146
|
+
handle.attemptUpgrade(); // child spawned; sentinel initialize sent; NOT ready yet
|
|
147
|
+
await tick();
|
|
148
|
+
// client sends a call while the child is mid-handshake → must be queued, not forwarded
|
|
149
|
+
send({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'x' } });
|
|
150
|
+
await tick();
|
|
151
|
+
assert.equal(child.linesToChild.length, 1);
|
|
152
|
+
assert.equal(JSON.parse(child.linesToChild[0]).id, SENTINEL_ID);
|
|
153
|
+
// child completes its handshake → queued request flushes AFTER initialized
|
|
154
|
+
child.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: SENTINEL_ID, result: { capabilities: {} } }) + '\n');
|
|
155
|
+
await tick();
|
|
156
|
+
const methods = child.linesToChild.map((l) => { const m = JSON.parse(l); return m.method || `id:${m.id}`; });
|
|
157
|
+
assert.deepEqual(methods, ['initialize', 'notifications/initialized', 'tools/call']);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('upgrade: child that exits before ready falls back to stub and answers queued requests', async () => {
|
|
161
|
+
const child = makeFakeChild();
|
|
162
|
+
const { out, send, handle } = makeRig({ shouldUpgrade: () => true, spawnReal: () => child });
|
|
163
|
+
send(INIT);
|
|
164
|
+
await tick();
|
|
165
|
+
handle.attemptUpgrade(); // child spawned, not ready
|
|
166
|
+
await tick();
|
|
167
|
+
send({ jsonrpc: '2.0', id: 8, method: 'tools/list', params: {} }); // queued during handoff
|
|
168
|
+
await tick();
|
|
169
|
+
child.emit('exit', 1, null); // child dies BEFORE the sentinel reply
|
|
170
|
+
await tick();
|
|
171
|
+
assert.equal(handle._state().hasChild, false); // reverted to stub
|
|
172
|
+
const resp = out.map((s) => JSON.parse(s)).find((m) => m.id === 8);
|
|
173
|
+
assert.ok(resp, 'queued request got a stub answer (client not left hanging)');
|
|
174
|
+
assert.deepEqual(resp.result.tools, []);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('upgrade: error on a READY child exits instead of reverting to a flapping stub', async () => {
|
|
178
|
+
const child = makeFakeChild();
|
|
179
|
+
const { send, handle, getExit } = makeRig({ shouldUpgrade: () => true, spawnReal: () => child });
|
|
180
|
+
send(INIT);
|
|
181
|
+
await tick();
|
|
182
|
+
handle.attemptUpgrade();
|
|
183
|
+
await tick();
|
|
184
|
+
child.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: SENTINEL_ID, result: { capabilities: {} } }) + '\n');
|
|
185
|
+
await tick();
|
|
186
|
+
assert.equal(handle._state().childReady, true);
|
|
187
|
+
child.emit('error', new Error('boom')); // a READY child errors
|
|
188
|
+
await tick();
|
|
189
|
+
assert.equal(handle._state().hasChild, true, 'must NOT revert to stub (that would flap)');
|
|
190
|
+
assert.equal(getExit(), 1, 'ready-child error exits the launcher like the binary died');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('poller is wired to attemptUpgrade at the default interval', () => {
|
|
194
|
+
const input = new PassThrough();
|
|
195
|
+
const out = [];
|
|
196
|
+
let captured = null, spawnCalls = 0;
|
|
197
|
+
serveEmptyMcpStub({
|
|
198
|
+
input, output: { write: (s) => out.push(s) },
|
|
199
|
+
setInterval: (fn, ms) => { captured = { fn, ms }; return 1; },
|
|
200
|
+
clearInterval: () => {}, exit: () => {},
|
|
201
|
+
upgrade: { shouldUpgrade: () => true, spawnReal: () => { spawnCalls++; return null; } },
|
|
202
|
+
});
|
|
203
|
+
assert.ok(captured, 'poller started via setInterval');
|
|
204
|
+
assert.equal(captured.ms, 4000); // DEFAULT_POLL_MS
|
|
205
|
+
captured.fn(); // simulate one poll tick
|
|
206
|
+
assert.equal(spawnCalls, 1); // tick attempted the upgrade
|
|
207
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdsrs/code-graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.85.0",
|
|
4
4
|
"description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"node": ">=16"
|
|
36
36
|
},
|
|
37
37
|
"optionalDependencies": {
|
|
38
|
-
"@sdsrs/code-graph-linux-x64": "0.
|
|
39
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
40
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
41
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
42
|
-
"@sdsrs/code-graph-win32-x64": "0.
|
|
38
|
+
"@sdsrs/code-graph-linux-x64": "0.85.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.85.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.85.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.85.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.85.0"
|
|
43
43
|
}
|
|
44
44
|
}
|