mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-14 23:22:54 +00:00
* fix(lbug): pin Ladybug core so Dependabot cannot ship a skewed FTS artifact The extension version is a separate upstream constant. Ignore daily core bumps and fail the pairing gate when the committed manifest does not name the installed core. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): make doctor and CI FTS gates resolve the packaged artifact Doctor and the REQUIRE_FTS file gates still treated an empty ~/.lbdb as unavailable, which would turn three CI jobs red once analyze stops installing into that tree. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): name native-abort and tuple-missing so analyze cannot mis-advise The CLI summary's trailing else treated every unknown skip reason as a missing extension. New crash and platform causes must get their own remedies, not a network-install hint. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): delete the dead read-path FTS index create ensureFTSIndex had no production callers and swallowed read-only CREATE_FTS_INDEX failures, which hid the only signal that a reader tried to write. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): vendor per-platform FTS artifacts so analyze needs no host install Keyword search depended on a CDN fetch into ~/.lbdb. Shipping the five published tuples inside the package makes air-gapped and ignore-scripts installs load the same artifact the publish gate checksums. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): load the packaged FTS artifact before any network install Analyze still required a CDN fetch into ~/.lbdb even when the package already shipped the file. FTS now path-loads the vendored tuple first and records source labels so a later truncated home copy cannot steal the diagnosis. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): diagnose a core/extension version skew instead of a missing runtime A structurally valid FTS artifact whose path version disagrees with the packaged pin must name both versions, not prescribe VC++ or OpenSSL. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): stamp an FTS phase so repair stays usable after an in-place abort A native CREATE_FTS_INDEX abort leaves no skip reason; the next run infers it from the dirty flag, and --repair-fts must not treat that phase as a half-written graph. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): park an in-place FTS crash WAL without wiping the graph An FTS abort after a successful checkpoint must reopen the live index on macOS, Windows, and Linux. Staging never parks the live WAL; readers keep today's large-WAL refusal. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): refuse read-only opens of an FTS-poisoned WAL MCP and serve cannot repair a leftover in-place abort. Fail before the native open and name --repair-fts, on macOS, Windows, and Linux. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): name a vendor-neutral Windows OpenSSL prerequisite OQ1 is unanswered here so GitNexus does not ship OpenSSL DLLs. Windows FTS now asks for a system OpenSSL 3 runtime instead of Git Bash PATH. Co-authored-by: Cursor <cursoragent@cursor.com> * test(lbug): inject the FTS vendor root and redact it on HTTP and MCP Path-loaded artifacts no longer vary with HOME. Tests pass an injected vendor tree and assert search warnings never leak a filesystem path. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(lbug): document load-only as the global FTS install default Analyze still overrides to auto. Packaged per-platform artifacts load before any network install on macOS, Windows, and Linux. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(lbug): format the FTS install-policy README table Prettier does not run on Markdown in pre-commit, so the U10 table wrap needs its own formatting commit. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): skip FTS CREATE after a persisted native abort A recovered analyze run was retrying CREATE_FTS_INDEX from skipReason alone. Keep that skip until --repair-fts, fail closed on unsupported tuples, and honor the checkpoint warrant for park/repair. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): honor checkpoint flushed warrant and align FTS tests with packaged vendor A no-op CHECKPOINT must not satisfy the FTS park warrant, and CI still asserted HOME-only FTS isolation after analyze started path-LOADing the packaged artifact. Co-authored-by: Cursor <cursoragent@cursor.com> * test(lbug): accept a nonempty incremental write set in the #2790 recovery check FTS-phase recovery can incremental-add files (changed=0, added=1). That is not the #2790 empty-diff wipe skip. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): compare FTS home versions to the core pin and tighten the publish filename gate Ladybug's ~/.lbdb/extension directory is the runtime/core version; treating it as the artifact version false-diagnosed skew. The publish guard now rejects a path-escaping filename the same way the fetch script does. Co-authored-by: Cursor <cursoragent@cursor.com> * test(lbug): seed FTS e2e fixtures from the packaged vendor artifact A machine with no ~/.lbdb copy should still run the vendor-survivorship cases; the seed no longer depends on HOME or a network install. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3274) Keep in-place FTS abort evidence after persist so a second CREATE abort cannot fail-open readers, and close the CLI, loader, embed, and e2e gaps the review called out. Note: full npm test hit Ladybug worker-pool startup failures under memory pressure; tsc and 180 targeted unit tests passed. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3274) Run the vendored-path symlink guard on the OS matrix, put e2e HOME fixtures on Ladybug's real extension layout, pin the embed crash-WAL gate before the writable open, and let analyze writers park through missing-shadow recovery. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lbug): keep --repair-fts CI green after vendored-first FTS Never-installed warning fixtures must not inspect a packaged vendor binary, and a failed dirty restamp must not abort an otherwise successful --repair-fts run. Co-authored-by: Cursor <cursoragent@cursor.com> * test(cli): give the #1169 analyze e2e the same 90s Windows budget as its sibling The first #1169 persist-meta case was still on a 60s spawn/it budget and was killed banner-only on windows-latest after the FTS warning fixture no longer failed the shard first. Co-authored-by: Cursor <cursoragent@cursor.com> * test(ci): reweight Windows shards after the FTS e2e grew Vendored-first HOME fixtures pushed fts-extension-e2e to ~6 minutes on windows-latest, so the old 146s weight packed it with skills-e2e and blew the 20-minute watchdog. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
612 lines
25 KiB
TypeScript
612 lines
25 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 {
|
|
createMCPServer,
|
|
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 = 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');
|
|
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
|
|
});
|
|
});
|
|
|
|
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');
|
|
}
|
|
});
|
|
});
|