GitNexus/gitnexus/test/unit/server.test.ts
Gergő Magyar 30676685e0
fix(rename): apply the exact reviewed edit plan (#3617)
* fix(rename): bind apply to reviewed plans (U7)

Retain exact semantic edits behind single-use preview tokens and reject
source, target, inventory and graph drift before application. Preserve
explicit partial-write accounting and keep large unedited sources covered
with hashes instead of retained text.

Refs #2973

* fix(rename): cover unindexed sources (U7)

Address PR #3617 review by binding every supported source to streamed byte
hashes and canonical targets, including tracked and untracked files absent
from the graph. Keep the exact reviewed edits and bound evidence memory.

Use conventional even-sample medians and extend the existing CI benchmark
to capture and validate unindexed sources without relaxing timing gates.

Note: pre-existing packed Tree-sitter GLIBCXX_3.4.31 host failure in
parse-cache-identity-package.test.ts is not addressed by this PR.

* fix(rename): preserve lazy startup (U7)

Keep language providers off the MCP startup path; load their existing
classification only when rename inventory is requested. The existing
12 startup closure checks now pass.

Address PR #3617 review by including token validation in reviewed apply
timing and growing indexed evidence before preview, preserving the
20MiB controls under a 256MiB native pool. Migrate dispatch and retention
tests to the preview token contract, keeping write-failure oracles.

Note: pre-existing auto-sync /tmp fixture security and installed ARM64
grammar-header failures remain outside this PR; the packed Tree-sitter
GLIBCXX_3.4.31 host failure also reproduces on unchanged main.

* test(rename): always remove apply fixture (U7)

Own the temporary directory from creation in an outer finally so setup
and preview assertions cannot bypass cleanup. Keep spy restoration in
its inner finally and preserve all write-failure assertions.

Address PR #3617 cleanup feedback.

* fix(rename): retain targets and config evidence

* test(rename): verify approved physical output

* test(rename): cover native filesystem matrix

* test(rename): align preview contract fixtures

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2026-10-17 19:38:01 +01:00

741 lines
31 KiB
TypeScript

/**
* Unit Tests: MCP Server
*
* Tests: createMCPServer from server.ts
* - Server creation returns a Server instance
* - Tool handler wraps backend.callTool and appends hints
* - Tool handler catches errors and returns isError: true
* - Resource handlers delegate to resources.ts functions
* - Prompt handlers return expected prompts
* - Next-step hints cover all tool names
*
* NOTE: We test the server handler logic by calling the request handlers
* directly through the MCP Server's handler dispatch.
*/
import { describe, it, expect, vi } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
import {
createMCPServer,
getNextStepHint,
installSignalShutdown,
startMCPServer,
SHUTDOWN_EXIT_CODES,
} from '../../src/mcp/server.js';
import { GITNEXUS_TOOLS } from '../../src/mcp/tools.js';
import { createMcpRepositoryPolicy } from '../../src/mcp/repository-policy.js';
// ─── Mock backend ──────────────────────────────────────────────────
function createMockBackend(overrides: Record<string, any> = {}): any {
return {
callTool: vi.fn().mockResolvedValue({ result: 'ok' }),
listRepos: vi.fn().mockResolvedValue([]),
countRepos: vi.fn().mockResolvedValue(0),
cachedRepoCount: vi.fn().mockReturnValue(0),
resolveRepo: vi
.fn()
.mockResolvedValue({ name: 'test', repoPath: '/tmp/test', lastCommit: 'abc' }),
selectToolRepository: vi
.fn()
.mockResolvedValue({ name: 'test', repoPath: '/tmp/test', lastCommit: 'abc' }),
getContext: vi.fn().mockReturnValue(null),
queryClusters: vi.fn().mockResolvedValue({ clusters: [] }),
queryProcesses: vi.fn().mockResolvedValue({ processes: [] }),
queryClusterDetail: vi.fn().mockResolvedValue({ error: 'not found' }),
queryProcessDetail: vi.fn().mockResolvedValue({ error: 'not found' }),
disconnect: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
async function callToolThroughServer(
backend: ReturnType<typeof createMockBackend>,
name: string,
args: Record<string, unknown>,
): Promise<{ text: string; isError: boolean }> {
const server = createMCPServer(backend);
const client = new Client({ name: 'budget-test-client', version: '0.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
try {
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
const response = CallToolResultSchema.parse(await client.callTool({ name, arguments: args }));
const text = response.content.find((item) => item.type === 'text')?.text;
if (typeof text !== 'string') throw new Error('Expected an MCP text response');
return { text, isError: response.isError === true };
} finally {
await client.close();
await server.close();
}
}
// ─── createMCPServer ─────────────────────────────────────────────────
describe('createMCPServer', () => {
it('returns a Server instance with expected shape', () => {
const backend = createMockBackend();
const server = createMCPServer(backend);
expect(server).toBeDefined();
// Server should have connect/close methods
expect(typeof server.connect).toBe('function');
expect(typeof server.close).toBe('function');
});
it('server has setRequestHandler method', () => {
const backend = createMockBackend();
const server = createMCPServer(backend);
// The server has registered handlers — verify it was created without errors
expect(server).toBeTruthy();
});
it('tools/list response includes tool annotations', async () => {
const backend = createMockBackend();
const server = createMCPServer(backend);
const client = new Client({ name: 'test-client', version: '0.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
try {
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
const response = await client.listTools();
expect(response.tools).toHaveLength(GITNEXUS_TOOLS.length);
for (const tool of response.tools) {
const definition = GITNEXUS_TOOLS.find((t) => t.name === tool.name)!;
expect(tool.annotations).toEqual(definition.annotations);
}
} finally {
await client.close();
await server.close();
}
});
it('requires repo in repo-scoped tool schemas when cwd cannot resolve multiple repos', async () => {
const backend = createMockBackend({
countRepos: vi.fn().mockResolvedValue(2),
listRepos: vi.fn().mockResolvedValue([
{ name: 'alpha', path: '/tmp/alpha' },
{ name: 'beta', path: '/tmp/beta' },
]),
selectToolRepository: vi.fn().mockRejectedValue(new Error('Multiple repositories indexed')),
});
const server = createMCPServer(backend);
const client = new Client({ name: 'multi-repo-client', version: '0.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
try {
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
const tools = await client.listTools();
const query = tools.tools.find((tool) => tool.name === 'query');
const listRepos = tools.tools.find((tool) => tool.name === 'list_repos');
expect(query?.inputSchema.required).toContain('repo');
expect(listRepos?.inputSchema.required).not.toContain('repo');
for (const name of ['read_file', 'grep'] as const) {
const tool = tools.tools.find((candidate) => candidate.name === name);
expect(tool?.inputSchema.required, name).toContain('repo');
expect(tool?.inputSchema.properties, name).not.toHaveProperty('branch');
}
expect(
GITNEXUS_TOOLS.find((tool) => tool.name === 'query')?.inputSchema.required,
).not.toContain('repo');
} finally {
await client.close();
await server.close();
}
});
it('keeps repo optional when cwd resolves one of multiple visible repos', async () => {
const backend = createMockBackend({
countRepos: vi.fn().mockResolvedValue(2),
cachedRepoCount: vi.fn().mockReturnValue(2),
listRepos: vi.fn().mockResolvedValue([
{ name: 'alpha', path: '/tmp/alpha' },
{ name: 'beta', path: '/tmp/beta' },
]),
selectToolRepository: vi
.fn()
.mockResolvedValue({ name: 'alpha', repoPath: '/tmp/alpha', lastCommit: 'abc' }),
});
const server = createMCPServer(backend);
const client = new Client({ name: 'cwd-repo-client', version: '0.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
try {
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
const tools = await client.listTools();
const context = tools.tools.find((tool) => tool.name === 'context');
const rename = tools.tools.find((tool) => tool.name === 'rename');
expect(context?.inputSchema.required).not.toContain('repo');
expect(rename?.inputSchema.required).toContain('repo');
const response = await client.callTool({ name: 'context', arguments: { name: 'Example' } });
expect(response.isError).not.toBe(true);
expect(backend.callTool).toHaveBeenCalledWith('context', { name: 'Example' });
expect(backend.countRepos).toHaveBeenCalledTimes(1);
expect(backend.listRepos).not.toHaveBeenCalled();
expect(backend.selectToolRepository).toHaveBeenCalledTimes(1);
expect(backend.selectToolRepository).toHaveBeenCalledWith(undefined, undefined, {
allowCwdDefault: true,
refreshRegistry: true,
});
} finally {
await client.close();
await server.close();
}
});
it('keeps repo optional when a default repo is configured', async () => {
const backend = createMockBackend({
listRepos: vi.fn().mockResolvedValue([
{ name: 'alpha', path: '/tmp/alpha' },
{ name: 'beta', path: '/tmp/beta' },
]),
});
const repositoryPolicy = await createMcpRepositoryPolicy(backend, {
GITNEXUS_MCP_DEFAULT_REPO: 'alpha',
});
const server = createMCPServer(backend, { repositoryPolicy });
const client = new Client({ name: 'default-repo-client', version: '0.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
try {
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
const tools = await client.listTools();
const query = tools.tools.find((tool) => tool.name === 'query');
expect(query?.inputSchema.required).not.toContain('repo');
} finally {
await client.close();
await server.close();
}
});
it('requires repo when multiple allowed repositories are visible without a default', async () => {
const backend = createMockBackend({
listRepos: vi.fn().mockResolvedValue([
{ name: 'alpha', path: '/tmp/alpha' },
{ name: 'beta', path: '/tmp/beta' },
{ name: 'gamma', path: '/tmp/gamma' },
]),
});
const repositoryPolicy = await createMcpRepositoryPolicy(backend, {
GITNEXUS_MCP_ALLOWED_REPOS: 'alpha,beta',
});
const server = createMCPServer(backend, { repositoryPolicy });
const client = new Client({ name: 'allowlisted-repos-client', version: '0.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
try {
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
const tools = await client.listTools();
const query = tools.tools.find((tool) => tool.name === 'query');
expect(query?.inputSchema.required).toContain('repo');
} finally {
await client.close();
await server.close();
}
});
});
// ─── getNextStepHint (tested indirectly via server tool handler) ──────
describe('getNextStepHint (via tool call response)', () => {
// We test hints by calling the server's tool handler indirectly.
// Since createMCPServer registers handlers on the Server, we verify
// hints are appended by checking the tool response format.
it('query tool response includes hint about context', async () => {
const backend = createMockBackend({
callTool: vi.fn().mockResolvedValue({ processes: [], definitions: [] }),
});
const server = createMCPServer(backend);
// We can't easily call handlers directly on the MCP Server,
// so we verify the handler was registered by creating the server without error.
// The actual hint logic is tested via the integration path.
expect(backend.callTool).not.toHaveBeenCalled(); // not called until request
});
const QUERY_HINT_BASE =
'\n\n---\n**Next:** To understand a specific symbol in depth, use context({name: "<symbol_name>", repo: "demo"}) to see categorized refs and process participation.';
it('query hint is unchanged when include_content is not set', async () => {
const backend = createMockBackend({
callTool: vi.fn().mockResolvedValue({ processes: [] }),
});
const { text } = await callToolThroughServer(backend, 'query', {
search_query: 'auth',
repo: 'demo',
});
expect(text.endsWith(QUERY_HINT_BASE)).toBe(true);
expect(text).not.toContain('context({uid:');
});
it('query hint names the context({uid, include_content}) fallback when include_content is true', async () => {
const backend = createMockBackend({
callTool: vi.fn().mockResolvedValue({ processes: [] }),
});
const { text } = await callToolThroughServer(backend, 'query', {
search_query: 'auth',
repo: 'demo',
include_content: true,
});
expect(
text.endsWith(
`${QUERY_HINT_BASE} For source of a process_symbols row without content, use context({uid: "<id>", include_content: true, repo: "demo"}).`,
),
).toBe(true);
});
it('grep hint converts the 1-based hit line to a 0-based read_file window', async () => {
const backend = createMockBackend({
callTool: vi
.fn()
.mockResolvedValue({ results: [{ filePath: 'a.ts', line: 1, text: 'signOrder()' }] }),
});
const { text } = await callToolThroughServer(backend, 'grep', {
pattern: 'signOrder',
repo: 'demo',
});
expect(text).toContain('startLine: <hit.line - 1>');
expect(text).toContain('endLine: <hit.line - 1>');
expect(text).toContain('Grep line is 1-based; read_file is 0-based');
});
});
describe('getNextStepHint rename', () => {
const apply =
'rename({symbol_uid: "Method:src/a.ts:A.run#0", new_name: "go", dry_run: false, plan_token: "<plan_token from this preview>", repo: "demo"})';
it.each([
['omitted', { symbol_uid: 'Method:src/a.ts:A.run#0', new_name: 'go', repo: 'demo' }],
[
'true',
{ symbol_uid: 'Method:src/a.ts:A.run#0', new_name: 'go', repo: 'demo', dry_run: true },
],
])('points a dry run (dry_run %s) at the apply call, not at detect_changes', (_label, args) => {
const hint = getNextStepHint('rename', args);
expect(hint).toContain(apply);
expect(hint).toContain('no files were changed');
expect(hint).not.toContain('detect_changes');
});
it('shows the detect_changes hint only after an explicit dry_run:false', () => {
const hint = getNextStepHint(
'rename',
{ new_name: 'go', repo: 'demo', dry_run: false },
false,
{ status: 'success', applied: true, application_status: 'applied' },
);
expect(hint).toContain('detect_changes({repo: "demo"})');
expect(hint).not.toContain('dry_run: false');
});
it.each([
['the string "false"', 'false'],
['null', null],
['zero', 0],
])('does not treat dry_run %s as an applied rename', (_label, dryRun) => {
const hint = getNextStepHint('rename', { new_name: 'go', dry_run: dryRun });
expect(hint).not.toContain('detect_changes');
});
it('names symbol_name, quoted, when no symbol_uid was sent and omits repo when absent', () => {
const hint = getNextStepHint('rename', { symbol_name: 'run "fast"', new_name: 'go' });
expect(hint).toContain(
'rename({symbol_name: "run \\"fast\\"", new_name: "go", dry_run: false, plan_token: "<plan_token from this preview>"})',
);
});
it('never suggests applying from a read-only server', () => {
const hint = getNextStepHint('rename', { new_name: 'go', dry_run: true }, true);
expect(hint).toContain('MCP read-only mode cannot apply it');
expect(hint).toContain('fresh preview from a server that permits writes');
expect(hint).toContain("that server's plan_token");
expect(hint).not.toContain('dry_run: false');
expect(hint).not.toContain('detect_changes');
});
it.each(['plan_required', 'invalid_plan', 'plan_mismatch', 'source_changed'])(
'requests a fresh preview when apply fails with %s',
(code) => {
const hint = getNextStepHint('rename', { dry_run: false }, false, {
status: 'error',
applied: false,
code,
});
expect(hint).toContain('fresh rename preview');
expect(hint).toContain('plan_token');
expect(hint).not.toContain('detect_changes');
},
);
it('directs partial operations to reconcile exact write lists before retrying', () => {
const hint = getNextStepHint('rename', { dry_run: false }, false, {
status: 'partial',
applied: true,
application_status: 'partial',
});
expect(hint).toContain('failed_files and unattempted_files');
expect(hint).not.toContain('detect_changes');
});
});
describe('MCP output budgets', () => {
it('leaves the complete formatted response unchanged when no budget is configured', async () => {
const previous = process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS;
delete process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS;
try {
const backend = createMockBackend({
callTool: vi.fn().mockResolvedValue({ payload: 'complete' }),
});
const { text, isError } = await callToolThroughServer(backend, 'query', {
search_query: 'auth',
});
expect(isError).toBe(false);
expect(text).toContain('"payload": "complete"');
expect(text).toContain('**Next:**');
expect(text.endsWith('\n…')).toBe(false);
} finally {
if (previous === undefined) delete process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS;
else process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS = previous;
}
});
it('applies explicit maxTokens to the complete response deterministically and UTF-8 safely', async () => {
const backend = createMockBackend({
callTool: vi.fn().mockResolvedValue({ payload: '😀'.repeat(100) }),
});
const args = { search_query: 'auth', maxTokens: 8 };
const first = await callToolThroughServer(backend, 'query', args);
const second = await callToolThroughServer(backend, 'query', args);
expect(first.isError).toBe(false);
expect(first.text).toBe(second.text);
expect(Buffer.byteLength(first.text, 'utf8')).toBeLessThanOrEqual(8 * 4);
expect(first.text.endsWith('\n…')).toBe(true);
expect(first.text).not.toContain('\uFFFD');
expect(backend.callTool).toHaveBeenCalledWith('query', { search_query: 'auth' });
});
it('uses the environment default when maxTokens is omitted', async () => {
const previous = process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS;
process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS = '8';
try {
const backend = createMockBackend({
callTool: vi.fn().mockResolvedValue({ payload: 'x'.repeat(200) }),
});
const { text } = await callToolThroughServer(backend, 'context', { name: 'auth' });
expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(8 * 4);
expect(text.endsWith('\n…')).toBe(true);
} finally {
if (previous === undefined) delete process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS;
else process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS = previous;
}
});
it('lets an explicit request override the environment default', async () => {
const previous = process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS;
process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS = '1';
try {
const backend = createMockBackend({
callTool: vi.fn().mockResolvedValue({ payload: 'complete' }),
});
const { text } = await callToolThroughServer(backend, 'impact', {
target: 'auth',
direction: 'upstream',
maxTokens: 200,
});
expect(text).toContain('"payload": "complete"');
expect(text).toContain('**Next:**');
expect(text.endsWith('\n…')).toBe(false);
} finally {
if (previous === undefined) delete process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS;
else process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS = previous;
}
});
it('rejects unknown tool arguments before backend execution (#3261)', async () => {
const backend = createMockBackend();
const { text, isError } = await callToolThroughServer(backend, 'impact', {
target: 'auth',
direction: 'downstream',
notARealArg: 2,
});
expect(isError).toBe(true);
expect(text).toMatch(/Unknown argument "notARealArg" for tool "impact"/);
expect(backend.callTool).not.toHaveBeenCalled();
});
it('accepts CLI-style depth as a known impact alias (#3261)', async () => {
// The CLI flag is --depth; since #3261 MCP advertises it alongside maxDepth
// as a compatibility alias. Before #3261, `depth` was silently dropped.
// After the fix it is a known alias and is forwarded.
const backend = createMockBackend();
const { isError } = await callToolThroughServer(backend, 'impact', {
target: 'auth',
direction: 'downstream',
depth: 2,
});
expect(isError).toBe(false);
expect(backend.callTool).toHaveBeenCalledWith('impact', {
target: 'auth',
direction: 'downstream',
depth: 2,
});
});
it('still accepts unpublished context target through tools/call', async () => {
const backend = createMockBackend();
const { isError } = await callToolThroughServer(backend, 'context', {
repo: '@g1',
target: 'Sym',
});
expect(isError).toBe(false);
expect(backend.callTool).toHaveBeenCalledWith('context', { repo: '@g1', target: 'Sym' });
});
it('still accepts the unpublished query alias for query (#2175)', async () => {
const backend = createMockBackend();
const { isError } = await callToolThroughServer(backend, 'query', {
query: 'auth',
});
expect(isError).toBe(false);
expect(backend.callTool).toHaveBeenCalledWith('query', { query: 'auth' });
});
it('still accepts the unpublished query alias for cypher (#2175)', async () => {
const backend = createMockBackend();
const { isError } = await callToolThroughServer(backend, 'cypher', {
query: 'MATCH (n) RETURN n',
});
expect(isError).toBe(false);
expect(backend.callTool).toHaveBeenCalledWith('cypher', { query: 'MATCH (n) RETURN n' });
});
it('maps legacy search/explore names through the advertised schema (#3261)', async () => {
const backend = createMockBackend();
const searchUnknown = await callToolThroughServer(backend, 'search', { notARealArg: 1 });
expect(searchUnknown.isError).toBe(true);
expect(searchUnknown.text).toMatch(/Unknown argument "notARealArg"/);
expect(backend.callTool).not.toHaveBeenCalled();
const searchOk = await callToolThroughServer(backend, 'search', { query: 'auth' });
expect(searchOk.isError).toBe(false);
expect(backend.callTool).toHaveBeenCalledWith('search', { query: 'auth' });
backend.callTool.mockClear();
const exploreUnknown = await callToolThroughServer(backend, 'explore', { notARealArg: 1 });
expect(exploreUnknown.isError).toBe(true);
expect(backend.callTool).not.toHaveBeenCalled();
const exploreOk = await callToolThroughServer(backend, 'explore', {
repo: '@g1',
target: 'Sym',
});
expect(exploreOk.isError).toBe(false);
expect(backend.callTool).toHaveBeenCalledWith('explore', { repo: '@g1', target: 'Sym' });
});
it('rejects a non-positive explicit maxTokens before backend execution', async () => {
const backend = createMockBackend();
const { text, isError } = await callToolThroughServer(backend, 'query', {
search_query: 'auth',
maxTokens: 0,
});
expect(isError).toBe(true);
expect(text).toMatch(/maxTokens.*positive integer/i);
expect(backend.callTool).not.toHaveBeenCalled();
});
it('rejects an invalid environment default before backend execution', async () => {
const previous = process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS;
process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS = 'invalid';
try {
const backend = createMockBackend();
const { text, isError } = await callToolThroughServer(backend, 'query', {
search_query: 'auth',
});
expect(isError).toBe(true);
expect(text).toMatch(/GITNEXUS_MCP_DEFAULT_MAX_TOKENS.*positive integer/i);
expect(backend.callTool).not.toHaveBeenCalled();
} finally {
if (previous === undefined) delete process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS;
else process.env.GITNEXUS_MCP_DEFAULT_MAX_TOKENS = previous;
}
});
it('applies a valid budget to backend error text', async () => {
const backend = createMockBackend({
callTool: vi.fn().mockRejectedValue(new Error('😀'.repeat(100))),
});
const { text, isError } = await callToolThroughServer(backend, 'context', {
name: 'auth',
maxTokens: 8,
});
expect(isError).toBe(true);
expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(8 * 4);
expect(text.endsWith('\n…')).toBe(true);
expect(text).not.toContain('\uFFFD');
});
});
// ─── Tool handler error handling ──────────────────────────────────────
describe('server error handling', () => {
it('includes string Error.code in tool error text', async () => {
const err = Object.assign(new Error('reader refuse'), { code: 'FTS_READER_UNREPAIRABLE' });
const backend = createMockBackend({
callTool: vi.fn().mockRejectedValue(err),
});
const { text, isError } = await callToolThroughServer(backend, 'context', { name: 'auth' });
expect(isError).toBe(true);
expect(text).toContain('FTS_READER_UNREPAIRABLE');
expect(text).toContain('reader refuse');
});
it('createMCPServer does not throw for valid backend', () => {
const backend = createMockBackend();
expect(() => createMCPServer(backend)).not.toThrow();
});
it('createMCPServer reads version from package.json', () => {
const backend = createMockBackend();
const server = createMCPServer(backend);
// Server was created with version from package.json — no crash
expect(server).toBeDefined();
});
});
// ─── Prompt definitions ───────────────────────────────────────────────
describe('prompt registration', () => {
it('server registers detect_impact and generate_map prompts', () => {
const backend = createMockBackend();
// Creating the server registers all handlers including prompts
const server = createMCPServer(backend);
expect(server).toBeDefined();
});
});
// ─── startMCPServer ────────────────────────────────────────────────────
describe('startMCPServer', () => {
it('registers stdin shutdown listeners before awaiting Server.prototype.connect', async () => {
// P1 finding: startMCPServer must register process.stdin end/close/error
// shutdown listeners before calling server.connect(), so that a stdin
// closure during transport setup is safely handled.
// This test asserts the listeners are present *during* the connect call.
// Capture baseline listener counts before startMCPServer adds anything
const closeBefore = process.stdin.listenerCount('close');
const endBefore = process.stdin.listenerCount('end');
const errorBefore = process.stdin.listenerCount('error');
const beforeListeners = {
stdinEnd: new Set(process.stdin.listeners('end')),
stdinClose: new Set(process.stdin.listeners('close')),
stdinError: new Set(process.stdin.listeners('error')),
stdoutError: new Set(process.stdout.listeners('error')),
sigint: new Set(process.listeners('SIGINT')),
sigterm: new Set(process.listeners('SIGTERM')),
exit: new Set(process.listeners('exit')),
uncaughtException: new Set(process.listeners('uncaughtException')),
unhandledRejection: new Set(process.listeners('unhandledRejection')),
};
// Stub process.exit to prevent actual termination if shutdown fires
const exitStub = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
// Spy on Server.prototype.connect to inspect listener counts during the call
const connectSpy = vi.spyOn(Server.prototype, 'connect').mockImplementation(async function () {
expect(process.stdin.listenerCount('close')).toBeGreaterThan(closeBefore);
expect(process.stdin.listenerCount('end')).toBeGreaterThan(endBefore);
expect(process.stdin.listenerCount('error')).toBeGreaterThan(errorBefore);
});
const backend = createMockBackend();
try {
await startMCPServer(backend);
} finally {
connectSpy.mockRestore();
exitStub.mockRestore();
// Remove only listeners added by startMCPServer so no side effects leak.
for (const listener of process.stdin.listeners('end')) {
if (!beforeListeners.stdinEnd.has(listener)) process.stdin.removeListener('end', listener);
}
for (const listener of process.stdin.listeners('close')) {
if (!beforeListeners.stdinClose.has(listener)) {
process.stdin.removeListener('close', listener);
}
}
for (const listener of process.stdin.listeners('error')) {
if (!beforeListeners.stdinError.has(listener)) {
process.stdin.removeListener('error', listener);
}
}
for (const listener of process.stdout.listeners('error')) {
if (!beforeListeners.stdoutError.has(listener)) {
process.stdout.removeListener('error', listener);
}
}
for (const listener of process.listeners('SIGINT')) {
if (!beforeListeners.sigint.has(listener)) process.removeListener('SIGINT', listener);
}
for (const listener of process.listeners('SIGTERM')) {
if (!beforeListeners.sigterm.has(listener)) process.removeListener('SIGTERM', listener);
}
for (const listener of process.listeners('exit')) {
if (!beforeListeners.exit.has(listener)) process.removeListener('exit', listener);
}
for (const listener of process.listeners('uncaughtException')) {
if (!beforeListeners.uncaughtException.has(listener)) {
process.removeListener('uncaughtException', listener);
}
}
for (const listener of process.listeners('unhandledRejection')) {
if (!beforeListeners.unhandledRejection.has(listener)) {
process.removeListener('unhandledRejection', listener);
}
}
}
});
});
// ─── Graceful shutdown signal handling (#1132) ────────────────────────
describe('installSignalShutdown (#1132)', () => {
it('maps SIGINT→130 / SIGTERM→143 and never passes the signal name to shutdown', () => {
// Node invokes signal listeners with the signal NAME string as the first
// argument. The old code registered `shutdown` directly, so that string
// reached process.exit() and crashed with ERR_INVALID_ARG_TYPE. Reproduce
// that exact invocation and assert a numeric code is used instead.
const received: unknown[] = [];
let onSigint: ((...args: unknown[]) => void) | undefined;
let onSigterm: ((...args: unknown[]) => void) | undefined;
installSignalShutdown(
(code) => received.push(code),
(event, listener) => {
if (event === 'SIGINT') onSigint = listener;
if (event === 'SIGTERM') onSigterm = listener;
},
);
expect(onSigint).toBeTypeOf('function');
expect(onSigterm).toBeTypeOf('function');
// Invoke exactly as Node does — with the signal name string as the arg.
onSigint?.('SIGINT');
onSigterm?.('SIGTERM');
expect(received).toEqual([SHUTDOWN_EXIT_CODES.SIGINT, SHUTDOWN_EXIT_CODES.SIGTERM]);
expect(received).toEqual([130, 143]);
for (const code of received) {
expect(typeof code).toBe('number');
}
});
});