mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
* feat(mcp): paginate list_repos to avoid client token truncation (#2119) list_repos returned every indexed repository in one unpaginated array, which large/LLM MCP clients truncate by token limit — so agents with hundreds of indexed repos could not enumerate them all (the data transmits fully; the consuming client drops it). Add bounded limit/offset pagination to the list_repos tool: - result changes from a bare array to { repositories, pagination: { total, limit, offset, returned, hasMore, nextOffset } }; default page 50, max 200 (shared constants) - reject malformed limit/offset; clamp limit above the max - deterministic order (lower-cased name, then path) over one registry snapshot per call, so paging never skips or duplicates an entry - covers both stdio and remote /api/mcp (shared createMCPServer/callTool) The internal listRepos() method (5 callers), GET /api/repos, and the `gitnexus list` CLI are unchanged. The array->object tool-result shape is a deliberate contract change, documented in CHANGELOG. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mcp): reject list_repos limit above the max instead of clamping (#2119) parseListReposPagination silently clamped limit>max to the maximum while throwing on every other out-of-bounds value (limit<1, offset<0, non-integer, NaN). A client that advanced offset by its requested limit (rather than pagination.nextOffset) then silently skipped repositories and saw hasMore:false — defeating the "never skips" guarantee. Reject an over-max limit too, so validation is symmetric and a caller never gets a smaller page than it asked for without a clear error. Updates the schema/description, the helper + ListReposPagination JSDoc, the guide note, and the two clamp tests. Resolves the cross-engine-corroborated P2 (Codex + adversarial lane) and the maintainability lane's clamp-vs-throw inconsistency from the PR #2120 review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(mcp): name the list_repos return type and mark the parser @internal Extract the inline listRepos() element shape into an exported RepoListing interface and use it for both listRepos() and listReposPage().repositories, replacing the opaque Awaited<ReturnType<LocalBackend['listRepos']>> expression the maintainability review flagged. Tag parseListReposPagination @internal (it is exported only for unit testing). Pure type/JSDoc change; no behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(eval-server): type formatListReposResult to the paginated shape Narrow formatListReposResult's parameter from `any` to { repositories: RepoListing[]; pagination?: ListReposPagination } and drop the dead bare-array branch — after #2119 callTool('list_repos') always returns the paginated object, so the Array.isArray shim was unreachable. Add a list_repos continuation hint to the eval-server's getNextStepHint (parity with the MCP server), and cover the previously-untested non-empty + hasMore:false formatter branch. Migrates the two bare-array formatter tests to the object shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(mcp): harden list_repos pagination coverage - Exercise the #2054 sibling-clone guarantee through the real callTool tool path (in the #2054 describe, which has temp-dir cleanup), proving siblings and remoteUrl survive listReposPage's sort+slice — not only listRepos(). - Assert total + limit on the middle-page test (a total miscalculation at a non-zero offset would otherwise slip past it). - Cover the benign boundaries: negative-zero offset (accepted as page 0) and a MAX_SAFE_INTEGER offset (empty page). - Replace the integration test's '\n\n---' split with a string-aware brace scan, so a repo path containing braces can never truncate the JSON parse. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(skills): sync the list_repos pagination example to the guide mirrors The .claude and gitnexus-claude-plugin guide mirrors only carried the one-line table note; add the full "Paginating list_repos" section (shape + multi-page traversal example + notes) so all three guide copies are byte-consistent with the canonical gitnexus/skills/gitnexus-guide.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: drop list_repos CHANGELOG entries from this PR Restore gitnexus/CHANGELOG.md to match main so this PR contributes no changelog change; the changelog is curated separately from feature PRs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
/**
|
|
* MCP server end-to-end startup test.
|
|
*
|
|
* Spawns `node dist/cli/index.js mcp` as a child process, drives the MCP
|
|
* stdio handshake (initialize → initialized → tools/list), and asserts:
|
|
*
|
|
* - The first JSON-RPC frame arrives within a CI-friendly time budget.
|
|
* - Every byte the server writes to stdout reassembles into a valid
|
|
* Content-Length-framed JSON-RPC message — any stray byte fails the
|
|
* test and is surfaced in the assertion message.
|
|
* - tools/list reports the GitNexus tool set we expect.
|
|
*
|
|
* This locks in U1 (no stray console.log/warn in MCP-reachable code) and
|
|
* U3 (AsyncLocalStorage stdout sentinel). A regression in either would
|
|
* present as either a non-frame byte on stdout (fail-fast) or a missing
|
|
* frame (timeout).
|
|
*
|
|
* Requires the built dist/. Use `npm run test:integration` (which runs
|
|
* `npm run build` via the pretest:integration hook) or run after
|
|
* `npm run build`.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
import path from 'node:path';
|
|
import fs from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
const DIST_CLI = path.join(REPO_ROOT, 'dist', 'cli', 'index.js');
|
|
|
|
const FIRST_FRAME_BUDGET_MS = process.env.CI ? 15_000 : 5_000;
|
|
const TOTAL_BUDGET_MS = process.env.CI ? 30_000 : 10_000;
|
|
|
|
interface SpawnedServer {
|
|
proc: ChildProcessWithoutNullStreams;
|
|
stdoutChunks: Buffer[];
|
|
stderrChunks: Buffer[];
|
|
/** Resolves with the next JSON-RPC message parsed from stdout. */
|
|
nextMessage: () => Promise<unknown>;
|
|
/** Bytes received on stdout that did NOT belong to a frame body. */
|
|
strayStdoutBytes: () => Buffer;
|
|
send: (message: unknown) => void;
|
|
close: () => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Spawn the built MCP server and provide a frame-aware reader.
|
|
* The reader strictly parses Content-Length framing; any byte outside
|
|
* a valid header→body window is captured as "stray" so the test can
|
|
* assert the stream is clean.
|
|
*/
|
|
function spawnMcpServer(): SpawnedServer {
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
// Avoid adding indexed repos noise to the test.
|
|
GITNEXUS_HOME: path.join(REPO_ROOT, 'test', 'integration', 'mcp', '.tmp-home'),
|
|
// Be deterministic across machines.
|
|
NODE_OPTIONS: '',
|
|
};
|
|
|
|
const proc = spawn(process.execPath, [DIST_CLI, 'mcp'], {
|
|
cwd: REPO_ROOT,
|
|
env,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
});
|
|
|
|
const stdoutChunks: Buffer[] = [];
|
|
const stderrChunks: Buffer[] = [];
|
|
const stray: Buffer[] = [];
|
|
const messageQueue: unknown[] = [];
|
|
const waiters: Array<(msg: unknown) => void> = [];
|
|
|
|
let buffer = Buffer.alloc(0);
|
|
// Parser state machine for Content-Length framing.
|
|
// 0 = expecting header, 1 = reading body of length `expected`.
|
|
let state: 0 | 1 = 0;
|
|
let expected = 0;
|
|
|
|
const HEADER_END = Buffer.from('\r\n\r\n', 'utf8');
|
|
|
|
function pushMessage(msg: unknown) {
|
|
if (waiters.length > 0) {
|
|
const w = waiters.shift()!;
|
|
w(msg);
|
|
} else {
|
|
messageQueue.push(msg);
|
|
}
|
|
}
|
|
|
|
function tryParse() {
|
|
while (true) {
|
|
if (state === 0) {
|
|
const hdrEnd = buffer.indexOf(HEADER_END);
|
|
if (hdrEnd === -1) return;
|
|
const header = buffer.subarray(0, hdrEnd).toString('utf8');
|
|
// Anything before the Content-Length: line (e.g. random bytes) is stray.
|
|
// Strict: the header MUST start with "Content-Length:" (case-insensitive).
|
|
const m = /^Content-Length:\s*(\d+)\s*$/im.exec(header);
|
|
if (!m) {
|
|
stray.push(buffer.subarray(0, hdrEnd + HEADER_END.length));
|
|
buffer = buffer.subarray(hdrEnd + HEADER_END.length);
|
|
continue;
|
|
}
|
|
// If there's text between buffer start and the header line, it's stray
|
|
// unless the entire header parsed cleanly with no preamble.
|
|
const headerStart = header.search(/Content-Length:/i);
|
|
if (headerStart > 0) {
|
|
stray.push(buffer.subarray(0, headerStart));
|
|
buffer = buffer.subarray(headerStart);
|
|
continue;
|
|
}
|
|
expected = parseInt(m[1], 10);
|
|
buffer = buffer.subarray(hdrEnd + HEADER_END.length);
|
|
state = 1;
|
|
}
|
|
if (state === 1) {
|
|
if (buffer.length < expected) return;
|
|
const bodyBuf = buffer.subarray(0, expected);
|
|
buffer = buffer.subarray(expected);
|
|
state = 0;
|
|
try {
|
|
pushMessage(JSON.parse(bodyBuf.toString('utf8')));
|
|
} catch (err) {
|
|
// Body that doesn't parse as JSON is a fatal protocol error.
|
|
stray.push(bodyBuf);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
proc.stdout.on('data', (chunk: Buffer) => {
|
|
stdoutChunks.push(chunk);
|
|
buffer = Buffer.concat([buffer, chunk]);
|
|
tryParse();
|
|
});
|
|
proc.stderr.on('data', (chunk: Buffer) => {
|
|
stderrChunks.push(chunk);
|
|
});
|
|
|
|
return {
|
|
proc,
|
|
stdoutChunks,
|
|
stderrChunks,
|
|
nextMessage: () =>
|
|
new Promise<unknown>((resolve, reject) => {
|
|
if (messageQueue.length > 0) {
|
|
resolve(messageQueue.shift());
|
|
return;
|
|
}
|
|
const timer = setTimeout(() => {
|
|
reject(
|
|
new Error(
|
|
`Timed out waiting for JSON-RPC message. stderr so far:\n${Buffer.concat(stderrChunks).toString('utf8')}`,
|
|
),
|
|
);
|
|
}, TOTAL_BUDGET_MS);
|
|
waiters.push((msg) => {
|
|
clearTimeout(timer);
|
|
resolve(msg);
|
|
});
|
|
}),
|
|
strayStdoutBytes: () => {
|
|
// Include any leftover unparsed buffer.
|
|
const tail = state === 0 ? buffer : Buffer.alloc(0);
|
|
return Buffer.concat([...stray, tail]);
|
|
},
|
|
send: (message: unknown) => {
|
|
const body = JSON.stringify(message);
|
|
const frame = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`;
|
|
proc.stdin.write(frame);
|
|
},
|
|
close: async () => {
|
|
proc.stdin.end();
|
|
// Give the server a moment to clean up; force-kill if it hangs.
|
|
await new Promise<void>((resolve) => {
|
|
const killer = setTimeout(() => {
|
|
proc.kill('SIGKILL');
|
|
resolve();
|
|
}, 2000);
|
|
proc.on('close', () => {
|
|
clearTimeout(killer);
|
|
resolve();
|
|
});
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('MCP server end-to-end startup', () => {
|
|
it('preserves JSON-RPC stdout discipline through initialize + tools/list + tools/call', async () => {
|
|
if (!fs.existsSync(DIST_CLI)) {
|
|
throw new Error(
|
|
`dist/cli/index.js missing — run \`npm run build\` first (or use \`npm run test:integration\` which builds via pretest:integration).`,
|
|
);
|
|
}
|
|
|
|
const server = spawnMcpServer();
|
|
try {
|
|
// initialize handshake
|
|
const startedAt = Date.now();
|
|
server.send({
|
|
jsonrpc: '2.0',
|
|
id: 1,
|
|
method: 'initialize',
|
|
params: {
|
|
protocolVersion: '2025-06-18',
|
|
capabilities: {},
|
|
clientInfo: { name: 'gitnexus-startup-test', version: '0.0.0' },
|
|
},
|
|
});
|
|
|
|
const initResponse = (await server.nextMessage()) as {
|
|
jsonrpc: string;
|
|
id: number;
|
|
result?: { protocolVersion: string; serverInfo: { name: string } };
|
|
error?: unknown;
|
|
};
|
|
const firstFrameAt = Date.now();
|
|
|
|
expect(initResponse.jsonrpc).toBe('2.0');
|
|
expect(initResponse.id).toBe(1);
|
|
expect(initResponse.error).toBeUndefined();
|
|
expect(initResponse.result).toBeDefined();
|
|
expect(initResponse.result!.serverInfo.name).toMatch(/gitnexus/i);
|
|
expect(firstFrameAt - startedAt).toBeLessThan(FIRST_FRAME_BUDGET_MS);
|
|
|
|
// initialized notification (no response expected)
|
|
server.send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
|
|
// tools/list
|
|
server.send({ jsonrpc: '2.0', id: 2, method: 'tools/list' });
|
|
const toolsResponse = (await server.nextMessage()) as {
|
|
jsonrpc: string;
|
|
id: number;
|
|
result?: { tools: Array<{ name: string }> };
|
|
};
|
|
|
|
expect(toolsResponse.id).toBe(2);
|
|
expect(toolsResponse.result).toBeDefined();
|
|
const toolNames = (toolsResponse.result!.tools ?? []).map((t) => t.name);
|
|
// The published GitNexus tool set. Adjust if the surface changes.
|
|
const expectedTools = [
|
|
'list_repos',
|
|
'query',
|
|
'context',
|
|
'impact',
|
|
'detect_changes',
|
|
'rename',
|
|
];
|
|
for (const t of expectedTools) {
|
|
expect(toolNames).toContain(t);
|
|
}
|
|
|
|
// tools/call list_repos — proves the paginated { repositories, pagination }
|
|
// shape survives the real request → backend.callTool → JSON.stringify →
|
|
// content[0].text serialization path (#2119), independent of repo count.
|
|
server.send({
|
|
jsonrpc: '2.0',
|
|
id: 3,
|
|
method: 'tools/call',
|
|
params: { name: 'list_repos', arguments: { limit: 5 } },
|
|
});
|
|
const callResponse = (await server.nextMessage()) as {
|
|
id: number;
|
|
result?: { content?: Array<{ type: string; text: string }>; isError?: boolean };
|
|
};
|
|
expect(callResponse.id).toBe(3);
|
|
expect(callResponse.result?.isError).not.toBe(true);
|
|
const callText = callResponse.result!.content![0].text;
|
|
// The server appends a non-JSON next-step hint after the JSON payload.
|
|
// Extract the leading JSON object with a string-aware brace scan so a repo
|
|
// path containing braces can never truncate the parse (more robust than
|
|
// splitting on the hint's separator).
|
|
const jsonStart = callText.indexOf('{');
|
|
let depth = 0;
|
|
let inStr = false;
|
|
let esc = false;
|
|
let jsonEnd = callText.length;
|
|
for (let i = jsonStart; i < callText.length; i++) {
|
|
const ch = callText[i];
|
|
if (esc) {
|
|
esc = false;
|
|
} else if (ch === '\\') {
|
|
esc = true;
|
|
} else if (ch === '"') {
|
|
inStr = !inStr;
|
|
} else if (!inStr && ch === '{') {
|
|
depth++;
|
|
} else if (!inStr && ch === '}' && --depth === 0) {
|
|
jsonEnd = i + 1;
|
|
break;
|
|
}
|
|
}
|
|
const payload = JSON.parse(callText.slice(jsonStart, jsonEnd));
|
|
expect(Array.isArray(payload.repositories)).toBe(true);
|
|
expect(typeof payload.pagination.total).toBe('number');
|
|
expect(payload.pagination.limit).toBe(5);
|
|
expect(payload.pagination.offset).toBe(0);
|
|
expect(payload.repositories.length).toBeLessThanOrEqual(5);
|
|
|
|
// The headline assertion: every byte the server emitted on stdout
|
|
// must reassemble into a valid JSON-RPC frame. Any leftover is a
|
|
// protocol-corruption regression.
|
|
const stray = server.strayStdoutBytes();
|
|
if (stray.length > 0) {
|
|
const stderr = Buffer.concat(server.stderrChunks).toString('utf8');
|
|
throw new Error(
|
|
`Stdout contained ${stray.length} bytes outside JSON-RPC framing — protocol corruption regression.\nStray bytes (utf8): ${JSON.stringify(stray.toString('utf8'))}\nStderr from server:\n${stderr}`,
|
|
);
|
|
}
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
}, 60_000);
|
|
});
|