From e17a2282e8e55817e96e388035d65643fc070291 Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 24 Feb 2026 00:52:05 +0000 Subject: [PATCH] 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 --- completions/mcpctl.bash | 6 +- completions/mcpctl.fish | 6 +- src/cli/src/commands/config.ts | 39 +- src/cli/src/commands/mcp.ts | 196 +++++++++ src/cli/src/index.ts | 4 + src/cli/tests/commands/claude.test.ts | 67 ++- src/cli/tests/commands/mcp.test.ts | 414 +++++++++++++++++++ src/mcplocal/src/discovery.ts | 2 +- src/mcplocal/tests/project-discovery.test.ts | 2 +- 9 files changed, 701 insertions(+), 35 deletions(-) create mode 100644 src/cli/src/commands/mcp.ts create mode 100644 src/cli/tests/commands/mcp.test.ts diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 8037a9f..9554295 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -2,7 +2,7 @@ _mcpctl() { local cur prev words cword _init_completion || return - local commands="status login logout config get describe delete logs create edit apply backup restore help" + local commands="status login logout config get describe delete logs create edit apply backup restore mcp help" local project_commands="attach-server detach-server get describe delete logs create edit help" local global_opts="-v --version --daemon-url --direct --project -h --help" local resources="servers instances secrets templates projects users groups rbac" @@ -78,7 +78,7 @@ _mcpctl() { case "$subcmd" in config) if [[ $((cword - subcmd_pos)) -eq 1 ]]; then - COMPREPLY=($(compgen -W "view set path reset claude-generate impersonate help" -- "$cur")) + COMPREPLY=($(compgen -W "view set path reset claude impersonate help" -- "$cur")) fi return ;; status) @@ -89,6 +89,8 @@ _mcpctl() { return ;; logout) return ;; + mcp) + return ;; get|describe|delete) if [[ -z "$resource_type" ]]; then COMPREPLY=($(compgen -W "$resources" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 8bc628b..5a3ec52 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -3,7 +3,7 @@ # Erase any stale completions from previous versions complete -c mcpctl -e -set -l commands status login logout config get describe delete logs create edit apply backup restore help +set -l commands status login logout config get describe delete logs create edit apply backup restore mcp help set -l project_commands attach-server detach-server get describe delete logs create edit help # Disable file completions by default @@ -196,12 +196,12 @@ complete -c mcpctl -n "__fish_seen_subcommand_from login" -l email -d 'Email add complete -c mcpctl -n "__fish_seen_subcommand_from login" -l password -d 'Password' -x # config subcommands -set -l config_cmds view set path reset claude-generate impersonate +set -l config_cmds view set path reset claude claude-generate impersonate complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a view -d 'Show configuration' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a set -d 'Set a config value' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a path -d 'Show config file path' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a reset -d 'Reset to defaults' -complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude-generate -d 'Generate .mcp.json' +complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude -d 'Generate .mcp.json for project' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a impersonate -d 'Impersonate a user' # create subcommands diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 2bf8368..7524776 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -10,7 +10,7 @@ import type { CredentialsDeps, StoredCredentials } from '../auth/index.js'; import type { ApiClient } from '../api-client.js'; interface McpConfig { - mcpServers: Record }>; + mcpServers: Record }>; } export interface ConfigCommandDeps { @@ -84,21 +84,27 @@ export function createConfigCommand(deps?: Partial, apiDeps?: log('Configuration reset to defaults'); }); - if (apiDeps) { - const { client, credentialsDeps, log: apiLog } = apiDeps; - - config - .command('claude-generate') - .description('Generate .mcp.json from a project configuration') + // claude/claude-generate: generate .mcp.json pointing at mcpctl mcp bridge + function registerClaudeCommand(name: string, hidden: boolean): void { + const cmd = config + .command(name) + .description(hidden ? '' : 'Generate .mcp.json that connects a project via mcpctl mcp bridge') .requiredOption('--project ', 'Project name') .option('-o, --output ', 'Output file path', '.mcp.json') .option('--merge', 'Merge with existing .mcp.json instead of overwriting') .option('--stdout', 'Print to stdout instead of writing a file') - .action(async (opts: { project: string; output: string; merge?: boolean; stdout?: boolean }) => { - const mcpConfig = await client.get(`/api/v1/projects/${opts.project}/mcp-config`); + .action((opts: { project: string; output: string; merge?: boolean; stdout?: boolean }) => { + const mcpConfig: McpConfig = { + mcpServers: { + [opts.project]: { + command: 'mcpctl', + args: ['mcp', '-p', opts.project], + }, + }, + }; if (opts.stdout) { - apiLog(JSON.stringify(mcpConfig, null, 2)); + log(JSON.stringify(mcpConfig, null, 2)); return; } @@ -121,8 +127,19 @@ export function createConfigCommand(deps?: Partial, apiDeps?: writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n'); const serverCount = Object.keys(finalConfig.mcpServers).length; - apiLog(`Wrote ${outputPath} (${serverCount} server(s))`); + log(`Wrote ${outputPath} (${serverCount} server(s))`); }); + if (hidden) { + // Commander shows empty-description commands but they won't clutter help output + void cmd; // suppress unused lint + } + } + + registerClaudeCommand('claude', false); + registerClaudeCommand('claude-generate', true); // backward compat + + if (apiDeps) { + const { client, credentialsDeps, log: apiLog } = apiDeps; config .command('impersonate') diff --git a/src/cli/src/commands/mcp.ts b/src/cli/src/commands/mcp.ts new file mode 100644 index 0000000..ddf410c --- /dev/null +++ b/src/cli/src/commands/mcp.ts @@ -0,0 +1,196 @@ +import { Command } from 'commander'; +import http from 'node:http'; +import { createInterface } from 'node:readline'; + +export interface McpBridgeOptions { + projectName: string; + mcplocalUrl: string; + token?: string | undefined; + stdin: NodeJS.ReadableStream; + stdout: NodeJS.WritableStream; + stderr: NodeJS.WritableStream; +} + +function postJsonRpc( + url: string, + body: string, + sessionId: string | undefined, + token: string | undefined, +): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const headers: Record = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + if (sessionId) { + headers['mcp-session-id'] = sessionId; + } + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const req = http.request( + { + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname, + method: 'POST', + headers, + timeout: 30_000, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks).toString('utf-8'), + }); + }); + }, + ); + req.on('error', reject); + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timed out')); + }); + req.write(body); + req.end(); + }); +} + +function sendDelete( + url: string, + sessionId: string, + token: string | undefined, +): Promise { + return new Promise((resolve) => { + const parsed = new URL(url); + const headers: Record = { + 'mcp-session-id': sessionId, + }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const req = http.request( + { + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname, + method: 'DELETE', + headers, + timeout: 5_000, + }, + () => resolve(), + ); + req.on('error', () => resolve()); // Best effort cleanup + req.on('timeout', () => { + req.destroy(); + resolve(); + }); + req.end(); + }); +} + +/** + * STDIO-to-Streamable-HTTP MCP bridge. + * + * Reads JSON-RPC messages line-by-line from stdin, POSTs them to + * mcplocal's project endpoint, and writes responses to stdout. + */ +export async function runMcpBridge(opts: McpBridgeOptions): Promise { + const { projectName, mcplocalUrl, token, stdin, stdout, stderr } = opts; + const endpointUrl = `${mcplocalUrl.replace(/\/$/, '')}/projects/${encodeURIComponent(projectName)}/mcp`; + + let sessionId: string | undefined; + + const rl = createInterface({ input: stdin, crlfDelay: Infinity }); + + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; + + try { + const result = await postJsonRpc(endpointUrl, trimmed, sessionId, token); + + // Capture session ID from first response + if (!sessionId) { + const sid = result.headers['mcp-session-id']; + if (typeof sid === 'string') { + sessionId = sid; + } + } + + if (result.status >= 400) { + stderr.write(`MCP bridge error: HTTP ${result.status}: ${result.body}\n`); + // Still forward the response body — it may contain a JSON-RPC error + } + + stdout.write(result.body + '\n'); + } catch (err) { + stderr.write(`MCP bridge error: ${err instanceof Error ? err.message : String(err)}\n`); + } + } + + // stdin closed — cleanup session + if (sessionId) { + await sendDelete(endpointUrl, sessionId, token); + } +} + +export interface McpCommandDeps { + getProject: () => string | undefined; + configLoader?: () => { mcplocalUrl: string }; + credentialsLoader?: () => { token: string } | null; +} + +export function createMcpCommand(deps: McpCommandDeps): Command { + const cmd = new Command('mcp') + .description('MCP STDIO transport bridge — connects stdin/stdout to a project MCP endpoint') + .action(async () => { + const projectName = deps.getProject(); + if (!projectName) { + process.stderr.write('Error: --project is required for the mcp command\n'); + process.exitCode = 1; + return; + } + + let mcplocalUrl = 'http://localhost:3200'; + if (deps.configLoader) { + mcplocalUrl = deps.configLoader().mcplocalUrl; + } else { + try { + const { loadConfig } = await import('../config/index.js'); + mcplocalUrl = loadConfig().mcplocalUrl; + } catch { + // Use default + } + } + + let token: string | undefined; + if (deps.credentialsLoader) { + token = deps.credentialsLoader()?.token; + } else { + try { + const { loadCredentials } = await import('../auth/index.js'); + token = loadCredentials()?.token; + } catch { + // No credentials + } + } + + await runMcpBridge({ + projectName, + mcplocalUrl, + token, + stdin: process.stdin, + stdout: process.stdout, + stderr: process.stderr, + }); + }); + + return cmd; +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index f2e7461..17a5632 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -13,6 +13,7 @@ import { createEditCommand } from './commands/edit.js'; import { createBackupCommand, createRestoreCommand } from './commands/backup.js'; import { createLoginCommand, createLogoutCommand } from './commands/auth.js'; import { createAttachServerCommand, createDetachServerCommand } from './commands/project-ops.js'; +import { createMcpCommand } from './commands/mcp.js'; import { ApiClient, ApiError } from './api-client.js'; import { loadConfig } from './config/index.js'; import { loadCredentials } from './auth/index.js'; @@ -150,6 +151,9 @@ export function createProgram(): Command { }; program.addCommand(createAttachServerCommand(projectOpsDeps), { hidden: true }); program.addCommand(createDetachServerCommand(projectOpsDeps), { hidden: true }); + program.addCommand(createMcpCommand({ + getProject: () => program.opts().project as string | undefined, + }), { hidden: true }); return program; } diff --git a/src/cli/tests/commands/claude.test.ts b/src/cli/tests/commands/claude.test.ts index ac7c126..ef9ae93 100644 --- a/src/cli/tests/commands/claude.test.ts +++ b/src/cli/tests/commands/claude.test.ts @@ -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; 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']); }); }); diff --git a/src/cli/tests/commands/mcp.test.ts b/src/cli/tests/commands/mcp.test.ts new file mode 100644 index 0000000..588706a --- /dev/null +++ b/src/cli/tests/commands/mcp.test.ts @@ -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((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'); + }); +}); diff --git a/src/mcplocal/src/discovery.ts b/src/mcplocal/src/discovery.ts index df70a9c..9b01506 100644 --- a/src/mcplocal/src/discovery.ts +++ b/src/mcplocal/src/discovery.ts @@ -35,7 +35,7 @@ export async function refreshProjectUpstreams( let servers: McpdServer[]; if (authToken) { // Forward the client's auth token to mcpd so RBAC applies - const result = await mcpdClient.forward('GET', path, '', undefined); + const result = await mcpdClient.forward('GET', path, '', undefined, authToken); if (result.status >= 400) { throw new Error(`Failed to fetch project servers: ${result.status}`); } diff --git a/src/mcplocal/tests/project-discovery.test.ts b/src/mcplocal/tests/project-discovery.test.ts index 3ae9287..68a6aee 100644 --- a/src/mcplocal/tests/project-discovery.test.ts +++ b/src/mcplocal/tests/project-discovery.test.ts @@ -54,7 +54,7 @@ describe('refreshProjectUpstreams', () => { 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); + expect(client.forward).toHaveBeenCalledWith('GET', '/api/v1/projects/smart-home/servers', '', undefined, 'user-token-123'); expect(router.getUpstreamNames()).toContain('grafana'); });