feat: implement v2 3-tier architecture (mcpctl → mcplocal → mcpd)
- Rename local-proxy to mcplocal with HTTP server, LLM pipeline, mcpd discovery
- Add LLM pre-processing: token estimation, filter cache, metrics, Gemini CLI + DeepSeek providers
- Add mcpd auth (login/logout) and MCP proxy endpoints
- Update CLI: dual URLs (mcplocalUrl/mcpdUrl), auth commands, --direct flag
- Add tiered health monitoring, shell completions, e2e integration tests
- 57 test files, 597 tests passing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 11:42:06 +00:00
|
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
|
|
|
import { McpdUpstream } from '../src/upstream/mcpd.js';
|
|
|
|
|
import type { JsonRpcRequest } from '../src/types.js';
|
|
|
|
|
|
|
|
|
|
function mockMcpdClient(responses: Map<string, unknown> = new Map()) {
|
|
|
|
|
return {
|
|
|
|
|
baseUrl: 'http://test:3100',
|
|
|
|
|
token: 'test-token',
|
|
|
|
|
get: vi.fn(),
|
|
|
|
|
post: vi.fn(async (_path: string, body: unknown) => {
|
|
|
|
|
const req = body as { serverId: string; method: string };
|
|
|
|
|
const key = `${req.serverId}:${req.method}`;
|
|
|
|
|
if (responses.has(key)) {
|
|
|
|
|
return responses.get(key);
|
|
|
|
|
}
|
|
|
|
|
return { result: { ok: true } };
|
|
|
|
|
}),
|
|
|
|
|
put: vi.fn(),
|
|
|
|
|
delete: vi.fn(),
|
|
|
|
|
forward: vi.fn(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('McpdUpstream', () => {
|
|
|
|
|
it('sends tool calls via mcpd proxy', async () => {
|
|
|
|
|
const client = mockMcpdClient(new Map([
|
|
|
|
|
['srv-1:tools/call', { result: { content: [{ type: 'text', text: 'hello' }] } }],
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
const upstream = new McpdUpstream('srv-1', 'slack', client as any);
|
|
|
|
|
const request: JsonRpcRequest = {
|
|
|
|
|
jsonrpc: '2.0',
|
|
|
|
|
id: '1',
|
|
|
|
|
method: 'tools/call',
|
|
|
|
|
params: { name: 'search', arguments: { query: 'test' } },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const response = await upstream.send(request);
|
|
|
|
|
expect(response.result).toEqual({ content: [{ type: 'text', text: 'hello' }] });
|
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/mcp/proxy', {
|
|
|
|
|
serverId: 'srv-1',
|
|
|
|
|
method: 'tools/call',
|
|
|
|
|
params: { name: 'search', arguments: { query: 'test' } },
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('sends tools/list via mcpd proxy', async () => {
|
|
|
|
|
const client = mockMcpdClient(new Map([
|
|
|
|
|
['srv-1:tools/list', { result: { tools: [{ name: 'search', description: 'Search' }] } }],
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
const upstream = new McpdUpstream('srv-1', 'slack', client as any);
|
|
|
|
|
const request: JsonRpcRequest = {
|
|
|
|
|
jsonrpc: '2.0',
|
|
|
|
|
id: '2',
|
|
|
|
|
method: 'tools/list',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const response = await upstream.send(request);
|
|
|
|
|
expect(response.result).toEqual({ tools: [{ name: 'search', description: 'Search' }] });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns error when mcpd fails', async () => {
|
|
|
|
|
const client = mockMcpdClient();
|
|
|
|
|
client.post.mockRejectedValue(new Error('connection refused'));
|
|
|
|
|
|
|
|
|
|
const upstream = new McpdUpstream('srv-1', 'slack', client as any);
|
|
|
|
|
const request: JsonRpcRequest = { jsonrpc: '2.0', id: '3', method: 'tools/list' };
|
|
|
|
|
|
|
|
|
|
const response = await upstream.send(request);
|
|
|
|
|
expect(response.error).toBeDefined();
|
|
|
|
|
expect(response.error!.message).toContain('mcpd proxy error');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns error when upstream is closed', async () => {
|
|
|
|
|
const client = mockMcpdClient();
|
|
|
|
|
const upstream = new McpdUpstream('srv-1', 'slack', client as any);
|
|
|
|
|
await upstream.close();
|
|
|
|
|
|
|
|
|
|
const request: JsonRpcRequest = { jsonrpc: '2.0', id: '4', method: 'tools/list' };
|
|
|
|
|
const response = await upstream.send(request);
|
|
|
|
|
expect(response.error).toBeDefined();
|
|
|
|
|
expect(response.error!.message).toContain('closed');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('reports alive status correctly', async () => {
|
|
|
|
|
const client = mockMcpdClient();
|
|
|
|
|
const upstream = new McpdUpstream('srv-1', 'slack', client as any);
|
|
|
|
|
expect(upstream.isAlive()).toBe(true);
|
|
|
|
|
await upstream.close();
|
|
|
|
|
expect(upstream.isAlive()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('relays error responses from mcpd', async () => {
|
|
|
|
|
const client = mockMcpdClient(new Map([
|
|
|
|
|
['srv-1:tools/call', { error: { code: -32601, message: 'Tool not found' } }],
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
const upstream = new McpdUpstream('srv-1', 'slack', client as any);
|
|
|
|
|
const request: JsonRpcRequest = {
|
|
|
|
|
jsonrpc: '2.0',
|
|
|
|
|
id: '5',
|
|
|
|
|
method: 'tools/call',
|
|
|
|
|
params: { name: 'nonexistent' },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const response = await upstream.send(request);
|
|
|
|
|
expect(response.error).toEqual({ code: -32601, message: 'Tool not found' });
|
|
|
|
|
});
|
2026-04-17 00:48:57 +01:00
|
|
|
|
|
|
|
|
it('routes */list methods through discoveryClient when provided', async () => {
|
|
|
|
|
const mainClient = mockMcpdClient();
|
|
|
|
|
const discoveryClient = mockMcpdClient(new Map([
|
|
|
|
|
['srv-1:tools/list', { result: { tools: [] } }],
|
|
|
|
|
['srv-1:resources/list', { result: { resources: [] } }],
|
|
|
|
|
['srv-1:prompts/list', { result: { prompts: [] } }],
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
const upstream = new McpdUpstream('srv-1', 'slack', mainClient as any, undefined, discoveryClient as any);
|
|
|
|
|
|
|
|
|
|
await upstream.send({ jsonrpc: '2.0', id: '1', method: 'tools/list' });
|
|
|
|
|
await upstream.send({ jsonrpc: '2.0', id: '2', method: 'resources/list' });
|
|
|
|
|
await upstream.send({ jsonrpc: '2.0', id: '3', method: 'prompts/list' });
|
|
|
|
|
|
|
|
|
|
expect(discoveryClient.post).toHaveBeenCalledTimes(3);
|
|
|
|
|
expect(mainClient.post).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('routes tools/call through mainClient even when discoveryClient is set', async () => {
|
|
|
|
|
const mainClient = mockMcpdClient(new Map([
|
|
|
|
|
['srv-1:tools/call', { result: { ok: true } }],
|
|
|
|
|
]));
|
|
|
|
|
const discoveryClient = mockMcpdClient();
|
|
|
|
|
|
|
|
|
|
const upstream = new McpdUpstream('srv-1', 'slack', mainClient as any, undefined, discoveryClient as any);
|
|
|
|
|
await upstream.send({
|
|
|
|
|
jsonrpc: '2.0', id: '1', method: 'tools/call',
|
|
|
|
|
params: { name: 'noop', arguments: {} },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(mainClient.post).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(discoveryClient.post).not.toHaveBeenCalled();
|
|
|
|
|
});
|
feat: implement v2 3-tier architecture (mcpctl → mcplocal → mcpd)
- Rename local-proxy to mcplocal with HTTP server, LLM pipeline, mcpd discovery
- Add LLM pre-processing: token estimation, filter cache, metrics, Gemini CLI + DeepSeek providers
- Add mcpd auth (login/logout) and MCP proxy endpoints
- Update CLI: dual URLs (mcplocalUrl/mcpdUrl), auth commands, --direct flag
- Add tiered health monitoring, shell completions, e2e integration tests
- 57 test files, 597 tests passing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 11:42:06 +00:00
|
|
|
});
|