feat: add mcpctl mcp STDIO bridge, rework config claude
- 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>
This commit is contained in:
@@ -8,19 +8,14 @@ import { saveCredentials, loadCredentials } from '../../src/auth/index.js';
|
||||
|
||||
function mockClient(): ApiClient {
|
||||
return {
|
||||
get: vi.fn(async () => ({
|
||||
mcpServers: {
|
||||
'slack--default': { command: 'npx', args: ['-y', '@anthropic/slack-mcp'], env: { WORKSPACE: 'test' } },
|
||||
'github--default': { command: 'npx', args: ['-y', '@anthropic/github-mcp'] },
|
||||
},
|
||||
})),
|
||||
get: vi.fn(async () => ({})),
|
||||
post: vi.fn(async () => ({ token: 'impersonated-tok', user: { email: 'other@test.com' } })),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ApiClient;
|
||||
}
|
||||
|
||||
describe('config claude-generate', () => {
|
||||
describe('config claude', () => {
|
||||
let client: ReturnType<typeof mockClient>;
|
||||
let output: string[];
|
||||
let tmpDir: string;
|
||||
@@ -36,18 +31,23 @@ describe('config claude-generate', () => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('generates .mcp.json from project config', async () => {
|
||||
it('generates .mcp.json with mcpctl mcp bridge entry', async () => {
|
||||
const outPath = join(tmpDir, '.mcp.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath], { from: 'user' });
|
||||
await cmd.parseAsync(['claude', '--project', 'homeautomation', '-o', outPath], { from: 'user' });
|
||||
|
||||
// No API call should be made
|
||||
expect(client.get).not.toHaveBeenCalled();
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/proj-1/mcp-config');
|
||||
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
||||
expect(written.mcpServers['slack--default']).toBeDefined();
|
||||
expect(output.join('\n')).toContain('2 server(s)');
|
||||
expect(written.mcpServers['homeautomation']).toEqual({
|
||||
command: 'mcpctl',
|
||||
args: ['mcp', '-p', 'homeautomation'],
|
||||
});
|
||||
expect(output.join('\n')).toContain('1 server(s)');
|
||||
});
|
||||
|
||||
it('prints to stdout with --stdout', async () => {
|
||||
@@ -55,9 +55,13 @@ describe('config claude-generate', () => {
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '--stdout'], { from: 'user' });
|
||||
await cmd.parseAsync(['claude', '--project', 'myproj', '--stdout'], { from: 'user' });
|
||||
|
||||
expect(output[0]).toContain('mcpServers');
|
||||
const parsed = JSON.parse(output[0]);
|
||||
expect(parsed.mcpServers['myproj']).toEqual({
|
||||
command: 'mcpctl',
|
||||
args: ['mcp', '-p', 'myproj'],
|
||||
});
|
||||
});
|
||||
|
||||
it('merges with existing .mcp.json', async () => {
|
||||
@@ -70,12 +74,41 @@ describe('config claude-generate', () => {
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath, '--merge'], { from: 'user' });
|
||||
await cmd.parseAsync(['claude', '--project', 'proj-1', '-o', outPath, '--merge'], { from: 'user' });
|
||||
|
||||
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
||||
expect(written.mcpServers['existing--server']).toBeDefined();
|
||||
expect(written.mcpServers['slack--default']).toBeDefined();
|
||||
expect(output.join('\n')).toContain('3 server(s)');
|
||||
expect(written.mcpServers['proj-1']).toEqual({
|
||||
command: 'mcpctl',
|
||||
args: ['mcp', '-p', 'proj-1'],
|
||||
});
|
||||
expect(output.join('\n')).toContain('2 server(s)');
|
||||
});
|
||||
|
||||
it('backward compat: claude-generate still works', async () => {
|
||||
const outPath = join(tmpDir, '.mcp.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath], { from: 'user' });
|
||||
|
||||
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
||||
expect(written.mcpServers['proj-1']).toEqual({
|
||||
command: 'mcpctl',
|
||||
args: ['mcp', '-p', 'proj-1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses project name as the server key', async () => {
|
||||
const outPath = join(tmpDir, '.mcp.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['claude', '--project', 'my-fancy-project', '-o', outPath], { from: 'user' });
|
||||
|
||||
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
||||
expect(Object.keys(written.mcpServers)).toEqual(['my-fancy-project']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
414
src/cli/tests/commands/mcp.test.ts
Normal file
414
src/cli/tests/commands/mcp.test.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import http from 'node:http';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { runMcpBridge } from '../../src/commands/mcp.js';
|
||||
|
||||
// ---- Mock MCP server (simulates mcplocal project endpoint) ----
|
||||
|
||||
interface RecordedRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
body: string;
|
||||
}
|
||||
|
||||
let mockServer: http.Server;
|
||||
let mockPort: number;
|
||||
const recorded: RecordedRequest[] = [];
|
||||
let sessionCounter = 0;
|
||||
|
||||
function makeInitializeResponse(id: number | string) {
|
||||
return JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: 'test-server', version: '1.0.0' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function makeToolsListResponse(id: number | string) {
|
||||
return JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
tools: [
|
||||
{ name: 'grafana/query', description: 'Query Grafana', inputSchema: { type: 'object', properties: {} } },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function makeToolCallResponse(id: number | string) {
|
||||
return JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
content: [{ type: 'text', text: 'tool result' }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
mockServer = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (c: Buffer) => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf-8');
|
||||
recorded.push({ method: req.method ?? '', url: req.url ?? '', headers: req.headers, body });
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url?.startsWith('/projects/')) {
|
||||
let sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
|
||||
// Assign session ID on first request
|
||||
if (!sessionId) {
|
||||
sessionCounter++;
|
||||
sessionId = `session-${sessionCounter}`;
|
||||
}
|
||||
res.setHeader('mcp-session-id', sessionId);
|
||||
|
||||
// Parse JSON-RPC and respond based on method
|
||||
try {
|
||||
const rpc = JSON.parse(body) as { id: number | string; method: string };
|
||||
let responseBody: string;
|
||||
|
||||
switch (rpc.method) {
|
||||
case 'initialize':
|
||||
responseBody = makeInitializeResponse(rpc.id);
|
||||
break;
|
||||
case 'tools/list':
|
||||
responseBody = makeToolsListResponse(rpc.id);
|
||||
break;
|
||||
case 'tools/call':
|
||||
responseBody = makeToolCallResponse(rpc.id);
|
||||
break;
|
||||
default:
|
||||
responseBody = JSON.stringify({ jsonrpc: '2.0', id: rpc.id, error: { code: -32601, message: 'Method not found' } });
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(responseBody);
|
||||
} catch {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
mockServer.listen(0, () => {
|
||||
const addr = mockServer.address();
|
||||
if (addr && typeof addr === 'object') {
|
||||
mockPort = addr.port;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mockServer.close();
|
||||
});
|
||||
|
||||
// ---- Helper to run bridge with mock streams ----
|
||||
|
||||
function createMockStreams() {
|
||||
const stdoutChunks: string[] = [];
|
||||
const stderrChunks: string[] = [];
|
||||
|
||||
const stdout = new Writable({
|
||||
write(chunk: Buffer, _encoding, callback) {
|
||||
stdoutChunks.push(chunk.toString());
|
||||
callback();
|
||||
},
|
||||
});
|
||||
|
||||
const stderr = new Writable({
|
||||
write(chunk: Buffer, _encoding, callback) {
|
||||
stderrChunks.push(chunk.toString());
|
||||
callback();
|
||||
},
|
||||
});
|
||||
|
||||
return { stdout, stderr, stdoutChunks, stderrChunks };
|
||||
}
|
||||
|
||||
function pushAndEnd(stdin: Readable, lines: string[]) {
|
||||
for (const line of lines) {
|
||||
stdin.push(line + '\n');
|
||||
}
|
||||
stdin.push(null); // EOF
|
||||
}
|
||||
|
||||
// ---- Tests ----
|
||||
|
||||
describe('MCP STDIO Bridge', () => {
|
||||
beforeAll(() => {
|
||||
recorded.length = 0;
|
||||
sessionCounter = 0;
|
||||
});
|
||||
|
||||
it('forwards initialize request and returns response', async () => {
|
||||
recorded.length = 0;
|
||||
const stdin = new Readable({ read() {} });
|
||||
const { stdout, stdoutChunks } = createMockStreams();
|
||||
|
||||
const initMsg = JSON.stringify({
|
||||
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||
});
|
||||
|
||||
pushAndEnd(stdin, [initMsg]);
|
||||
|
||||
await runMcpBridge({
|
||||
projectName: 'test-project',
|
||||
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||
});
|
||||
|
||||
// Verify request was made to correct URL
|
||||
expect(recorded.some((r) => r.url === '/projects/test-project/mcp' && r.method === 'POST')).toBe(true);
|
||||
|
||||
// Verify response on stdout
|
||||
const output = stdoutChunks.join('');
|
||||
const parsed = JSON.parse(output.trim());
|
||||
expect(parsed.result.serverInfo.name).toBe('test-server');
|
||||
expect(parsed.result.protocolVersion).toBe('2024-11-05');
|
||||
});
|
||||
|
||||
it('sends session ID on subsequent requests', async () => {
|
||||
recorded.length = 0;
|
||||
const stdin = new Readable({ read() {} });
|
||||
const { stdout, stdoutChunks } = createMockStreams();
|
||||
|
||||
const initMsg = JSON.stringify({
|
||||
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||
});
|
||||
const toolsListMsg = JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
||||
|
||||
pushAndEnd(stdin, [initMsg, toolsListMsg]);
|
||||
|
||||
await runMcpBridge({
|
||||
projectName: 'test-project',
|
||||
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||
});
|
||||
|
||||
// First POST should NOT have mcp-session-id header
|
||||
const firstPost = recorded.find((r) => r.method === 'POST' && r.body.includes('initialize'));
|
||||
expect(firstPost).toBeDefined();
|
||||
expect(firstPost!.headers['mcp-session-id']).toBeUndefined();
|
||||
|
||||
// Second POST SHOULD have mcp-session-id header
|
||||
const secondPost = recorded.find((r) => r.method === 'POST' && r.body.includes('tools/list'));
|
||||
expect(secondPost).toBeDefined();
|
||||
expect(secondPost!.headers['mcp-session-id']).toMatch(/^session-/);
|
||||
|
||||
// Verify tools/list response
|
||||
const lines = stdoutChunks.join('').trim().split('\n');
|
||||
expect(lines.length).toBe(2);
|
||||
const toolsResponse = JSON.parse(lines[1]);
|
||||
expect(toolsResponse.result.tools[0].name).toBe('grafana/query');
|
||||
});
|
||||
|
||||
it('forwards tools/call and returns result', async () => {
|
||||
recorded.length = 0;
|
||||
const stdin = new Readable({ read() {} });
|
||||
const { stdout, stdoutChunks } = createMockStreams();
|
||||
|
||||
const initMsg = JSON.stringify({
|
||||
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||
});
|
||||
const callMsg = JSON.stringify({
|
||||
jsonrpc: '2.0', id: 2, method: 'tools/call',
|
||||
params: { name: 'grafana/query', arguments: { query: 'test' } },
|
||||
});
|
||||
|
||||
pushAndEnd(stdin, [initMsg, callMsg]);
|
||||
|
||||
await runMcpBridge({
|
||||
projectName: 'test-project',
|
||||
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||
});
|
||||
|
||||
const lines = stdoutChunks.join('').trim().split('\n');
|
||||
expect(lines.length).toBe(2);
|
||||
const callResponse = JSON.parse(lines[1]);
|
||||
expect(callResponse.result.content[0].text).toBe('tool result');
|
||||
});
|
||||
|
||||
it('forwards Authorization header when token provided', async () => {
|
||||
recorded.length = 0;
|
||||
const stdin = new Readable({ read() {} });
|
||||
const { stdout } = createMockStreams();
|
||||
|
||||
const initMsg = JSON.stringify({
|
||||
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||
});
|
||||
|
||||
pushAndEnd(stdin, [initMsg]);
|
||||
|
||||
await runMcpBridge({
|
||||
projectName: 'test-project',
|
||||
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||
token: 'my-secret-token',
|
||||
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||
});
|
||||
|
||||
const post = recorded.find((r) => r.method === 'POST');
|
||||
expect(post).toBeDefined();
|
||||
expect(post!.headers['authorization']).toBe('Bearer my-secret-token');
|
||||
});
|
||||
|
||||
it('does not send Authorization header when no token', async () => {
|
||||
recorded.length = 0;
|
||||
const stdin = new Readable({ read() {} });
|
||||
const { stdout } = createMockStreams();
|
||||
|
||||
const initMsg = JSON.stringify({
|
||||
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||
});
|
||||
|
||||
pushAndEnd(stdin, [initMsg]);
|
||||
|
||||
await runMcpBridge({
|
||||
projectName: 'test-project',
|
||||
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||
});
|
||||
|
||||
const post = recorded.find((r) => r.method === 'POST');
|
||||
expect(post).toBeDefined();
|
||||
expect(post!.headers['authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends DELETE to clean up session on stdin EOF', async () => {
|
||||
recorded.length = 0;
|
||||
const stdin = new Readable({ read() {} });
|
||||
const { stdout } = createMockStreams();
|
||||
|
||||
const initMsg = JSON.stringify({
|
||||
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||
});
|
||||
|
||||
pushAndEnd(stdin, [initMsg]);
|
||||
|
||||
await runMcpBridge({
|
||||
projectName: 'test-project',
|
||||
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||
});
|
||||
|
||||
// Should have a DELETE request for session cleanup
|
||||
const deleteReq = recorded.find((r) => r.method === 'DELETE');
|
||||
expect(deleteReq).toBeDefined();
|
||||
expect(deleteReq!.headers['mcp-session-id']).toMatch(/^session-/);
|
||||
});
|
||||
|
||||
it('does not send DELETE if no session was established', async () => {
|
||||
recorded.length = 0;
|
||||
const stdin = new Readable({ read() {} });
|
||||
const { stdout } = createMockStreams();
|
||||
|
||||
// Push EOF immediately with no messages
|
||||
stdin.push(null);
|
||||
|
||||
await runMcpBridge({
|
||||
projectName: 'test-project',
|
||||
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||
});
|
||||
|
||||
expect(recorded.filter((r) => r.method === 'DELETE')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('writes errors to stderr, not stdout', async () => {
|
||||
recorded.length = 0;
|
||||
const stdin = new Readable({ read() {} });
|
||||
const { stdout, stdoutChunks, stderr, stderrChunks } = createMockStreams();
|
||||
|
||||
// Send to a non-existent port to trigger connection error
|
||||
const badMsg = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
||||
pushAndEnd(stdin, [badMsg]);
|
||||
|
||||
await runMcpBridge({
|
||||
projectName: 'test-project',
|
||||
mcplocalUrl: 'http://localhost:1', // will fail to connect
|
||||
stdin, stdout, stderr,
|
||||
});
|
||||
|
||||
// Error should be on stderr
|
||||
expect(stderrChunks.join('')).toContain('MCP bridge error');
|
||||
// stdout should be empty (no corrupted output)
|
||||
expect(stdoutChunks.join('')).toBe('');
|
||||
});
|
||||
|
||||
it('skips blank lines in stdin', async () => {
|
||||
recorded.length = 0;
|
||||
const stdin = new Readable({ read() {} });
|
||||
const { stdout, stdoutChunks } = createMockStreams();
|
||||
|
||||
const initMsg = JSON.stringify({
|
||||
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||
});
|
||||
|
||||
pushAndEnd(stdin, ['', ' ', initMsg, '']);
|
||||
|
||||
await runMcpBridge({
|
||||
projectName: 'test-project',
|
||||
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||
});
|
||||
|
||||
// Only one POST (for the actual message)
|
||||
const posts = recorded.filter((r) => r.method === 'POST');
|
||||
expect(posts).toHaveLength(1);
|
||||
|
||||
// One response line
|
||||
const lines = stdoutChunks.join('').trim().split('\n');
|
||||
expect(lines).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('URL-encodes project name', async () => {
|
||||
recorded.length = 0;
|
||||
const stdin = new Readable({ read() {} });
|
||||
const { stdout } = createMockStreams();
|
||||
const { stderr } = createMockStreams();
|
||||
|
||||
const initMsg = JSON.stringify({
|
||||
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||
});
|
||||
|
||||
pushAndEnd(stdin, [initMsg]);
|
||||
|
||||
await runMcpBridge({
|
||||
projectName: 'my project',
|
||||
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||
stdin, stdout, stderr,
|
||||
});
|
||||
|
||||
const post = recorded.find((r) => r.method === 'POST');
|
||||
expect(post?.url).toBe('/projects/my%20project/mcp');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user