GitNexus/gitnexus/test/unit/server.test.ts
Temirkhan 1fe3bf9399
feat(mcp): add tool safety annotations (#1127)
* feat(mcp): add tool safety annotations

* test(mcp): address PR #1127 review follow-ups

- Replace private `_requestHandlers` SDK access in server.test.ts with
  `Client` + `InMemoryTransport.createLinkedPair()` for the tools/list
  annotation propagation test. The new path uses supported public APIs
  and surfaces SDK changes loudly instead of silently degrading.
- Extract `OPEN_WORLD_READ_ONLY_TOOLS` set in tools.test.ts so future
  read-only open-world tools can be added without rewriting the
  invariant; preserves the current "only `query` is open-world" guard.
- Add inline rationale on `group_sync` annotations explaining the
  conservative `idempotentHint: false` (writes contracts.json on every
  call even when output is deterministic).

No runtime behavior change. Annotations themselves and tools/list shape
are unchanged.

---------

Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-05-03 09:06:41 +01:00

127 lines
5.3 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 { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { createMCPServer } from '../../src/mcp/server.js';
import { GITNEXUS_TOOLS } from '../../src/mcp/tools.js';
// ─── Mock backend ──────────────────────────────────────────────────
function createMockBackend(overrides: Record<string, any> = {}): any {
return {
callTool: vi.fn().mockResolvedValue({ result: 'ok' }),
listRepos: vi.fn().mockResolvedValue([]),
resolveRepo: 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,
};
}
// ─── 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();
}
});
});
// ─── 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
});
});
// ─── Tool handler error handling ──────────────────────────────────────
describe('server error handling', () => {
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();
});
});