From cd1c266fe1548ab82e63d83b7fa0f23f70916614 Mon Sep 17 00:00:00 2001 From: silverback Date: Tue, 17 Feb 2026 21:53:50 +0000 Subject: [PATCH 1/6] Add silverback-auth MCP authentication proxy Implement a JSON-RPC 2.0 proxy that sits between Claude Code CLI and MCP servers, adding JWT-based authentication and glob-pattern tool filtering. Replaces the previous Go scaffolding with a TypeScript implementation co-located in the existing src/ tree. Core modules: token validation/generation, JSON-RPC parsing, child process management, bidirectional proxy with write serialization, config wrapping for workspace setup, and /sb-mcp-token Slack command. 75 new tests covering all modules. Bypass mode when MCP_JWT_SECRET is unset preserves backward compatibility. --- Dockerfile | 12 +- config/commands.yaml | 6 + package.json | 2 + pnpm-lock.yaml | 6 + src/bot/commands/mcp-token.ts | 119 +++++++++ src/bot/commands/registry.ts | 2 +- src/index.ts | 2 + src/mcp-auth/__tests__/child.test.ts | 73 ++++++ src/mcp-auth/__tests__/config.test.ts | 73 ++++++ src/mcp-auth/__tests__/jsonrpc.test.ts | 88 +++++++ src/mcp-auth/__tests__/proxy.test.ts | 275 +++++++++++++++++++++ src/mcp-auth/__tests__/token.test.ts | 177 +++++++++++++ src/mcp-auth/__tests__/wrap-config.test.ts | 257 +++++++++++++++++++ src/mcp-auth/child.ts | 67 +++++ src/mcp-auth/config.ts | 32 +++ src/mcp-auth/index.ts | 102 ++++++++ src/mcp-auth/jsonrpc.ts | 44 ++++ src/mcp-auth/proxy.ts | 164 ++++++++++++ src/mcp-auth/token.ts | 117 +++++++++ src/mcp-auth/wrap-config.ts | 137 ++++++++++ src/types/index.ts | 45 ++++ src/workspace/manager.ts | 31 +++ 22 files changed, 1819 insertions(+), 12 deletions(-) create mode 100644 src/bot/commands/mcp-token.ts create mode 100644 src/mcp-auth/__tests__/child.test.ts create mode 100644 src/mcp-auth/__tests__/config.test.ts create mode 100644 src/mcp-auth/__tests__/jsonrpc.test.ts create mode 100644 src/mcp-auth/__tests__/proxy.test.ts create mode 100644 src/mcp-auth/__tests__/token.test.ts create mode 100644 src/mcp-auth/__tests__/wrap-config.test.ts create mode 100644 src/mcp-auth/child.ts create mode 100644 src/mcp-auth/config.ts create mode 100644 src/mcp-auth/index.ts create mode 100644 src/mcp-auth/jsonrpc.ts create mode 100644 src/mcp-auth/proxy.ts create mode 100644 src/mcp-auth/token.ts create mode 100644 src/mcp-auth/wrap-config.ts diff --git a/Dockerfile b/Dockerfile index b7455fc..19e7322 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,21 +27,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && apt-get update && apt-get install -y --no-install-recommends gh \ && rm -rf /var/lib/apt/lists/* -# Install Go (supports both amd64 and arm64 architectures) -ARG GO_VERSION=1.24.13 -RUN ARCH=$(dpkg --print-architecture) && \ - curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${ARCH}.tar.gz" \ - | tar -C /usr/local -xzf - -ENV PATH="/usr/local/go/bin:/home/bot/go/bin:${PATH}" -ENV GOPATH="/home/bot/go" - # Install Claude Code CLI RUN npm install -g @anthropic-ai/claude-code # Create non-root user -RUN useradd -m -s /bin/bash bot \ - && mkdir -p /home/bot/go \ - && chown bot:bot /home/bot/go +RUN useradd -m -s /bin/bash bot # Install oh-my-claudecode plugin as bot user USER bot diff --git a/config/commands.yaml b/config/commands.yaml index 672bb24..93507f4 100644 --- a/config/commands.yaml +++ b/config/commands.yaml @@ -29,6 +29,12 @@ commands: handler: "./handlers/connect" enabled: true + - name: sb-mcp-token + description: "Generate an MCP auth token for testing" + usage: "/sb-mcp-token [ttl_hours]" + handler: "./handlers/mcp-token" + enabled: true + # OMC Commands - name: sb-autopilot description: "Start autopilot mode" diff --git a/package.json b/package.json index acb57a5..9c600d0 100644 --- a/package.json +++ b/package.json @@ -15,11 +15,13 @@ "dotenv": "^17.2.4", "ioredis": "^5.4.2", "js-yaml": "^4.1.0", + "jsonwebtoken": "^9.0.3", "pino": "^9.6.0", "uuid": "^11.1.0" }, "devDependencies": { "@types/js-yaml": "^4.0.9", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.19.11", "@types/uuid": "^10.0.0", "chokidar": "^5.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e70e9d9..e06be29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: js-yaml: specifier: ^4.1.0 version: 4.1.1 + jsonwebtoken: + specifier: ^9.0.3 + version: 9.0.3 pino: specifier: ^9.6.0 version: 9.14.0 @@ -33,6 +36,9 @@ importers: '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 '@types/node': specifier: ^22.19.11 version: 22.19.11 diff --git a/src/bot/commands/mcp-token.ts b/src/bot/commands/mcp-token.ts new file mode 100644 index 0000000..0757107 --- /dev/null +++ b/src/bot/commands/mcp-token.ts @@ -0,0 +1,119 @@ +import { generateToken } from '../../mcp-auth/token'; +import { Logger } from '../../logging/logger'; + +const logger = new Logger('cmd:mcp-token'); + +/** + * Create handler for /sb-mcp-token command. + * Generates MCP auth tokens for tool access. + * Usage: /sb-mcp-token [ttl_hours] + */ +export function createMcpTokenHandler(): (command: any, client: any) => Promise { + return async (command, client) => { + const args = (command.text || '').trim().split(/\s+/); + + // Validate MCP_JWT_SECRET is configured + const jwtSecret = process.env.MCP_JWT_SECRET; + if (!jwtSecret) { + await client.chat.postEphemeral({ + channel: command.channel_id, + user: command.user_id, + text: ':x: MCP authentication is not configured. `MCP_JWT_SECRET` environment variable is not set.', + }); + return; + } + + // Parse arguments + if (args.length < 2 || !args[0]) { + await client.chat.postEphemeral({ + channel: command.channel_id, + user: command.user_id, + text: [ + ':key: *MCP Token Generator*', + '', + '*Usage:* `/sb-mcp-token [ttl_hours]`', + '', + '*Parameters:*', + '• `env` - Target environment (e.g., `dev`, `prod`)', + '• `tools` - Comma-separated tool patterns (e.g., `list_*,get_*,raw_sql_query`)', + '• `ttl_hours` - Token lifetime in hours (default: 4, max: 8)', + '', + '*Examples:*', + '```', + '/sb-mcp-token dev list_*,get_* 4', + '/sb-mcp-token prod * 1', + '/sb-mcp-token dev raw_sql_query,list_tables', + '```', + '', + ':warning: Tokens block all tools by default. You must specify which tools to allow.', + ].join('\n'), + }); + return; + } + + const env = args[0]; + const toolsCsv = args[1]; + const ttlHours = Math.min(parseFloat(args[2] || '4'), 8); + + if (isNaN(ttlHours) || ttlHours <= 0) { + await client.chat.postEphemeral({ + channel: command.channel_id, + user: command.user_id, + text: ':x: Invalid TTL. Must be a positive number (max 8 hours).', + }); + return; + } + + const tools = toolsCsv.split(',').map((t: string) => t.trim()).filter(Boolean); + if (tools.length === 0) { + await client.chat.postEphemeral({ + channel: command.channel_id, + user: command.user_id, + text: ':x: At least one tool pattern is required.', + }); + return; + } + + // sub is automatically the Slack user ID + const sub = command.user_id; + const ttlSeconds = Math.round(ttlHours * 3600); + + try { + const token = generateToken(jwtSecret, sub, env, tools, ttlSeconds); + + const expiresAt = new Date(Date.now() + ttlSeconds * 1000); + + if (tools.includes('*')) { + logger.warn('token generated with wildcard tool access', { sub, env }); + } + + await client.chat.postEphemeral({ + channel: command.channel_id, + user: command.user_id, + text: [ + ':white_check_mark: *MCP Token Generated*', + '', + `*Subject:* ${sub}`, + `*Environment:* ${env}`, + `*Tools:* \`${tools.join(', ')}\``, + `*Expires:* ${expiresAt.toISOString()}`, + '', + '```', + token, + '```', + '', + ':lock: This token is only visible to you.', + ].join('\n'), + }); + + logger.info('MCP token generated', { sub, env, tools, ttlHours }); + } catch (err) { + logger.error('Failed to generate MCP token', { error: err }); + await client.chat.postEphemeral({ + channel: command.channel_id, + user: command.user_id, + text: `:x: Failed to generate token: ${(err as Error).message}`, + }); + } + }; +} diff --git a/src/bot/commands/registry.ts b/src/bot/commands/registry.ts index e165911..84aceb5 100644 --- a/src/bot/commands/registry.ts +++ b/src/bot/commands/registry.ts @@ -9,7 +9,7 @@ import { Logger } from '../../logging/logger'; const logger = new Logger('command-registry'); // Commands that don't require a channel to be connected to a repo -const CONNECTION_EXEMPT = new Set(['sb-connect', 'sb-status', 'sb-queue', 'sb-cancel']); +const CONNECTION_EXEMPT = new Set(['sb-connect', 'sb-status', 'sb-queue', 'sb-cancel', 'sb-mcp-token']); export class CommandRegistry { private commands: Map = new Map(); diff --git a/src/index.ts b/src/index.ts index 0cbc831..e6fd09f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { createStatusHandler } from './bot/commands/status'; import { createQueueHandler } from './bot/commands/queue'; import { createConnectHandler } from './bot/commands/connect'; import { createOmcHandler, createOmcCancelHandler } from './bot/commands/omc'; +import { createMcpTokenHandler } from './bot/commands/mcp-token'; import { RequestQueue } from './queue/request-queue'; import { SessionManager } from './orchestrator/session'; @@ -124,6 +125,7 @@ async function main(): Promise { registry.registerHandler('sb-status', createStatusHandler(queue, auth, executor, workspaceManager)); registry.registerHandler('sb-queue', createQueueHandler(queue)); registry.registerHandler('sb-connect', createConnectHandler(workspaceManager)); + registry.registerHandler('sb-mcp-token', createMcpTokenHandler()); // OMC mode handlers registry.registerHandler('sb-autopilot', createOmcHandler(queue, 'sb-autopilot', 'autopilot')); registry.registerHandler('sb-ralph', createOmcHandler(queue, 'sb-ralph', 'ralph')); diff --git a/src/mcp-auth/__tests__/child.test.ts b/src/mcp-auth/__tests__/child.test.ts new file mode 100644 index 0000000..ef82e46 --- /dev/null +++ b/src/mcp-auth/__tests__/child.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { spawnChild, shutdownChild } from '../child'; + +describe('child', () => { + it('spawns a process and communicates via stdin/stdout', async () => { + const child = spawnChild('cat', [], {}, {}); + + const msg = '{"jsonrpc":"2.0","id":1,"method":"test"}\n'; + child.stdin!.write(msg); + child.stdin!.end(); + + const chunks: Buffer[] = []; + child.stdout!.on('data', (chunk: Buffer) => chunks.push(chunk)); + + const code = await new Promise((resolve) => { + child.on('exit', (code) => resolve(code ?? 1)); + }); + + expect(code).toBe(0); + expect(Buffer.concat(chunks).toString()).toBe(msg); + }); + + it('strips MCP_JWT_SECRET and MCP_TOKEN from child env', async () => { + // Set test env vars + const origSecret = process.env.MCP_JWT_SECRET; + const origToken = process.env.MCP_TOKEN; + process.env.MCP_JWT_SECRET = 'test-secret'; + process.env.MCP_TOKEN = 'test-token'; + + try { + const child = spawnChild('env', [], {}, {}); + + const chunks: Buffer[] = []; + child.stdout!.on('data', (chunk: Buffer) => chunks.push(chunk)); + + await new Promise((resolve) => { + child.on('exit', () => resolve()); + }); + + const output = Buffer.concat(chunks).toString(); + expect(output).not.toContain('MCP_JWT_SECRET='); + expect(output).not.toContain('MCP_TOKEN='); + } finally { + // Restore + if (origSecret !== undefined) process.env.MCP_JWT_SECRET = origSecret; + else delete process.env.MCP_JWT_SECRET; + if (origToken !== undefined) process.env.MCP_TOKEN = origToken; + else delete process.env.MCP_TOKEN; + } + }); + + it('merges config and extra env', async () => { + const child = spawnChild('env', [], { CONFIG_VAR: 'hello' }, { MCP_ENV: 'dev' }); + + const chunks: Buffer[] = []; + child.stdout!.on('data', (chunk: Buffer) => chunks.push(chunk)); + + await new Promise((resolve) => { + child.on('exit', () => resolve()); + }); + + const output = Buffer.concat(chunks).toString(); + expect(output).toContain('CONFIG_VAR=hello'); + expect(output).toContain('MCP_ENV=dev'); + }); + + it('returns exit code on shutdown', async () => { + const child = spawnChild('sleep', ['60'], {}, {}); + const code = await shutdownChild(child, 2000); + // SIGTERM should cause exit (typically code 143 on Linux, or null -> 1) + expect(typeof code).toBe('number'); + }); +}); diff --git a/src/mcp-auth/__tests__/config.test.ts b/src/mcp-auth/__tests__/config.test.ts new file mode 100644 index 0000000..8952090 --- /dev/null +++ b/src/mcp-auth/__tests__/config.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { loadConfig } from '../config'; +import { writeFileSync, mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +describe('config', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `mcp-auth-test-${Date.now()}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('loads a valid config', () => { + const configPath = join(tempDir, 'config.json'); + writeFileSync(configPath, JSON.stringify({ + command: './mcp-server', + args: ['--verbose'], + env: { VAULT_ADDR: 'http://vault:8200' }, + })); + + const config = loadConfig(configPath); + expect(config.command).toBe('./mcp-server'); + expect(config.args).toEqual(['--verbose']); + expect(config.env).toEqual({ VAULT_ADDR: 'http://vault:8200' }); + }); + + it('defaults args and env when omitted', () => { + const configPath = join(tempDir, 'config.json'); + writeFileSync(configPath, JSON.stringify({ command: './mcp-server' })); + + const config = loadConfig(configPath); + expect(config.command).toBe('./mcp-server'); + expect(config.args).toEqual([]); + expect(config.env).toEqual({}); + }); + + it('rejects missing command', () => { + const configPath = join(tempDir, 'config.json'); + writeFileSync(configPath, JSON.stringify({ args: [] })); + + expect(() => loadConfig(configPath)).toThrow('command'); + }); + + it('rejects malformed JSON', () => { + const configPath = join(tempDir, 'config.json'); + writeFileSync(configPath, 'not json'); + + expect(() => loadConfig(configPath)).toThrow('invalid JSON'); + }); + + it('rejects missing file', () => { + expect(() => loadConfig('/nonexistent/config.json')).toThrow('failed to read'); + }); + + it('ignores extra fields', () => { + const configPath = join(tempDir, 'config.json'); + writeFileSync(configPath, JSON.stringify({ + command: './mcp-server', + unknown_field: 'ignored', + another: 123, + })); + + const config = loadConfig(configPath); + expect(config.command).toBe('./mcp-server'); + expect((config as any).unknown_field).toBeUndefined(); + }); +}); diff --git a/src/mcp-auth/__tests__/jsonrpc.test.ts b/src/mcp-auth/__tests__/jsonrpc.test.ts new file mode 100644 index 0000000..6055ad1 --- /dev/null +++ b/src/mcp-auth/__tests__/jsonrpc.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { parseMessage, serializeMessage, isRequest, isResponse, isNotification, createError, ERR_INSUFFICIENT_SCOPE, ERR_PARSE_ERROR } from '../jsonrpc'; + +describe('jsonrpc', () => { + describe('parseMessage', () => { + it('parses a request', () => { + const msg = parseMessage('{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"test"}}'); + expect(msg.jsonrpc).toBe('2.0'); + expect(msg.id).toBe(1); + expect(msg.method).toBe('tools/call'); + }); + + it('parses a response', () => { + const msg = parseMessage('{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}'); + expect(msg.id).toBe(1); + expect(msg.result).toEqual({ tools: [] }); + }); + + it('parses a notification', () => { + const msg = parseMessage('{"jsonrpc":"2.0","method":"notifications/cancelled"}'); + expect(msg.method).toBe('notifications/cancelled'); + expect(msg.id).toBeUndefined(); + }); + + it('parses an error response', () => { + const msg = parseMessage('{"jsonrpc":"2.0","id":1,"error":{"code":-32003,"message":"denied"}}'); + expect(msg.error?.code).toBe(-32003); + expect(msg.error?.message).toBe('denied'); + }); + + it('throws on malformed JSON', () => { + expect(() => parseMessage('not json')).toThrow(); + }); + }); + + describe('serializeMessage', () => { + it('serializes and adds newline', () => { + const result = serializeMessage({ jsonrpc: '2.0', id: 1, result: { ok: true } }); + expect(result).toBe('{"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n'); + }); + + it('round-trips correctly', () => { + const original = { jsonrpc: '2.0', id: 42, method: 'test', params: { x: 1 } }; + const serialized = serializeMessage(original); + const parsed = parseMessage(serialized.trim()); + expect(parsed).toEqual(original); + }); + }); + + describe('message classification', () => { + it('identifies requests', () => { + const msg = { jsonrpc: '2.0', id: 1, method: 'tools/call' }; + expect(isRequest(msg)).toBe(true); + expect(isResponse(msg)).toBe(false); + expect(isNotification(msg)).toBe(false); + }); + + it('identifies responses', () => { + const msg = { jsonrpc: '2.0', id: 1, result: {} }; + expect(isRequest(msg)).toBe(false); + expect(isResponse(msg)).toBe(true); + expect(isNotification(msg)).toBe(false); + }); + + it('identifies notifications', () => { + const msg = { jsonrpc: '2.0', method: 'notifications/cancelled' }; + expect(isRequest(msg)).toBe(false); + expect(isResponse(msg)).toBe(false); + expect(isNotification(msg)).toBe(true); + }); + }); + + describe('createError', () => { + it('creates a -32003 error', () => { + const err = createError(1, ERR_INSUFFICIENT_SCOPE, 'tool not permitted'); + expect(err.jsonrpc).toBe('2.0'); + expect(err.id).toBe(1); + expect(err.error?.code).toBe(-32003); + expect(err.error?.message).toBe('tool not permitted'); + }); + + it('creates a -32700 parse error', () => { + const err = createError(null, ERR_PARSE_ERROR, 'Parse error'); + expect(err.error?.code).toBe(-32700); + expect(err.id).toBeNull(); + }); + }); +}); diff --git a/src/mcp-auth/__tests__/proxy.test.ts b/src/mcp-auth/__tests__/proxy.test.ts new file mode 100644 index 0000000..3c83411 --- /dev/null +++ b/src/mcp-auth/__tests__/proxy.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect } from 'vitest'; +import { PassThrough } from 'stream'; +import { runProxy } from '../proxy'; + +function createMockStreams() { + return { + clientIn: new PassThrough(), + clientOut: new PassThrough(), + serverIn: new PassThrough(), + serverOut: new PassThrough(), + }; +} + +function collectOutput(stream: PassThrough): Promise { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer) => chunks.push(chunk)); + stream.on('end', () => resolve(Buffer.concat(chunks).toString())); + // Also resolve after a short timeout for tests that don't end the stream + setTimeout(() => resolve(Buffer.concat(chunks).toString()), 200); + }); +} + +describe('proxy', () => { + it('passes through in bypass mode', async () => { + const { clientIn, clientOut, serverIn, serverOut } = createMockStreams(); + + const proxyDone = runProxy({ + clientIn, clientOut, serverIn, serverOut, + toolPatterns: null, bypass: true, + }); + + const serverOutput = collectOutput(serverIn); + const clientOutput = collectOutput(clientOut); + + // Client sends a message + clientIn.write('{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"any_tool"}}\n'); + + // Server sends a response + serverOut.write('{"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n'); + + // End streams + clientIn.end(); + serverOut.end(); + + await proxyDone; + + const serverGot = await serverOutput; + expect(serverGot).toContain('"tools/call"'); + + const clientGot = await clientOutput; + expect(clientGot).toContain('"ok":true'); + }); + + it('denies tools/call for unmatched pattern with -32003', async () => { + const { clientIn, clientOut, serverIn, serverOut } = createMockStreams(); + + const proxyDone = runProxy({ + clientIn, clientOut, serverIn, serverOut, + toolPatterns: ['list_*'], bypass: false, + }); + + const clientOutput = collectOutput(clientOut); + const serverOutput = collectOutput(serverIn); + + // Client tries to call a denied tool + clientIn.write('{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"raw_sql_query"}}\n'); + clientIn.end(); + serverOut.end(); + + await proxyDone; + + const clientGot = await clientOutput; + expect(clientGot).toContain('-32003'); + expect(clientGot).toContain('not permitted'); + + // Server should NOT have received the denied call + const serverGot = await serverOutput; + expect(serverGot).not.toContain('raw_sql_query'); + }); + + it('allows tools/call for matching pattern', async () => { + const { clientIn, clientOut, serverIn, serverOut } = createMockStreams(); + + const proxyDone = runProxy({ + clientIn, clientOut, serverIn, serverOut, + toolPatterns: ['list_*'], bypass: false, + }); + + const serverOutput = collectOutput(serverIn); + + // Client calls an allowed tool + clientIn.write('{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_tables"}}\n'); + + // Server responds + serverOut.write('{"jsonrpc":"2.0","id":1,"result":{"content":[]}}\n'); + + clientIn.end(); + serverOut.end(); + + await proxyDone; + + const serverGot = await serverOutput; + expect(serverGot).toContain('list_tables'); + }); + + it('filters tools/list responses', async () => { + const { clientIn, clientOut, serverIn, serverOut } = createMockStreams(); + + const proxyDone = runProxy({ + clientIn, clientOut, serverIn, serverOut, + toolPatterns: ['list_*'], bypass: false, + }); + + const clientOutput = collectOutput(clientOut); + + // Client requests tools list + clientIn.write('{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n'); + + // Server responds with both allowed and denied tools + serverOut.write(JSON.stringify({ + jsonrpc: '2.0', + id: 1, + result: { + tools: [ + { name: 'list_tables', description: 'List tables' }, + { name: 'raw_sql_query', description: 'Run SQL' }, + { name: 'list_schemas', description: 'List schemas' }, + ], + }, + }) + '\n'); + + clientIn.end(); + serverOut.end(); + + await proxyDone; + + const clientGot = await clientOutput; + expect(clientGot).toContain('list_tables'); + expect(clientGot).toContain('list_schemas'); + expect(clientGot).not.toContain('raw_sql_query'); + }); + + it('passes through non-tool messages', async () => { + const { clientIn, clientOut, serverIn, serverOut } = createMockStreams(); + + const proxyDone = runProxy({ + clientIn, clientOut, serverIn, serverOut, + toolPatterns: ['list_*'], bypass: false, + }); + + const serverOutput = collectOutput(serverIn); + const clientOutput = collectOutput(clientOut); + + // Client sends initialize + clientIn.write('{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n'); + + // Server responds + serverOut.write('{"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}\n'); + + clientIn.end(); + serverOut.end(); + + await proxyDone; + + const serverGot = await serverOutput; + expect(serverGot).toContain('initialize'); + + const clientGot = await clientOutput; + expect(clientGot).toContain('capabilities'); + }); + + it('returns -32700 for malformed JSON from client', async () => { + const { clientIn, clientOut, serverIn, serverOut } = createMockStreams(); + + const proxyDone = runProxy({ + clientIn, clientOut, serverIn, serverOut, + toolPatterns: ['*'], bypass: false, + }); + + const clientOutput = collectOutput(clientOut); + const serverOutput = collectOutput(serverIn); + + // Client sends malformed JSON + clientIn.write('this is not json\n'); + clientIn.end(); + serverOut.end(); + + await proxyDone; + + const clientGot = await clientOutput; + expect(clientGot).toContain('-32700'); + + // Server should NOT receive malformed data + const serverGot = await serverOutput; + expect(serverGot).toBe(''); + }); + + it('returns -32700 for malformed JSON from server', async () => { + const { clientIn, clientOut, serverIn, serverOut } = createMockStreams(); + + const proxyDone = runProxy({ + clientIn, clientOut, serverIn, serverOut, + toolPatterns: ['*'], bypass: false, + }); + + const clientOutput = collectOutput(clientOut); + + // Server sends malformed JSON + serverOut.write('not valid json\n'); + clientIn.end(); + serverOut.end(); + + await proxyDone; + + const clientGot = await clientOutput; + expect(clientGot).toContain('-32700'); + }); + + it('wildcard * allows all tools', async () => { + const { clientIn, clientOut, serverIn, serverOut } = createMockStreams(); + + const proxyDone = runProxy({ + clientIn, clientOut, serverIn, serverOut, + toolPatterns: ['*'], bypass: false, + }); + + const serverOutput = collectOutput(serverIn); + + clientIn.write('{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"any_tool_name"}}\n'); + clientIn.end(); + serverOut.end(); + + await proxyDone; + + const serverGot = await serverOutput; + expect(serverGot).toContain('any_tool_name'); + }); + + it('preserves tool fields after filtering', async () => { + const { clientIn, clientOut, serverIn, serverOut } = createMockStreams(); + + const proxyDone = runProxy({ + clientIn, clientOut, serverIn, serverOut, + toolPatterns: ['my_tool'], bypass: false, + }); + + const clientOutput = collectOutput(clientOut); + + clientIn.write('{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n'); + + serverOut.write(JSON.stringify({ + jsonrpc: '2.0', + id: 1, + result: { + tools: [ + { name: 'my_tool', description: 'My tool', inputSchema: { type: 'object', properties: { x: { type: 'number' } } } }, + { name: 'other_tool', description: 'Other' }, + ], + }, + }) + '\n'); + + clientIn.end(); + serverOut.end(); + + await proxyDone; + + const clientGot = await clientOutput; + const parsed = JSON.parse(clientGot.trim()); + expect(parsed.result.tools).toHaveLength(1); + expect(parsed.result.tools[0].name).toBe('my_tool'); + expect(parsed.result.tools[0].description).toBe('My tool'); + expect(parsed.result.tools[0].inputSchema).toBeDefined(); + }); +}); diff --git a/src/mcp-auth/__tests__/token.test.ts b/src/mcp-auth/__tests__/token.test.ts new file mode 100644 index 0000000..5a3ebd6 --- /dev/null +++ b/src/mcp-auth/__tests__/token.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect } from 'vitest'; +import { validateToken, generateToken, matchToolPattern, isToolAllowed, resolveAllowedTools } from '../token'; +import jwt from 'jsonwebtoken'; + +const TEST_SECRET = 'test-secret-key-for-unit-tests'; + +describe('token', () => { + describe('generateToken + validateToken roundtrip', () => { + it('generates and validates a token successfully', () => { + const token = generateToken(TEST_SECRET, 'ross', 'dev', ['list_*', 'get_*'], 3600); + const claims = validateToken(token, TEST_SECRET); + expect(claims.sub).toBe('ross'); + expect(claims.env).toBe('dev'); + expect(claims.tools).toEqual(['list_*', 'get_*']); + expect(claims.exp - claims.iat).toBeLessThanOrEqual(3600); + }); + + it('accepts TTL of exactly 8 hours', () => { + const token = generateToken(TEST_SECRET, 'ross', 'dev', ['*'], 28800); + const claims = validateToken(token, TEST_SECRET); + expect(claims.sub).toBe('ross'); + }); + }); + + describe('validateToken', () => { + it('rejects expired token', () => { + const token = jwt.sign( + { env: 'dev', tools: ['*'] }, + TEST_SECRET, + { algorithm: 'HS256', subject: 'ross', expiresIn: -1 }, + ); + expect(() => validateToken(token, TEST_SECRET)).toThrow('token expired'); + }); + + it('rejects wrong secret', () => { + const token = generateToken(TEST_SECRET, 'ross', 'dev', ['*'], 3600); + expect(() => validateToken(token, 'wrong-secret')).toThrow('token invalid'); + }); + + it('rejects missing sub', () => { + const token = jwt.sign( + { env: 'dev', tools: ['*'] }, + TEST_SECRET, + { algorithm: 'HS256', expiresIn: 3600 }, + ); + expect(() => validateToken(token, TEST_SECRET)).toThrow('missing required claim: sub'); + }); + + it('rejects missing env', () => { + const token = jwt.sign( + { tools: ['*'] }, + TEST_SECRET, + { algorithm: 'HS256', subject: 'ross', expiresIn: 3600 }, + ); + expect(() => validateToken(token, TEST_SECRET)).toThrow('missing required claim: env'); + }); + + it('rejects missing tools', () => { + const token = jwt.sign( + { env: 'dev' }, + TEST_SECRET, + { algorithm: 'HS256', subject: 'ross', expiresIn: 3600 }, + ); + expect(() => validateToken(token, TEST_SECRET)).toThrow('missing required claim: tools'); + }); + + it('rejects empty tools array', () => { + const token = jwt.sign( + { env: 'dev', tools: [] }, + TEST_SECRET, + { algorithm: 'HS256', subject: 'ross', expiresIn: 3600 }, + ); + expect(() => validateToken(token, TEST_SECRET)).toThrow('missing required claim: tools'); + }); + + it('rejects TTL > 8 hours', () => { + const iat = Math.floor(Date.now() / 1000); + const token = jwt.sign( + { env: 'dev', tools: ['*'], iat, exp: iat + 28801 }, + TEST_SECRET, + { algorithm: 'HS256', subject: 'ross' }, + ); + expect(() => validateToken(token, TEST_SECRET)).toThrow('exceeds maximum'); + }); + + it('rejects wrong algorithm', () => { + const token = jwt.sign( + { env: 'dev', tools: ['*'] }, + TEST_SECRET, + { algorithm: 'HS384', subject: 'ross', expiresIn: 3600 }, + ); + expect(() => validateToken(token, TEST_SECRET)).toThrow('token invalid'); + }); + }); + + describe('generateToken', () => { + it('rejects TTL > 8 hours', () => { + expect(() => generateToken(TEST_SECRET, 'ross', 'dev', ['*'], 28801)).toThrow('exceeds maximum'); + }); + + it('rejects TTL <= 0', () => { + expect(() => generateToken(TEST_SECRET, 'ross', 'dev', ['*'], 0)).toThrow('must be positive'); + }); + + it('rejects empty sub', () => { + expect(() => generateToken(TEST_SECRET, '', 'dev', ['*'], 3600)).toThrow('sub is required'); + }); + + it('rejects empty tools', () => { + expect(() => generateToken(TEST_SECRET, 'ross', 'dev', [], 3600)).toThrow('tools is required'); + }); + }); + + describe('matchToolPattern', () => { + it('matches exact tool name', () => { + expect(matchToolPattern('raw_sql_query', 'raw_sql_query')).toBe(true); + }); + + it('does not match different tool name', () => { + expect(matchToolPattern('raw_sql_query', 'list_tables')).toBe(false); + }); + + it('matches wildcard *', () => { + expect(matchToolPattern('*', 'anything')).toBe(true); + expect(matchToolPattern('*', '')).toBe(true); + }); + + it('matches prefix wildcard', () => { + expect(matchToolPattern('list_*', 'list_tables')).toBe(true); + expect(matchToolPattern('list_*', 'list_schemas')).toBe(true); + expect(matchToolPattern('list_*', 'list_')).toBe(true); + }); + + it('does not match non-prefix', () => { + expect(matchToolPattern('list_*', 'get_tables')).toBe(false); + expect(matchToolPattern('list_*', 'raw_sql_query')).toBe(false); + }); + }); + + describe('isToolAllowed', () => { + it('returns true when any pattern matches', () => { + expect(isToolAllowed('list_tables', ['list_*', 'get_*'])).toBe(true); + }); + + it('returns false when no pattern matches', () => { + expect(isToolAllowed('raw_sql_query', ['list_*', 'get_*'])).toBe(false); + }); + + it('returns true for wildcard', () => { + expect(isToolAllowed('anything', ['*'])).toBe(true); + }); + }); + + describe('resolveAllowedTools', () => { + const available = ['list_tables', 'list_schemas', 'get_table_details', 'raw_sql_query', 'datalake_list_objects']; + + it('resolves single pattern', () => { + const result = resolveAllowedTools(['list_*'], available); + expect(result).toEqual(new Set(['list_tables', 'list_schemas'])); + }); + + it('resolves multiple patterns', () => { + const result = resolveAllowedTools(['list_*', 'raw_sql_query'], available); + expect(result).toEqual(new Set(['list_tables', 'list_schemas', 'raw_sql_query'])); + }); + + it('wildcard matches all', () => { + const result = resolveAllowedTools(['*'], available); + expect(result).toEqual(new Set(available)); + }); + + it('no matches returns empty set', () => { + const result = resolveAllowedTools(['nonexistent_*'], available); + expect(result).toEqual(new Set()); + }); + }); +}); diff --git a/src/mcp-auth/__tests__/wrap-config.test.ts b/src/mcp-auth/__tests__/wrap-config.test.ts new file mode 100644 index 0000000..7e42ba5 --- /dev/null +++ b/src/mcp-auth/__tests__/wrap-config.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import * as path from 'path'; +import { loadAuthConfig, discoverMcpServers, wrapMcpServers, writeSettingsLocal } from '../wrap-config'; + +describe('wrap-config', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(path.join(tmpdir(), 'mcp-wrap-test-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + // --- loadAuthConfig --- + + describe('loadAuthConfig', () => { + it('returns parsed config when file exists', () => { + writeFileSync( + path.join(tempDir, '.silverback-auth.json'), + JSON.stringify({ servers: ['my-server', 'other-server'] }), + ); + const config = loadAuthConfig(tempDir); + expect(config).not.toBeNull(); + expect(config!.servers).toEqual(['my-server', 'other-server']); + }); + + it('returns null when file does not exist', () => { + const config = loadAuthConfig(tempDir); + expect(config).toBeNull(); + }); + + it('throws when servers is not an array', () => { + writeFileSync( + path.join(tempDir, '.silverback-auth.json'), + JSON.stringify({ servers: 'not-an-array' }), + ); + expect(() => loadAuthConfig(tempDir)).toThrow('servers must be an array'); + }); + }); + + // --- discoverMcpServers --- + + describe('discoverMcpServers', () => { + it('reads from .mcp.json', () => { + writeFileSync( + path.join(tempDir, '.mcp.json'), + JSON.stringify({ + mcpServers: { + 'server-a': { command: 'node', args: ['a.js'] }, + }, + }), + ); + const servers = discoverMcpServers(tempDir); + expect(servers['server-a']).toEqual({ command: 'node', args: ['a.js'] }); + }); + + it('reads from .claude/settings.json', () => { + mkdirSync(path.join(tempDir, '.claude'), { recursive: true }); + writeFileSync( + path.join(tempDir, '.claude', 'settings.json'), + JSON.stringify({ + mcpServers: { + 'server-b': { command: 'python', args: ['b.py'] }, + }, + }), + ); + const servers = discoverMcpServers(tempDir); + expect(servers['server-b']).toEqual({ command: 'python', args: ['b.py'] }); + }); + + it('.claude/settings.json overrides .mcp.json for same server name', () => { + writeFileSync( + path.join(tempDir, '.mcp.json'), + JSON.stringify({ + mcpServers: { + 'shared': { command: 'old-cmd', args: [] }, + }, + }), + ); + mkdirSync(path.join(tempDir, '.claude'), { recursive: true }); + writeFileSync( + path.join(tempDir, '.claude', 'settings.json'), + JSON.stringify({ + mcpServers: { + 'shared': { command: 'new-cmd', args: ['--flag'] }, + }, + }), + ); + const servers = discoverMcpServers(tempDir); + expect(servers['shared'].command).toBe('new-cmd'); + }); + + it('returns empty object when no config files exist', () => { + const servers = discoverMcpServers(tempDir); + expect(servers).toEqual({}); + }); + + it('handles malformed .mcp.json gracefully (empty result for that source)', () => { + writeFileSync(path.join(tempDir, '.mcp.json'), 'not valid json {{{'); + const servers = discoverMcpServers(tempDir); + expect(servers).toEqual({}); + }); + }); + + // --- wrapMcpServers --- + + describe('wrapMcpServers', () => { + const proxyBinaryPath = '/usr/local/bin/mcp-proxy'; + const jwtSecret = 'test-secret'; + const token = 'test-token'; + + function makeOptions(extra?: Partial[0]>) { + return { workspacePath: tempDir, proxyBinaryPath, jwtSecret, token, ...extra }; + } + + function writeAuthConfig(servers: string[]) { + writeFileSync( + path.join(tempDir, '.silverback-auth.json'), + JSON.stringify({ servers }), + ); + } + + function writeMcpJson(mcpServers: Record) { + writeFileSync( + path.join(tempDir, '.mcp.json'), + JSON.stringify({ mcpServers }), + ); + } + + it('known server gets wrapped with proxy binary', () => { + writeAuthConfig(['my-server']); + writeMcpJson({ 'my-server': { command: 'node', args: ['server.js'] } }); + + const result = wrapMcpServers(makeOptions()); + expect(result).not.toBeNull(); + expect(result!.mcpServers['my-server'].command).toBe('node'); + expect(result!.mcpServers['my-server'].args).toContain(proxyBinaryPath); + }); + + it('unknown server passes through unchanged', () => { + writeAuthConfig(['known-server']); + writeMcpJson({ + 'unknown-server': { command: 'python', args: ['srv.py'], env: { FOO: 'bar' } }, + }); + + const result = wrapMcpServers(makeOptions()); + expect(result).not.toBeNull(); + expect(result!.mcpServers['unknown-server']).toEqual({ + command: 'python', + args: ['srv.py'], + env: { FOO: 'bar' }, + }); + }); + + it('mix of known and unknown servers', () => { + writeAuthConfig(['known']); + writeMcpJson({ + 'known': { command: 'node', args: ['k.js'] }, + 'unknown': { command: 'ruby', args: ['u.rb'] }, + }); + + const result = wrapMcpServers(makeOptions()); + expect(result).not.toBeNull(); + // known is wrapped + expect(result!.mcpServers['known'].args).toContain(proxyBinaryPath); + // unknown passes through + expect(result!.mcpServers['unknown'].command).toBe('ruby'); + }); + + it('returns null when .silverback-auth.json does not exist', () => { + writeMcpJson({ 'server': { command: 'node' } }); + const result = wrapMcpServers(makeOptions()); + expect(result).toBeNull(); + }); + + it('returns null when no MCP servers discovered', () => { + writeAuthConfig(['some-server']); + // No .mcp.json or .claude/settings.json written + const result = wrapMcpServers(makeOptions()); + expect(result).toBeNull(); + }); + + it('wrapped server has correct command/args/env structure', () => { + writeAuthConfig(['srv']); + writeMcpJson({ 'srv': { command: 'node', args: ['index.js'] } }); + + const result = wrapMcpServers(makeOptions()); + expect(result).not.toBeNull(); + const wrapped = result!.mcpServers['srv']; + expect(wrapped.command).toBe('node'); + expect(wrapped.args).toEqual([proxyBinaryPath, '--config', expect.stringContaining('srv.json')]); + expect(wrapped.env).toEqual({ + MCP_JWT_SECRET: jwtSecret, + MCP_TOKEN: token, + }); + }); + + it('original server env vars preserved in proxy config file', () => { + writeAuthConfig(['srv']); + writeMcpJson({ + 'srv': { command: 'node', args: ['index.js'], env: { ORIGINAL_VAR: 'original-value' } }, + }); + + wrapMcpServers(makeOptions()); + + const configFilePath = path.join(tempDir, '.claude', 'mcp-auth', 'srv.json'); + expect(existsSync(configFilePath)).toBe(true); + const proxyConfig = JSON.parse(readFileSync(configFilePath, 'utf-8')); + expect(proxyConfig.env).toEqual({ ORIGINAL_VAR: 'original-value' }); + }); + + it('proxy config written to .claude/mcp-auth/{name}.json', () => { + writeAuthConfig(['target']); + writeMcpJson({ 'target': { command: 'go', args: ['run', 'main.go'] } }); + + wrapMcpServers(makeOptions()); + + const configFilePath = path.join(tempDir, '.claude', 'mcp-auth', 'target.json'); + expect(existsSync(configFilePath)).toBe(true); + const proxyConfig = JSON.parse(readFileSync(configFilePath, 'utf-8')); + expect(proxyConfig.command).toBe('go'); + expect(proxyConfig.args).toEqual(['run', 'main.go']); + }); + }); + + // --- writeSettingsLocal --- + + describe('writeSettingsLocal', () => { + it('creates .claude/ directory if needed', () => { + const settings = { mcpServers: { 'srv': { command: 'node' } } }; + writeSettingsLocal(tempDir, settings); + expect(existsSync(path.join(tempDir, '.claude'))).toBe(true); + }); + + it('writes valid JSON with mcpServers', () => { + const settings = { + mcpServers: { + 'srv': { command: 'node', args: ['index.js'], env: { KEY: 'val' } }, + }, + }; + writeSettingsLocal(tempDir, settings); + + const written = path.join(tempDir, '.claude', 'settings.local.json'); + expect(existsSync(written)).toBe(true); + const parsed = JSON.parse(readFileSync(written, 'utf-8')); + expect(parsed.mcpServers['srv']).toEqual({ + command: 'node', + args: ['index.js'], + env: { KEY: 'val' }, + }); + }); + }); +}); diff --git a/src/mcp-auth/child.ts b/src/mcp-auth/child.ts new file mode 100644 index 0000000..4182211 --- /dev/null +++ b/src/mcp-auth/child.ts @@ -0,0 +1,67 @@ +import { spawn, ChildProcess } from 'child_process'; +import { Logger } from '../logging/logger'; + +const log = new Logger('mcp-auth:child'); + +/** + * Spawn the child MCP server process. + * Strips MCP_JWT_SECRET and MCP_TOKEN from child environment. + * Merges config.env and extraEnv into child environment. + */ +export function spawnChild( + command: string, + args: string[], + configEnv: Record, + extraEnv?: Record, +): ChildProcess { + // Build child env: start with process.env, strip secrets, add config + extra + const env: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (k === 'MCP_JWT_SECRET' || k === 'MCP_TOKEN') continue; + if (v !== undefined) env[k] = v; + } + Object.assign(env, configEnv); + if (extraEnv) Object.assign(env, extraEnv); + + log.info(`spawning child: ${command} ${args.join(' ')}`); + + const child = spawn(command, args, { + stdio: ['pipe', 'pipe', 'inherit'], + env, + }); + + child.on('error', (err) => { + log.error(`child process error: ${err.message}`); + }); + + return child; +} + +/** + * Gracefully shutdown the child process. + * SIGTERM first, then SIGKILL after timeout. + */ +export function shutdownChild(proc: ChildProcess, timeoutMs = 5000): Promise { + return new Promise((resolve) => { + if (proc.exitCode !== null) { + resolve(proc.exitCode); + return; + } + + let killed = false; + const timer = setTimeout(() => { + if (!killed) { + log.info('child did not exit, sending SIGKILL'); + proc.kill('SIGKILL'); + } + }, timeoutMs); + + proc.on('exit', (code) => { + killed = true; + clearTimeout(timer); + resolve(code ?? 1); + }); + + proc.kill('SIGTERM'); + }); +} diff --git a/src/mcp-auth/config.ts b/src/mcp-auth/config.ts new file mode 100644 index 0000000..a24a434 --- /dev/null +++ b/src/mcp-auth/config.ts @@ -0,0 +1,32 @@ +import { readFileSync } from 'fs'; +import type { McpProxyConfig } from '../types'; + +export function loadConfig(configPath: string): McpProxyConfig { + let raw: string; + try { + raw = readFileSync(configPath, 'utf-8'); + } catch (err: unknown) { + throw new Error(`failed to read config file: ${configPath}: ${(err as Error).message}`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error(`invalid JSON in config file: ${configPath}`); + } + + const config = parsed as Record; + + if (!config.command || typeof config.command !== 'string') { + throw new Error('config: "command" is required and must be a string'); + } + + return { + command: config.command, + args: Array.isArray(config.args) ? config.args.map(String) : [], + env: (typeof config.env === 'object' && config.env !== null && !Array.isArray(config.env)) + ? Object.fromEntries(Object.entries(config.env as Record).map(([k, v]) => [k, String(v)])) + : {}, + }; +} diff --git a/src/mcp-auth/index.ts b/src/mcp-auth/index.ts new file mode 100644 index 0000000..5786d8d --- /dev/null +++ b/src/mcp-auth/index.ts @@ -0,0 +1,102 @@ +import { Logger } from '../logging/logger'; +import { validateToken } from './token'; +import { loadConfig } from './config'; +import { spawnChild, shutdownChild } from './child'; +import { runProxy } from './proxy'; + +const log = new Logger('mcp-auth'); + +async function main() { + // Parse --config flag + const configIdx = process.argv.indexOf('--config'); + if (configIdx === -1 || configIdx + 1 >= process.argv.length) { + log.error('usage: silverback-auth --config '); + process.exit(1); + } + const configPath = process.argv[configIdx + 1]; + + // Load config + const config = loadConfig(configPath); + + // Check auth mode + const jwtSecret = process.env.MCP_JWT_SECRET; + const bypass = !jwtSecret; + + let toolPatterns: string[] | null = null; + let mcpEnv: string | undefined; + + if (!bypass) { + const token = process.env.MCP_TOKEN; + if (!token) { + log.error('MCP_JWT_SECRET is set but MCP_TOKEN is missing'); + process.exit(1); + } + + try { + const claims = validateToken(token, jwtSecret!); + toolPatterns = claims.tools; + mcpEnv = claims.env; + log.info(`auth mode: sub=${claims.sub} env=${claims.env} tools=${claims.tools.join(',')}`); + if (claims.tools.includes('*')) { + log.warn('token grants full tool access (wildcard *)'); + } + } catch (err: unknown) { + log.error(`token validation failed: ${(err as Error).message}`); + process.exit(1); + } + } else { + log.info('bypass mode: no authentication'); + } + + // Build extra env for child + const extraEnv: Record = {}; + if (mcpEnv) { + extraEnv.MCP_ENV = mcpEnv; + } + + // Spawn child process + const child = spawnChild(config.command, config.args, config.env, extraEnv); + + // Handle signals + let shuttingDown = false; + const handleSignal = async (sig: string) => { + if (shuttingDown) return; + shuttingDown = true; + log.info(`received ${sig}, shutting down`); + const code = await shutdownChild(child); + process.exit(code); + }; + process.on('SIGTERM', () => handleSignal('SIGTERM')); + process.on('SIGINT', () => handleSignal('SIGINT')); + + // Handle child exit + child.on('exit', (code) => { + log.info(`child exited with code ${code}`); + process.exit(code ?? 1); + }); + + // Run proxy + try { + await runProxy({ + clientIn: process.stdin, + clientOut: process.stdout, + serverIn: child.stdin!, + serverOut: child.stdout!, + toolPatterns, + bypass, + }); + } catch (err: unknown) { + log.error(`proxy error: ${(err as Error).message}`); + } + + // If proxy ends, shutdown child + if (!shuttingDown) { + const code = await shutdownChild(child); + process.exit(code); + } +} + +main().catch((err) => { + console.error(`[silverback-auth] fatal: ${err.message}`); + process.exit(1); +}); diff --git a/src/mcp-auth/jsonrpc.ts b/src/mcp-auth/jsonrpc.ts new file mode 100644 index 0000000..4da1462 --- /dev/null +++ b/src/mcp-auth/jsonrpc.ts @@ -0,0 +1,44 @@ +import { createInterface, Interface } from 'readline'; +import type { Readable } from 'stream'; +import type { JsonRpcMessage } from '../types'; + +export const ERR_INSUFFICIENT_SCOPE = -32003; +export const ERR_PARSE_ERROR = -32700; + +export function parseMessage(line: string): JsonRpcMessage { + return JSON.parse(line) as JsonRpcMessage; +} + +export function serializeMessage(msg: JsonRpcMessage): string { + return JSON.stringify(msg) + '\n'; +} + +export function isRequest(msg: JsonRpcMessage): boolean { + return msg.method !== undefined && msg.id !== undefined && msg.id !== null; +} + +export function isResponse(msg: JsonRpcMessage): boolean { + return msg.method === undefined && msg.id !== undefined; +} + +export function isNotification(msg: JsonRpcMessage): boolean { + return msg.method !== undefined && (msg.id === undefined || msg.id === null); +} + +export function createError(id: number | string | null | undefined, code: number, message: string): JsonRpcMessage { + return { + jsonrpc: '2.0', + id: id ?? null, + error: { code, message }, + }; +} + +/** + * Create an async line iterator from a readable stream using readline. + */ +export function createLineReader(stream: Readable): Interface { + return createInterface({ + input: stream, + crlfDelay: Infinity, + }); +} diff --git a/src/mcp-auth/proxy.ts b/src/mcp-auth/proxy.ts new file mode 100644 index 0000000..fcefe3b --- /dev/null +++ b/src/mcp-auth/proxy.ts @@ -0,0 +1,164 @@ +import type { Readable, Writable } from 'stream'; +import { createLineReader, parseMessage, serializeMessage, createError, isRequest, isResponse, ERR_INSUFFICIENT_SCOPE, ERR_PARSE_ERROR } from './jsonrpc'; +import { isToolAllowed } from './token'; +import { Logger } from '../logging/logger'; + +const log = new Logger('mcp-auth:proxy'); + +interface ProxyOptions { + clientIn: Readable; + clientOut: Writable; + serverIn: Writable; + serverOut: Readable; + toolPatterns: string[] | null; // null = bypass mode + bypass: boolean; +} + +/** + * Run the bidirectional proxy between MCP client and server. + * Filters tools/call and tools/list based on JWT tool patterns. + */ +export function runProxy(options: ProxyOptions): Promise { + const { clientIn, clientOut, serverIn, serverOut, toolPatterns, bypass } = options; + + // Write serialization: prevent interleaved writes to clientOut + let writeChain = Promise.resolve(); + function safeWriteClient(data: string): Promise { + writeChain = writeChain.then(() => new Promise((resolve, reject) => { + clientOut.write(data, (err) => err ? reject(err) : resolve()); + })); + return writeChain; + } + + // Track pending requests for response correlation + const pending = new Map(); // id -> method + + return new Promise((resolve, reject) => { + let clientDone = false; + let serverDone = false; + + function checkDone() { + if (clientDone && serverDone) resolve(); + } + + // Client -> Server loop + const clientReader = createLineReader(clientIn); + + clientReader.on('line', async (line: string) => { + if (bypass) { + serverIn.write(line + '\n'); + return; + } + + let msg; + try { + msg = parseMessage(line); + } catch { + log.warn('malformed JSON from client'); + await safeWriteClient(serializeMessage(createError(null, ERR_PARSE_ERROR, 'Parse error'))); + return; + } + + // Track request IDs for response correlation + if (isRequest(msg) && msg.id !== undefined && msg.id !== null) { + pending.set(String(msg.id), msg.method!); + } + + // Check tools/call scope + if (msg.method === 'tools/call') { + const params = msg.params as Record | undefined; + const toolName = params?.name as string | undefined; + + if (toolName && toolPatterns && !isToolAllowed(toolName, toolPatterns)) { + log.info(`denied tools/call: ${toolName}`); + const errMsg = createError(msg.id, ERR_INSUFFICIENT_SCOPE, `tool not permitted: ${toolName}`); + await safeWriteClient(serializeMessage(errMsg)); + // Remove from pending since we handled it + if (msg.id !== undefined && msg.id !== null) { + pending.delete(String(msg.id)); + } + return; + } + } + + // Forward to server + serverIn.write(line + '\n'); + }); + + clientReader.on('close', () => { + clientDone = true; + // Signal EOF to child + if (typeof (serverIn as any).end === 'function') { // eslint-disable-line @typescript-eslint/no-explicit-any + (serverIn as any).end(); // eslint-disable-line @typescript-eslint/no-explicit-any + } + checkDone(); + }); + + // Server -> Client loop + const serverReader = createLineReader(serverOut); + + serverReader.on('line', async (line: string) => { + if (bypass) { + await safeWriteClient(line + '\n'); + return; + } + + let msg; + try { + msg = parseMessage(line); + } catch { + log.warn('malformed JSON from server'); + await safeWriteClient(serializeMessage(createError(null, ERR_PARSE_ERROR, 'Parse error'))); + return; + } + + // Check if this is a response to a tracked request + if (isResponse(msg) && msg.id !== undefined && msg.id !== null) { + const idStr = String(msg.id); + const method = pending.get(idStr); + if (method) { + pending.delete(idStr); + + // Filter tools/list responses + if (method === 'tools/list' && msg.result && toolPatterns) { + try { + const result = msg.result as { tools?: Array<{ name: string; [key: string]: unknown }> }; + if (result.tools && Array.isArray(result.tools)) { + result.tools = result.tools.filter(tool => + isToolAllowed(tool.name, toolPatterns) + ); + msg.result = result; + await safeWriteClient(serializeMessage(msg)); + return; + } + } catch (err) { + log.warn(`failed to filter tools/list response: ${err}`); + // Fall through to forward unfiltered + } + } + } + } + + // Forward to client + await safeWriteClient(line + '\n'); + }); + + serverReader.on('close', () => { + serverDone = true; + checkDone(); + }); + + // Handle errors + clientIn.on('error', (err) => { + log.error(`client input error: ${err.message}`); + clientDone = true; + checkDone(); + }); + + serverOut.on('error', (err) => { + log.error(`server output error: ${err.message}`); + serverDone = true; + checkDone(); + }); + }); +} diff --git a/src/mcp-auth/token.ts b/src/mcp-auth/token.ts new file mode 100644 index 0000000..ed5e5da --- /dev/null +++ b/src/mcp-auth/token.ts @@ -0,0 +1,117 @@ +import jwt from 'jsonwebtoken'; +import type { McpAuthClaims } from '../types'; + +const MAX_TTL_SECONDS = 28800; // 8 hours + +/** + * Validate a JWT token using HMAC-SHA256. + * Enforces required claims (sub, env, tools) and max 8h TTL. + */ +export function validateToken(tokenStr: string, secret: string): McpAuthClaims { + let decoded: jwt.JwtPayload; + try { + decoded = jwt.verify(tokenStr, secret, { algorithms: ['HS256'] }) as jwt.JwtPayload; + } catch (err: unknown) { + if (err instanceof jwt.TokenExpiredError) { + throw new Error('token expired'); + } + if (err instanceof jwt.JsonWebTokenError) { + throw new Error(`token invalid: ${err.message}`); + } + throw err; + } + + // Validate required claims + if (!decoded.sub || typeof decoded.sub !== 'string') { + throw new Error('missing required claim: sub'); + } + if (!decoded.env || typeof decoded.env !== 'string') { + throw new Error('missing required claim: env'); + } + if (!Array.isArray(decoded.tools) || decoded.tools.length === 0) { + throw new Error('missing required claim: tools'); + } + for (const t of decoded.tools) { + if (typeof t !== 'string') { + throw new Error('tools claim must be an array of strings'); + } + } + + // Enforce max TTL + const iat = decoded.iat; + const exp = decoded.exp; + if (iat === undefined || exp === undefined) { + throw new Error('missing required claims: iat and exp'); + } + if (exp - iat > MAX_TTL_SECONDS) { + throw new Error(`token TTL exceeds maximum of ${MAX_TTL_SECONDS} seconds (8 hours)`); + } + + return { + sub: decoded.sub, + env: decoded.env as string, + tools: decoded.tools as string[], + iat, + exp, + }; +} + +/** + * Generate a signed JWT token with HMAC-SHA256. + */ +export function generateToken( + secret: string, + sub: string, + env: string, + tools: string[], + ttlSeconds: number, +): string { + if (ttlSeconds > MAX_TTL_SECONDS) { + throw new Error(`TTL exceeds maximum of ${MAX_TTL_SECONDS} seconds (8 hours)`); + } + if (ttlSeconds <= 0) { + throw new Error('TTL must be positive'); + } + if (!sub) throw new Error('sub is required'); + if (!env) throw new Error('env is required'); + if (!tools || tools.length === 0) throw new Error('tools is required'); + + return jwt.sign({ env, tools }, secret, { + algorithm: 'HS256', + subject: sub, + expiresIn: ttlSeconds, + }); +} + +/** + * Match a tool name against a glob pattern. + * Supports: "*" (match all), "prefix_*" (prefix match), exact match. + */ +export function matchToolPattern(pattern: string, toolName: string): boolean { + if (pattern === '*') return true; + if (pattern.endsWith('*')) { + const prefix = pattern.slice(0, -1); + return toolName.startsWith(prefix); + } + return pattern === toolName; +} + +/** + * Check if a tool name is allowed by any of the given patterns. + */ +export function isToolAllowed(toolName: string, patterns: string[]): boolean { + return patterns.some(p => matchToolPattern(p, toolName)); +} + +/** + * Resolve which tools from an available list are allowed by the given patterns. + */ +export function resolveAllowedTools(patterns: string[], availableTools: string[]): Set { + const allowed = new Set(); + for (const tool of availableTools) { + if (isToolAllowed(tool, patterns)) { + allowed.add(tool); + } + } + return allowed; +} diff --git a/src/mcp-auth/wrap-config.ts b/src/mcp-auth/wrap-config.ts new file mode 100644 index 0000000..0ea99cc --- /dev/null +++ b/src/mcp-auth/wrap-config.ts @@ -0,0 +1,137 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { Logger } from '../logging/logger'; +import type { McpServerEntry, McpSettingsFile, SilverbackAuthConfig, McpProxyConfig } from '../types'; + +const log = new Logger('mcp-auth:wrap-config'); + +/** + * Load .silverback-auth.json from workspace root. + * Returns null if file doesn't exist (auth is opt-in per repo). + */ +export function loadAuthConfig(workspacePath: string): SilverbackAuthConfig | null { + const configPath = path.join(workspacePath, '.silverback-auth.json'); + if (!fs.existsSync(configPath)) return null; + const raw = fs.readFileSync(configPath, 'utf-8'); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed.servers)) { + throw new Error('.silverback-auth.json: servers must be an array'); + } + for (const s of parsed.servers) { + if (typeof s !== 'string') { + throw new Error('.silverback-auth.json: servers must be strings'); + } + } + return parsed as SilverbackAuthConfig; +} + +/** + * Discover MCP servers from workspace config files. + * Reads (in order, later overrides earlier): + * 1. {workspace}/.mcp.json + * 2. {workspace}/.claude/settings.json + * Does NOT read .claude/settings.local.json (we're about to write that). + */ +export function discoverMcpServers(workspacePath: string): Record { + const servers: Record = {}; + + // 1. .mcp.json + const mcpJsonPath = path.join(workspacePath, '.mcp.json'); + if (fs.existsSync(mcpJsonPath)) { + try { + const raw = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf-8')); + if (raw.mcpServers && typeof raw.mcpServers === 'object') { + Object.assign(servers, raw.mcpServers); + } + } catch (err) { + log.warn(`failed to parse .mcp.json: ${(err as Error).message}`); + } + } + + // 2. .claude/settings.json + const settingsPath = path.join(workspacePath, '.claude', 'settings.json'); + if (fs.existsSync(settingsPath)) { + try { + const raw = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + if (raw.mcpServers && typeof raw.mcpServers === 'object') { + Object.assign(servers, raw.mcpServers); + } + } catch (err) { + log.warn(`failed to parse .claude/settings.json: ${(err as Error).message}`); + } + } + + return servers; +} + +/** + * Wrap known MCP servers with the auth proxy. + * Returns a complete McpSettingsFile for settings.local.json, or null if no wrapping needed. + */ +export function wrapMcpServers(options: { + workspacePath: string; + proxyBinaryPath: string; + jwtSecret: string; + token: string; +}): McpSettingsFile | null { + const { workspacePath, proxyBinaryPath, jwtSecret, token } = options; + + // Load auth config + const authConfig = loadAuthConfig(workspacePath); + if (!authConfig) return null; + + const knownServers = new Set(authConfig.servers); + + // Discover all MCP servers + const discovered = discoverMcpServers(workspacePath); + if (Object.keys(discovered).length === 0) { + log.info('no MCP servers discovered, nothing to wrap'); + return null; + } + + // Create mcp-auth config directory + const authDir = path.join(workspacePath, '.claude', 'mcp-auth'); + fs.mkdirSync(authDir, { recursive: true }); + + const result: Record = {}; + + for (const [name, entry] of Object.entries(discovered)) { + if (knownServers.has(name)) { + // Write proxy config for this server + const proxyConfig: McpProxyConfig = { + command: entry.command, + args: entry.args || [], + env: entry.env || {}, + }; + const configPath = path.join(authDir, `${name}.json`); + fs.writeFileSync(configPath, JSON.stringify(proxyConfig, null, 2)); + + // Wrap with proxy + result[name] = { + command: 'node', + args: [proxyBinaryPath, '--config', configPath], + env: { + MCP_JWT_SECRET: jwtSecret, + MCP_TOKEN: token, + }, + }; + log.info(`wrapped MCP server: ${name}`); + } else { + // Pass through unchanged + result[name] = entry; + } + } + + return { mcpServers: result }; +} + +/** + * Write settings.local.json to workspace. + */ +export function writeSettingsLocal(workspacePath: string, settings: McpSettingsFile): void { + const claudeDir = path.join(workspacePath, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const settingsPath = path.join(claudeDir, 'settings.local.json'); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + log.info(`wrote ${settingsPath}`); +} diff --git a/src/types/index.ts b/src/types/index.ts index 2f2bb13..59a9220 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -170,3 +170,48 @@ export interface TaskContext { // RecoveryContext is an alias for TaskContext export type RecoveryContext = TaskContext; +// MCP Auth types (silverback-auth proxy) + +export interface McpAuthClaims { + sub: string; + env: string; + tools: string[]; // glob patterns: "*", "list_*", "raw_sql_query" + iat: number; + exp: number; +} + +export interface McpProxyConfig { + command: string; + args: string[]; + env: Record; +} + +export interface McpServerEntry { + command: string; + args?: string[]; + env?: Record; +} + +export interface McpSettingsFile { + mcpServers: Record; +} + +export interface SilverbackAuthConfig { + servers: string[]; // MCP server names to wrap with auth +} + +export interface JsonRpcMessage { + jsonrpc: string; + id?: number | string | null; + method?: string; + params?: unknown; + result?: unknown; + error?: JsonRpcError | null; +} + +export interface JsonRpcError { + code: number; + message: string; + data?: unknown; +} + diff --git a/src/workspace/manager.ts b/src/workspace/manager.ts index 1107e2b..336b35a 100644 --- a/src/workspace/manager.ts +++ b/src/workspace/manager.ts @@ -1,7 +1,10 @@ +import * as path from 'path'; import { KeyValueStore } from '../types'; import { RepoCache } from '../repos/cache'; import { BranchManager } from '../git/branch'; import { Logger } from '../logging/logger'; +import { wrapMcpServers, writeSettingsLocal } from '../mcp-auth/wrap-config'; +import { generateToken } from '../mcp-auth/token'; const logger = new Logger('workspace-manager'); @@ -74,6 +77,34 @@ export class WorkspaceManager { const branch = BranchManager.generateBranchName(threadId); const workspacePath = await this.repoCache.createWorkspace(threadId, mapping.org, mapping.repo, branch, cachePath); + // Wrap MCP servers with auth if configured + const jwtSecret = process.env.MCP_JWT_SECRET; + if (jwtSecret) { + try { + const defaultTools = process.env.MCP_DEFAULT_TOOLS + ? process.env.MCP_DEFAULT_TOOLS.split(',') + : ['*']; + if (defaultTools.includes('*')) { + logger.warn('workspace token grants full tool access (MCP_DEFAULT_TOOLS contains *)'); + } + const token = generateToken(jwtSecret, 'workspace', 'dev', defaultTools, 4 * 3600); + const proxyBinaryPath = path.resolve(__dirname, '../mcp-auth/index.js'); + const settings = wrapMcpServers({ + workspacePath, + proxyBinaryPath, + jwtSecret, + token, + }); + if (settings) { + writeSettingsLocal(workspacePath, settings); + logger.info('MCP servers wrapped with auth proxy', { threadId }); + } + } catch (err) { + logger.warn('Failed to wrap MCP servers with auth', { error: err, threadId }); + // Non-fatal: workspace still usable without auth wrapping + } + } + // Store workspace path await this.store.set(`workspace:${threadId}`, { workspacePath }, 7 * 24 * 60 * 60 * 1000); // 7 day TTL From 1dc19cb23a7d0a9e98444a741da200682707e92b Mon Sep 17 00:00:00 2001 From: silverback Date: Tue, 17 Feb 2026 21:55:58 +0000 Subject: [PATCH 2/6] Fix stale process-executor tests to match current CLI args Update three tests that expected removed --allowedTools flag to assert --model instead. Guard healthCheck credential test against env tokens that short-circuit the file existence check. --- src/executor/__tests__/process-executor.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/executor/__tests__/process-executor.test.ts b/src/executor/__tests__/process-executor.test.ts index 78688af..7a1e707 100644 --- a/src/executor/__tests__/process-executor.test.ts +++ b/src/executor/__tests__/process-executor.test.ts @@ -83,7 +83,7 @@ describe('ProcessExecutor', () => { '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions', - '--allowedTools', '*', + '--model', expect.any(String), '--', 'test prompt' ], expect.objectContaining({ @@ -240,12 +240,22 @@ describe('ProcessExecutor', () => { }); it('healthCheck() returns false when credentials file missing', async () => { + // Save and clear env tokens so healthCheck falls through to file check + const savedOauth = process.env.CLAUDE_CODE_OAUTH_TOKEN; + const savedApiKey = process.env.ANTHROPIC_API_KEY; + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + delete process.env.ANTHROPIC_API_KEY; + mockExistsSync.mockReturnValue(false); const result = await executor.healthCheck(); expect(result).toBe(false); expect(mockExistsSync).toHaveBeenCalled(); + + // Restore env + if (savedOauth) process.env.CLAUDE_CODE_OAUTH_TOKEN = savedOauth; + if (savedApiKey) process.env.ANTHROPIC_API_KEY = savedApiKey; }); it('healthCheck() returns true when credentials exist and claude --version succeeds', async () => { @@ -293,6 +303,6 @@ describe('ProcessExecutor', () => { const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; expect(spawnArgs).not.toContain('--dangerously-skip-permissions'); expect(spawnArgs).toContain('--print'); - expect(spawnArgs).toContain('--allowedTools'); + expect(spawnArgs).toContain('--model'); }); }); From 50eb03dc9ccce23fdf0a834e7486ae8c6d3f4cc9 Mon Sep 17 00:00:00 2001 From: silverback Date: Tue, 17 Feb 2026 22:09:14 +0000 Subject: [PATCH 3/6] Add CI workflow for typecheck and tests Runs tsc --noEmit and vitest on PRs and pushes to main, using Node 22 and pnpm via corepack with frozen lockfile. --- .github/workflows/ci.yml | 52 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bd16bf7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +jobs: + typecheck: + name: Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Enable corepack + run: corepack enable + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run typecheck + run: pnpm exec tsc --noEmit + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Enable corepack + run: corepack enable + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests + run: pnpm test From 33a9254250fdad0b64b706c4cfff2610078b5e5d Mon Sep 17 00:00:00 2001 From: silverback Date: Tue, 17 Feb 2026 22:12:03 +0000 Subject: [PATCH 4/6] Restore Go tooling in Dockerfile Go was incorrectly removed during silverback-auth work. It's still needed since the bot works on Go applications. --- Dockerfile | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 19e7322..b7455fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,11 +27,21 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && apt-get update && apt-get install -y --no-install-recommends gh \ && rm -rf /var/lib/apt/lists/* +# Install Go (supports both amd64 and arm64 architectures) +ARG GO_VERSION=1.24.13 +RUN ARCH=$(dpkg --print-architecture) && \ + curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${ARCH}.tar.gz" \ + | tar -C /usr/local -xzf - +ENV PATH="/usr/local/go/bin:/home/bot/go/bin:${PATH}" +ENV GOPATH="/home/bot/go" + # Install Claude Code CLI RUN npm install -g @anthropic-ai/claude-code # Create non-root user -RUN useradd -m -s /bin/bash bot +RUN useradd -m -s /bin/bash bot \ + && mkdir -p /home/bot/go \ + && chown bot:bot /home/bot/go # Install oh-my-claudecode plugin as bot user USER bot From 0e6f34def80fdbc005ea6089e3898155b00e047c Mon Sep 17 00:00:00 2001 From: silverback Date: Wed, 18 Feb 2026 05:51:50 +0000 Subject: [PATCH 5/6] Add MCP authentication proxy documentation Step-by-step guide covering configuration, token generation, tool pattern matching, runtime behavior, security considerations, and troubleshooting. --- docs/mcp-auth.md | 687 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 687 insertions(+) create mode 100644 docs/mcp-auth.md diff --git a/docs/mcp-auth.md b/docs/mcp-auth.md new file mode 100644 index 0000000..4a700e9 --- /dev/null +++ b/docs/mcp-auth.md @@ -0,0 +1,687 @@ +# MCP Authentication Proxy (silverback-auth) + +The silverback-auth MCP authentication layer is a JWT-based security proxy that sits between Claude Code CLI and any MCP (Model Context Protocol) server. It filters which tools a user can access using glob patterns defined in JWT tokens, providing granular tool-level access control. + +## Overview + +### What It Is + +- **JWT-based authentication proxy** that validates tokens using HMAC-SHA256 +- **Tool filtering** via glob patterns in the JWT token claims +- **One proxy per MCP server** — transparent to the LLM (tools remain discoverable) +- **JSON-RPC 2.0 protocol** using newline-delimited messages over stdin/stdout +- **Opt-in per repository** — only servers explicitly listed get wrapped + +### Architecture + +``` +Claude Code CLI → silverback-auth proxy → MCP Server + (stdin/stdout) (validates JWT, (actual tools) + filters tools) +``` + +The proxy: +1. Reads JSON-RPC messages line-by-line from the client (Claude Code CLI) +2. Validates the JWT token from the environment +3. Intercepts specific JSON-RPC methods to apply tool filtering: + - `tools/call` — blocks calls to unauthorized tools with error code `-32003` + - `tools/list` — filters the response to show only authorized tools +4. Forwards all other messages unmodified to the MCP server +5. Returns responses back to the client + +### Bypass Mode + +When `MCP_JWT_SECRET` environment variable is **not set**, the proxy runs in **bypass mode**: +- All JSON-RPC messages pass through unmodified +- No token validation occurs +- No tool filtering happens +- Useful for local development without authentication + +## Prerequisites + +### Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `MCP_JWT_SECRET` | Yes (for auth) | HMAC-SHA256 secret for JWT signing and verification. If not set, proxy runs in bypass mode. | +| `MCP_TOKEN` | Yes (when secret set) | The JWT token to validate against the secret. Required when `MCP_JWT_SECRET` is set. | +| `MCP_ENV` | No | Environment identifier passed from the token's `env` claim to the MCP server. | +| `MCP_DEFAULT_TOOLS` | No | Comma-separated default tool patterns for workspace tokens (default: `*`). | + +### Configuration Files + +The proxy uses two configuration patterns: + +1. **`.silverback-auth.json`** (committed to repo) — declares which MCP servers need auth wrapping +2. **`.mcp.json`** or **`.claude/settings.json`** (committed to repo) — standard MCP server definitions + +## Step 1: Declare Which MCP Servers Need Auth + +Create `.silverback-auth.json` in the repo root to specify which MCP servers should be wrapped with authentication: + +```json +{ + "servers": ["vendgogh", "analytics-db"] +} +``` + +**Key points:** +- Only servers listed here get wrapped with the auth proxy +- Servers not listed pass through unchanged +- If this file doesn't exist, no auth wrapping happens (opt-in per repo) +- The file is committed to version control + +In this example: +- `vendgogh` and `analytics-db` will be wrapped with authentication +- Any other MCP servers (e.g., `filesystem`) will pass through without auth + +## Step 2: Configure MCP Servers + +MCP servers are discovered from these files (later overrides earlier): + +1. **`.mcp.json`** (standard MCP config) +2. **`.claude/settings.json`** (Claude project settings) + +The proxy reads both files and merges them. It does NOT read `.claude/settings.local.json` (which is generated at runtime). + +### Example `.mcp.json` + +```json +{ + "mcpServers": { + "vendgogh": { + "command": "npx", + "args": ["-y", "@vendgogh/mcp-server"], + "env": { + "DATABASE_URL": "postgres://user:pass@localhost/db" + } + }, + "analytics-db": { + "command": "node", + "args": ["./mcp-servers/analytics.js"] + }, + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"] + } + } +} +``` + +In this example: +- `vendgogh` (if listed in `.silverback-auth.json`) will be wrapped with auth +- `analytics-db` (if listed in `.silverback-auth.json`) will be wrapped with auth +- `filesystem` will pass through unchanged (it's not in `.silverback-auth.json`) + +### Example `.claude/settings.json` + +```json +{ + "mcpServers": { + "vendgogh": { + "command": "npx", + "args": ["-y", "@vendgogh/mcp-server"] + } + } +} +``` + +## Step 3: Set Environment Variables + +Configure the proxy with required secrets and settings: + +```bash +export MCP_JWT_SECRET="your-hmac-secret-here" +export MCP_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +**Security note:** Never commit secrets to version control. Use your deployment platform's secret management (GitHub Secrets, AWS Secrets Manager, etc.). + +## Step 4: Runtime Behavior + +When a workspace is initialized with these configurations, the following happens automatically: + +1. **Configuration discovery**: The proxy loader reads `.silverback-auth.json` from the repo root +2. **Server discovery**: Merges MCP servers from `.mcp.json` and `.claude/settings.json` +3. **Config wrapping**: For each server listed in `.silverback-auth.json`: + - Creates a proxy config file at `.claude/mcp-auth/{server-name}.json` + - This file contains the original server's command, args, and env +4. **Server wrapping**: Replaces the server entry with the auth proxy: + - `command`: `node` + - `args`: `["/path/to/silverback-auth", "--config", ".claude/mcp-auth/{server-name}.json"]` + - `env`: Sets `MCP_JWT_SECRET` and `MCP_TOKEN` +5. **Settings output**: Writes the complete wrapped config to `.claude/settings.local.json` + +### Secrets Protection + +The proxy automatically **strips secrets from the child process environment**: + +```typescript +// These variables are NEVER passed to the MCP server +delete env['MCP_JWT_SECRET']; +delete env['MCP_TOKEN']; +``` + +This prevents credentials from leaking to untrusted MCP servers while still allowing the proxy to validate tokens. + +### File Structure + +``` +repo/ +├── .silverback-auth.json # Which servers to wrap (committed) +├── .mcp.json # MCP server definitions (committed) +├── .claude/ +│ ├── settings.json # Claude settings (committed) +│ ├── settings.local.json # Generated at runtime (gitignored) +│ └── mcp-auth/ # Generated proxy configs (gitignored) +│ ├── vendgogh.json +│ └── analytics-db.json +└── # Actual MCP servers run here +``` + +## Step 5: Generating Tokens + +### Via Slack Command + +Generate tokens using the `/sb-mcp-token` command: + +``` +/sb-mcp-token [ttl_hours] +``` + +**Parameters:** +- `env` — Target environment (e.g., `dev`, `prod`, `staging`) +- `tools` — Comma-separated tool patterns (e.g., `list_*,get_*`) +- `ttl_hours` — Token lifetime in hours (default: 4, max: 8) + +**Examples:** + +``` +/sb-mcp-token dev list_*,get_* 4 +``` +Read-only access to `list_*` and `get_*` tools for 4 hours. + +``` +/sb-mcp-token prod * 1 +``` +Full tool access for 1 hour in production (use sparingly). + +``` +/sb-mcp-token dev raw_sql_query,list_tables +``` +Specific tools with default 4-hour TTL. + +**Result:** +The command returns an ephemeral Slack message (visible only to you) with: +- Your Slack user ID (subject) +- Target environment +- Allowed tool patterns +- Expiration timestamp +- The JWT token itself + +### Programmatically + +Generate tokens in code: + +```typescript +import { generateToken } from './mcp-auth/token'; + +const token = generateToken( + process.env.MCP_JWT_SECRET!, // HMAC-SHA256 secret + 'U12345', // subject (who — usually Slack user ID) + 'dev', // environment + ['list_*', 'get_*'], // tool patterns + 4 * 3600 // TTL in seconds (4 hours) +); + +// token is a signed JWT string, e.g.: +// "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJVMTIzNDUiLCJlbnYiOiJkZXYiLCJ0b29scyI6WyJsaXN0XyoiLCJnZXRfKiJdLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MTcwMDAxNDQwMH0...." +``` + +### Token Requirements + +- **Max TTL**: 8 hours (28,800 seconds) — enforced at both generation and validation +- **Required claims**: `sub`, `env`, `tools`, `iat`, `exp` +- **Default-deny**: Tokens must explicitly list which tools are allowed. An empty `tools` array is rejected. +- **Algorithm**: HMAC-SHA256 (HS256) + +## Tool Pattern Matching + +Patterns in the JWT `tools` claim use simple glob matching: + +| Pattern | Matches | Example | +|---------|---------|---------| +| `*` | All tools | Everything | +| `list_*` | Prefix match | `list_tables`, `list_schemas`, `list_users` | +| `get_*` | Prefix match | `get_user`, `get_config`, `get_status` | +| `raw_sql_query` | Exact match only | Only `raw_sql_query` | +| `describe_*` | Prefix match | `describe_table`, `describe_schema` | + +**Combining patterns:** + +``` +["list_*", "get_*", "raw_sql_query"] +``` + +This allows: +- All tools starting with `list_` (e.g., `list_tables`, `list_schemas`) +- All tools starting with `get_` (e.g., `get_user`, `get_config`) +- The exact tool `raw_sql_query` + +**How matching works:** +1. The proxy receives a `tools/call` request for a tool (e.g., `describe_table`) +2. It checks if `describe_table` matches ANY pattern in the token's `tools` array +3. If no pattern matches, the request is blocked with error code `-32003` + +## JWT Token Structure + +Tokens are standard JWT (JSON Web Tokens) with the following structure: + +**Header:** +```json +{ + "alg": "HS256", + "typ": "JWT" +} +``` + +**Payload:** +```json +{ + "sub": "U12345", + "env": "dev", + "tools": ["list_*", "get_*"], + "iat": 1700000000, + "exp": 1700014400 +} +``` + +**Fields:** +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `sub` | string | Yes | Subject (usually Slack user ID) | +| `env` | string | Yes | Target environment (e.g., `dev`, `prod`) | +| `tools` | string[] | Yes | Array of tool patterns (glob format) | +| `iat` | number | Yes | Issued at (Unix timestamp) | +| `exp` | number | Yes | Expires at (Unix timestamp) | + +**Signature:** +``` +HMAC-SHA256(base64(header) + "." + base64(payload), MCP_JWT_SECRET) +``` + +The complete token is: `header.payload.signature` (3 parts separated by dots). + +## Error Handling + +### Error Codes + +When the proxy rejects a request, it returns a JSON-RPC error response: + +| Code | Meaning | When | +|------|---------|------| +| `-32003` | Insufficient scope | `tools/call` for a tool not in the token's `tools` patterns | +| `-32700` | Parse error | Malformed JSON-RPC message from client | + +### Example: Blocked Tool Call + +**Request:** +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "raw_sql_query", + "arguments": {"sql": "DELETE FROM users WHERE id = 1"} + } +} +``` + +**Response (if `raw_sql_query` is not in the token):** +```json +{ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32003, + "message": "tool not permitted: raw_sql_query" + } +} +``` + +### Example: Hidden Tool in List Response + +When the client calls `tools/list`: + +**Original response from MCP server:** +```json +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "tools": [ + {"name": "list_tables", "description": "List tables"}, + {"name": "list_schemas", "description": "List schemas"}, + {"name": "raw_sql_query", "description": "Execute SQL"} + ] + } +} +``` + +**Filtered response (if only `list_*` is allowed):** +```json +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "tools": [ + {"name": "list_tables", "description": "List tables"}, + {"name": "list_schemas", "description": "List schemas"} + ] + } +} +``` + +The `raw_sql_query` tool is hidden from the LLM entirely — it won't appear in the tool list. + +## Example: Restricting a Database Server + +### Goal +Allow a user to list tables and schemas, but prevent them from running arbitrary SQL queries. + +### Step 1: Register the server for auth (`.silverback-auth.json`) + +```json +{ + "servers": ["database"] +} +``` + +### Step 2: Define the server (`.mcp.json`) + +```json +{ + "mcpServers": { + "database": { + "command": "node", + "args": ["./mcp-servers/database.js"], + "env": { + "DATABASE_URL": "postgres://..." + } + } + } +} +``` + +### Step 3: Set environment variables + +```bash +export MCP_JWT_SECRET="secret-key-here" +``` + +### Step 4: Generate a restricted token + +``` +/sb-mcp-token dev list_tables,list_schemas,describe_table 4 +``` + +### Result + +The user can now: +- Call `list_tables` — returns table list +- Call `list_schemas` — returns schema list +- Call `describe_table` — returns table details + +But calling `raw_sql_query` returns: + +```json +{ + "jsonrpc": "2.0", + "id": 123, + "error": { + "code": -32003, + "message": "tool not permitted: raw_sql_query" + } +} +``` + +And `tools/list` response only includes the 3 allowed tools — `raw_sql_query` is completely hidden from the LLM. + +## Security Considerations + +### Secret Management + +- **Never commit `MCP_JWT_SECRET` to version control** +- Use your deployment platform's secret management: + - GitHub Secrets (for Actions) + - AWS Secrets Manager (for Lambda) + - HashiCorp Vault (for self-hosted) + - Environment variable injection at deployment time + +### Default-Deny Policy + +Tokens must explicitly list which tools are allowed: +- An empty `tools` array is rejected (validation fails) +- A token without the `tools` claim is rejected +- If a token doesn't list a tool, that tool is blocked + +### Secrets Never Leak + +The proxy automatically removes authentication secrets from child process environments: +- `MCP_JWT_SECRET` is stripped before spawning the MCP server +- `MCP_TOKEN` is stripped before spawning the MCP server +- This prevents untrusted MCP servers from accessing authentication tokens + +### Short-Lived Tokens + +- **Max TTL: 8 hours** — enforced at both generation and validation +- **Default TTL: 4 hours** (via `/sb-mcp-token`) +- Tokens are typically generated on-demand via Slack commands, not stored long-term +- Expired tokens are rejected with a clear error message + +### Ephemeral Token Delivery + +- Slack command results are posted as ephemeral messages +- Only the requesting user can see the token message +- Tokens are not logged or stored in shared channels +- Users should treat tokens like passwords — keep them private + +### Credential Management for MCP Servers + +The proxy does NOT manage credentials for MCP servers themselves. This remains the MCP server's responsibility: + +- Database credentials → Vault, environment variables, or IAM roles +- API keys → Secrets management system +- SSH keys → SSH agent or key files +- OAuth tokens → Refresh token flow + +The proxy only controls **which MCP tools are accessible**, not how those tools authenticate to external systems. + +## Proxy Lifecycle + +### Startup + +``` +1. Proxy reads --config flag and loads server config from JSON file +2. Checks if MCP_JWT_SECRET is set +3. If set: validates MCP_TOKEN, extracts tool patterns and environment +4. If not set: enters bypass mode (transparent pass-through) +5. Spawns child MCP server process +6. Removes MCP_JWT_SECRET and MCP_TOKEN from child environment +7. Starts bidirectional JSON-RPC forwarding +``` + +### Request Flow (Authenticated) + +``` +Client Proxy MCP Server + │ │ │ + ├─ tools/call request ──→│ │ + │ ├─ Check tool pattern │ + │ │ allowed? │ + │ │ (YES) │ + │ ├─ Forward to server ────→│ + │ │ ├─ Process request + │ │←─ Response ────────────│ + │←─ Response ───────────│ │ + │ │ │ + ├─ tools/call request ──→│ │ + │ (unauthorized) ├─ Check tool pattern │ + │ │ allowed? │ + │ │ (NO) │ + │←─ Error -32003 ───────┤ │ + │ (not forwarded) │ │ +``` + +### Shutdown + +``` +1. Client closes connection +2. Proxy signals EOF to child process +3. Proxy sends SIGTERM to child +4. Proxy waits up to 5 seconds for clean exit +5. If child doesn't exit, proxy sends SIGKILL +6. Proxy exits with child's exit code +``` + +## Logging + +The proxy logs important events to stderr using structured logging: + +``` +[silverback-auth] info: bypass mode: no authentication +[silverback-auth:proxy] info: denied tools/call: raw_sql_query +[silverback-auth:child] info: spawning child: node ./mcp-servers/database.js +[silverback-auth:child] info: child exited with code 0 +``` + +**Log levels:** +- `error` — Fatal errors (invalid config, token validation failure, process errors) +- `warn` — Non-fatal issues (wildcard tool access, malformed messages) +- `info` — Normal operations (mode, token validation, tool denials) + +## Testing and Verification + +### Test Token Generation + +```typescript +import { generateToken, validateToken } from './mcp-auth/token'; + +const secret = 'test-secret'; +const token = generateToken(secret, 'U123', 'dev', ['list_*'], 3600); +const claims = validateToken(token, secret); + +console.log(claims); +// { +// sub: 'U123', +// env: 'dev', +// tools: ['list_*'], +// iat: 1700000000, +// exp: 1700003600 +// } +``` + +### Test Tool Matching + +```typescript +import { isToolAllowed, matchToolPattern } from './mcp-auth/token'; + +// Pattern matching +matchToolPattern('list_*', 'list_tables'); // true +matchToolPattern('list_*', 'get_config'); // false +matchToolPattern('*', 'anything'); // true +matchToolPattern('exact_name', 'exact_name'); // true + +// Token-based matching +isToolAllowed('list_tables', ['list_*', 'get_*']); // true +isToolAllowed('raw_sql_query', ['list_*', 'get_*']); // false +isToolAllowed('raw_sql_query', ['*']); // true +``` + +### Test Proxy Behavior + +1. **Start with auth disabled** (bypass mode): + ```bash + node ./dist/mcp-auth/index.js --config test-config.json + ``` + All JSON-RPC messages pass through. + +2. **Set token and secret**: + ```bash + export MCP_JWT_SECRET="test-secret" + export MCP_TOKEN="" + node ./dist/mcp-auth/index.js --config test-config.json + ``` + Only authorized tools work. + +3. **Send invalid token**: + Proxy exits with error: `token validation failed: ...` + +4. **Send expired token**: + Proxy exits with error: `token validation failed: token expired` + +## Troubleshooting + +### "MCP_JWT_SECRET is set but MCP_TOKEN is missing" + +**Cause:** `MCP_JWT_SECRET` is set in the environment, but `MCP_TOKEN` is not. + +**Fix:** Generate and set a valid token: +```bash +export MCP_TOKEN="" +``` + +Or, to disable auth, unset the secret: +```bash +unset MCP_JWT_SECRET +``` + +### "token validation failed: token invalid" + +**Cause:** Token is malformed, signed with wrong secret, or corrupted. + +**Fix:** +1. Verify the token string is complete and uncorrupted +2. Verify `MCP_JWT_SECRET` matches the secret used to generate the token +3. Generate a new token with the correct secret + +### "token validation failed: token expired" + +**Cause:** Token's `exp` timestamp is in the past. + +**Fix:** Generate a new token with a future expiration time. + +### "tool not permitted: raw_sql_query" + +**Cause:** Token doesn't include `raw_sql_query` in the `tools` patterns. + +**This is expected behavior.** The proxy is correctly blocking an unauthorized tool. + +**Fix:** Generate a new token that includes the tool pattern: +``` +/sb-mcp-token dev raw_sql_query,list_* 4 +``` + +### MCP server child process exits immediately + +**Cause:** Config file path is wrong, or child command doesn't exist. + +**Fix:** +1. Check that `.claude/mcp-auth/{server-name}.json` exists +2. Check that the `command` and `args` in the config point to a valid MCP server +3. Test the child command manually: `node ./mcp-servers/database.js` + +### Proxy hangs on shutdown + +**Cause:** Child MCP server ignores SIGTERM and doesn't exit gracefully. + +**Fix:** Proxy has a 5-second timeout and will send SIGKILL. If the server still doesn't exit: +1. Ensure the MCP server handles SIGTERM properly +2. Add a timeout in the server's signal handler + +## References + +- **JWT Specification:** [RFC 7519](https://tools.ietf.org/html/rfc7519) +- **JSON-RPC 2.0:** [JSON-RPC 2.0 Specification](https://www.jsonrpc.org/specification) +- **Model Context Protocol:** [Model Context Protocol Documentation](https://modelcontextprotocol.io/) From 340c80b31bb001a00bd3be4345c3335580fdb3a0 Mon Sep 17 00:00:00 2001 From: silverback Date: Wed, 18 Feb 2026 14:32:09 +0000 Subject: [PATCH 6/6] Remove vendgogh references from MCP auth docs Replace with generic example names since this project is unrelated to vendgogh. --- docs/mcp-auth.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/mcp-auth.md b/docs/mcp-auth.md index 4a700e9..d21eda9 100644 --- a/docs/mcp-auth.md +++ b/docs/mcp-auth.md @@ -61,7 +61,7 @@ Create `.silverback-auth.json` in the repo root to specify which MCP servers sho ```json { - "servers": ["vendgogh", "analytics-db"] + "servers": ["my-database", "analytics-db"] } ``` @@ -72,7 +72,7 @@ Create `.silverback-auth.json` in the repo root to specify which MCP servers sho - The file is committed to version control In this example: -- `vendgogh` and `analytics-db` will be wrapped with authentication +- `my-database` and `analytics-db` will be wrapped with authentication - Any other MCP servers (e.g., `filesystem`) will pass through without auth ## Step 2: Configure MCP Servers @@ -89,9 +89,9 @@ The proxy reads both files and merges them. It does NOT read `.claude/settings.l ```json { "mcpServers": { - "vendgogh": { + "my-database": { "command": "npx", - "args": ["-y", "@vendgogh/mcp-server"], + "args": ["-y", "@example/mcp-db-server"], "env": { "DATABASE_URL": "postgres://user:pass@localhost/db" } @@ -109,7 +109,7 @@ The proxy reads both files and merges them. It does NOT read `.claude/settings.l ``` In this example: -- `vendgogh` (if listed in `.silverback-auth.json`) will be wrapped with auth +- `my-database` (if listed in `.silverback-auth.json`) will be wrapped with auth - `analytics-db` (if listed in `.silverback-auth.json`) will be wrapped with auth - `filesystem` will pass through unchanged (it's not in `.silverback-auth.json`) @@ -118,9 +118,9 @@ In this example: ```json { "mcpServers": { - "vendgogh": { + "my-database": { "command": "npx", - "args": ["-y", "@vendgogh/mcp-server"] + "args": ["-y", "@example/mcp-db-server"] } } } @@ -174,7 +174,7 @@ repo/ │ ├── settings.json # Claude settings (committed) │ ├── settings.local.json # Generated at runtime (gitignored) │ └── mcp-auth/ # Generated proxy configs (gitignored) -│ ├── vendgogh.json +│ ├── my-database.json │ └── analytics-db.json └── # Actual MCP servers run here ```