GitNexus/gitnexus/test/integration/mcp/server-startup.test.ts
Gergő Magyar 4682a477d8
feat(mcp): paginate list_repos to avoid client token truncation (#2119) (#2120)
* 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>
2026-06-09 19:59:54 +01:00

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);
});