- New `mcpctl mcp -p PROJECT` command: STDIO-to-StreamableHTTP bridge that reads JSON-RPC from stdin and forwards to mcplocal project endpoint - Rework `config claude` to write mcpctl mcp entry instead of fetching server configs from API (no secrets in .mcp.json) - Keep `config claude-generate` as backward-compat alias - Fix discovery.ts auth token not being forwarded to mcpd (RBAC bypass) - Update fish/bash completions for new commands - 10 new MCP bridge tests, updated claude tests, fixed project-discovery test Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
88 lines
3.3 KiB
TypeScript
88 lines
3.3 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { refreshProjectUpstreams } from '../src/discovery.js';
|
|
import { McpRouter } from '../src/router.js';
|
|
|
|
function mockMcpdClient(servers: Array<{ id: string; name: string; transport: string }>) {
|
|
return {
|
|
baseUrl: 'http://test:3100',
|
|
token: 'test-token',
|
|
get: vi.fn(async () => servers),
|
|
post: vi.fn(async () => ({ result: {} })),
|
|
put: vi.fn(),
|
|
delete: vi.fn(),
|
|
forward: vi.fn(async () => ({ status: 200, body: servers })),
|
|
};
|
|
}
|
|
|
|
describe('refreshProjectUpstreams', () => {
|
|
it('registers project-scoped servers as upstreams', async () => {
|
|
const router = new McpRouter();
|
|
const client = mockMcpdClient([
|
|
{ id: 'srv-1', name: 'grafana', transport: 'stdio' },
|
|
{ id: 'srv-2', name: 'ha', transport: 'stdio' },
|
|
]);
|
|
|
|
const registered = await refreshProjectUpstreams(router, client as any, 'smart-home');
|
|
expect(registered).toEqual(['grafana', 'ha']);
|
|
expect(router.getUpstreamNames()).toContain('grafana');
|
|
expect(router.getUpstreamNames()).toContain('ha');
|
|
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/smart-home/servers');
|
|
});
|
|
|
|
it('removes stale upstreams on refresh', async () => {
|
|
const router = new McpRouter();
|
|
|
|
// First refresh: 2 servers
|
|
const client1 = mockMcpdClient([
|
|
{ id: 'srv-1', name: 'grafana', transport: 'stdio' },
|
|
{ id: 'srv-2', name: 'ha', transport: 'stdio' },
|
|
]);
|
|
await refreshProjectUpstreams(router, client1 as any, 'smart-home');
|
|
expect(router.getUpstreamNames()).toHaveLength(2);
|
|
|
|
// Second refresh: only 1 server
|
|
const client2 = mockMcpdClient([
|
|
{ id: 'srv-1', name: 'grafana', transport: 'stdio' },
|
|
]);
|
|
await refreshProjectUpstreams(router, client2 as any, 'smart-home');
|
|
expect(router.getUpstreamNames()).toEqual(['grafana']);
|
|
});
|
|
|
|
it('forwards auth token via forward() method', async () => {
|
|
const router = new McpRouter();
|
|
const servers = [{ id: 'srv-1', name: 'grafana', transport: 'stdio' }];
|
|
const client = mockMcpdClient(servers);
|
|
|
|
await refreshProjectUpstreams(router, client as any, 'smart-home', 'user-token-123');
|
|
expect(client.forward).toHaveBeenCalledWith('GET', '/api/v1/projects/smart-home/servers', '', undefined, 'user-token-123');
|
|
expect(router.getUpstreamNames()).toContain('grafana');
|
|
});
|
|
|
|
it('throws on failed project fetch', async () => {
|
|
const router = new McpRouter();
|
|
const client = mockMcpdClient([]);
|
|
client.forward.mockResolvedValue({ status: 403, body: { error: 'Forbidden' } });
|
|
|
|
await expect(
|
|
refreshProjectUpstreams(router, client as any, 'secret-project', 'bad-token'),
|
|
).rejects.toThrow('Failed to fetch project servers: 403');
|
|
});
|
|
|
|
it('URL-encodes project name', async () => {
|
|
const router = new McpRouter();
|
|
const client = mockMcpdClient([]);
|
|
|
|
await refreshProjectUpstreams(router, client as any, 'my project');
|
|
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/my%20project/servers');
|
|
});
|
|
|
|
it('handles empty project server list', async () => {
|
|
const router = new McpRouter();
|
|
const client = mockMcpdClient([]);
|
|
|
|
const registered = await refreshProjectUpstreams(router, client as any, 'empty-project');
|
|
expect(registered).toEqual([]);
|
|
expect(router.getUpstreamNames()).toHaveLength(0);
|
|
});
|
|
});
|