diff --git a/docs/mcp.md b/docs/mcp.md index 523140c..c85c923 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6,31 +6,37 @@ It is the same product underneath: the tools wrap the same planner, the same orc ## Transports -### stdio (Claude Code / local dev) +### stdio (trusted local clients) -```bash -claude mcp add convoy -- npm run --silent mcp --prefix /path/to/convoy -``` +Use `npm run --silent mcp --prefix /path/to/convoy` as the server command in any client supporting MCP stdio. Local stdio retains the full toolset and the permissions of its host process. The external client can be Codex, Claude Code, Cursor, or another compatible agent; Convoy's optional built-in reasoning still uses Anthropic and falls back to deterministic output without a key. -Claude Code spawns the server as a subprocess. `ANTHROPIC_API_KEY` is read from Convoy's gitignored `.env`; without it the planner and medic fall back to deterministic output. +### Streamable HTTP (one trusted workspace) -### Streamable HTTP (CI runners / hosted) +Set independent bearer credentials in the server environment or secret manager: -```bash -npm run mcp-http # listens on :3738 -CONVOY_MCP_PORT=3739 npm run mcp-http # custom port -``` +| Variable | Permission | +| --- | --- | +| `CONVOY_MCP_READ_TOKEN` | List plans/runs, status, diagnosis, rollback preview | +| `CONVOY_MCP_EXECUTE_TOKEN` | Read tools plus plan, apply, orient | +| `CONVOY_MCP_APPROVAL_TOKEN` | Read tools plus approve/reject gates | -Stateless — no session IDs, no in-memory state between requests. All durable state is in `.convoy/state.db`, so CI workers and hosted agents can interleave calls safely. +Configure at least one. Each credential must have at least 32 non-whitespace characters, with a different random value for each role. `CONVOY_MCP_TOKEN` is a compatibility alias for the execution credential, not an approval credential. Missing or invalid configuration refuses startup. Rotate a credential in the secret source and restart the service to revoke the previous value. -Connect a client: -```bash -claude mcp add convoy-http --transport sse http://localhost:3738/mcp -``` +Start with `npm run mcp-http`. The default address is `127.0.0.1:3738`; `CONVOY_MCP_HOST` and `CONVOY_MCP_PORT` override it. A remote installation must supply HTTPS and network access controls through its deployment environment. + +Configure compatible clients as follows: -Health check: `GET http://localhost:3738/health` → `{ ok: true, transport: "http", port: 3738 }`. +| Setting | Value | +| --- | --- | +| Transport | Streamable HTTP (not legacy SSE) | +| URL | `http://127.0.0.1:3738/mcp`, or the HTTPS address you configured | +| Header | `Authorization: Bearer ` | -Both transports call the same `registerConvoyTools` function in `src/mcp/server.ts` — tool behaviour is identical. +The endpoint authenticates every request. Each stateless POST gets a separate MCP server and transport, so repeated and concurrent requests do not reuse transport state or another caller's role. `GET /health` is a minimal public health check. Requests with an Origin header are rejected unless the exact origin appears in the comma-separated `CONVOY_MCP_ALLOWED_ORIGINS` setting. Request bodies are limited to 1 MiB. + +Execution credentials cannot call `convoy_approve` or use `autoApprove`. Keep the approval credential with the operator or a separately controlled approval service. Raw `realVpsGhcr.ghcrToken` values are rejected over HTTP; use `ghcrTokenEnv` instead. HTTP also disables local-process rehearsal, direct rollback execution, onboarding, bootstrap, and connection setup. Direct rollback needs a run-bound approval before remote exposure; rehearsal needs an isolated worker. Use trusted local CLI/stdio for these workflows until those boundaries exist. + +This transport serves a **single trusted workspace**. Tokens do not yet isolate projects, filesystem paths, VPS targets, or tenants. Child processes receive a filtered environment without MCP bearer credentials, but they are not an OS isolation boundary. Do not expose this as a multi-customer execution service. External-reasoning injection and two-client live acceptance remain pilot gates. ## Tool reference @@ -48,15 +54,15 @@ All tools return JSON in a text content block; failures return `isError: true` w ## Example agent flow -A typical session — the agent drives the whole loop: +A typical session — the execution client requests work and a separate operator approves: 1. **Plan** — `convoy_plan { repoPath: "./demo-app" }` → returns `planId`, `platform: "fly"`, `deployable: true`. 2. **Apply** — `convoy_apply { planId }` → returns `runId` and `watchUrl`; the pipeline runs in the background. 3. **Watch** — poll `convoy_status { runId }` until `status` is `awaiting_approval`. The response includes `pendingApprovals: [{ id, kind: "open_pr", ... }]`. -4. **Approve** — `convoy_approve { runId, approvalId, decision: "approved" }` → the pipeline resumes through canary → promote → observe. +4. **Approve** — the approval client calls `convoy_approve { runId, approvalId, decision: "approved" }` → the pipeline resumes through canary → promote → observe. 5. **Diagnose** (on failure) — if `status` lands on `failed` or `awaiting_fix`, `convoy_diagnose { runId }` returns the medic's root cause and suggested fix; the agent can apply the code fix and re-apply the plan. -To make stages real instead of scripted, opt in per stage: +For trusted local stdio, opt in per real stage. HTTP rejects `realRehearsal` until an isolated worker is configured: ```json { "planId": "…", "realRehearsal": true, "realAuthor": true, "realFly": true } @@ -74,7 +80,7 @@ To make stages real instead of scripted, opt in per stage: "appName": "my-app", "imageRef": "ghcr.io/myorg/my-app", "ghcrUsername": "myorg-bot", - "ghcrToken": "ghp_...", + "ghcrTokenEnv": "GHCR_TOKEN", "runMigrations": true, "manageCaddy": true, "domain": "my-app.example.com", @@ -91,3 +97,5 @@ When `manageCaddy: true`, Convoy also: 3. Validates and reloads Caddy Prerequisites: `docker` and `ssh` installed locally; Docker logged in to GHCR (handled automatically); SSH access to the VPS; Caddy installed on the VPS (if `manageCaddy: true`). + +Set the referenced GHCR credential on the server before applying. Allowed references are `GHCR_TOKEN`, `GH_TOKEN`, and `GITHUB_TOKEN`. New MCP-generated configuration files persist only the reference; the CLI resolves it from its execution environment. Local callers using a raw token remain compatible, but their newly written MCP configuration also omits the secret. Existing configuration files from earlier versions are not automatically cleaned up. diff --git a/src/cli.ts b/src/cli.ts index bb272f7..a3821ae 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { resolveGhcrConfig } from './core/ghcr-config.js'; import { Command } from 'commander'; import pc from 'picocolors'; @@ -2301,7 +2302,7 @@ async function runApply(planId: string, opts: ApplyOpts): Promise { if (opts.realVpsGhcrConfig) { try { const { readFileSync } = await import('node:fs'); - ghcrOpts = JSON.parse(readFileSync(opts.realVpsGhcrConfig, 'utf8')) as RealVpsGhcrOpt; + ghcrOpts = resolveGhcrConfig(JSON.parse(readFileSync(opts.realVpsGhcrConfig, 'utf8'))); } catch (err) { console.error(pc.red(`--real-vps-ghcr-config: failed to read ${opts.realVpsGhcrConfig}: ${err instanceof Error ? err.message : String(err)}`)); process.exit(2); diff --git a/src/core/ghcr-config.test.ts b/src/core/ghcr-config.test.ts new file mode 100644 index 0000000..e724f48 --- /dev/null +++ b/src/core/ghcr-config.test.ts @@ -0,0 +1,18 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { executionEnvironment, prepareGhcrConfig, resolveGhcrConfig } from './ghcr-config.js'; +import { authorizeTool } from '../mcp/access.js'; + +const input = { host: 'deploy@example.test', cwd: '/work', deployRoot: '/opt/app', appName: 'app', imageRef: 'ghcr.io/test/app', ghcrUsername: 'test' }; +test('GHCR config persists only a reference and resolves it in the child', () => { + const prepared = prepareGhcrConfig({ ...input, ghcrTokenEnv: 'GH_TOKEN' }, { GH_TOKEN: 'example-secret' }); + assert(!JSON.stringify(prepared.config).includes('example-secret')); + assert.equal(resolveGhcrConfig(prepared.config, prepared.env).ghcrToken, 'example-secret'); + assert.equal(prepareGhcrConfig({ ...input, ghcrToken: 'local-token' }, {}).env.GHCR_TOKEN, 'local-token'); + assert.throws(() => resolveGhcrConfig(input, {}), /Missing GHCR/); + assert.throws(() => resolveGhcrConfig({ ...input, ghcrTokenEnv: 'CONVOY_MCP_APPROVAL_TOKEN' }, {}), /ghcrTokenEnv/); + assert.throws(() => authorizeTool({ role: 'execute' }, 'convoy_apply', { realVpsGhcr: { ...input, ghcrToken: 'raw' } }), /ghcrTokenEnv/); +}); +test('execution children do not inherit MCP bearer credentials', () => { + assert.deepEqual(executionEnvironment({ PATH: '/bin', GHCR_TOKEN: 'ghcr', CONVOY_MCP_TOKEN: 'legacy', CONVOY_MCP_READ_TOKEN: 'read', CONVOY_MCP_EXECUTE_TOKEN: 'execute', CONVOY_MCP_APPROVAL_TOKEN: 'approval' }), { PATH: '/bin', GHCR_TOKEN: 'ghcr' }); +}); diff --git a/src/core/ghcr-config.ts b/src/core/ghcr-config.ts new file mode 100644 index 0000000..0471312 --- /dev/null +++ b/src/core/ghcr-config.ts @@ -0,0 +1,24 @@ +import type { RealVpsGhcrOpt } from './stages.js'; + +export type GhcrConfigInput = Omit & { ghcrToken?: string; ghcrTokenEnv?: string }; +const ALLOWED_TOKEN_REFS = new Set(['GHCR_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN']); + +export function resolveGhcrConfig(input: GhcrConfigInput, env: NodeJS.ProcessEnv = process.env): RealVpsGhcrOpt { + const ref = input.ghcrTokenEnv ?? 'GHCR_TOKEN'; + if (!ALLOWED_TOKEN_REFS.has(ref)) throw new Error('ghcrTokenEnv must be GHCR_TOKEN, GH_TOKEN, or GITHUB_TOKEN'); + const token = input.ghcrToken ?? env[ref]; + if (!token?.trim()) throw new Error('Missing GHCR credential in ' + ref); + const { ghcrTokenEnv: _ref, ...config } = input; + return { ...config, ghcrToken: token }; +} + +/** Persist only the reference. Raw input is retained in child memory for local compatibility. */ +export function prepareGhcrConfig(input: GhcrConfigInput, env: NodeJS.ProcessEnv = process.env) { + const resolved = resolveGhcrConfig(input, env); + const { ghcrToken: token, ...config } = resolved; + return { config: { ...config, ghcrTokenEnv: 'GHCR_TOKEN' }, env: { GHCR_TOKEN: token } }; +} + +export function executionEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return Object.fromEntries(Object.entries(env).filter(([name]) => !/^CONVOY_MCP_.*TOKEN$/.test(name))); +} diff --git a/src/mcp/access.ts b/src/mcp/access.ts new file mode 100644 index 0000000..40d709d --- /dev/null +++ b/src/mcp/access.ts @@ -0,0 +1,48 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; + +export type McpRole = 'read' | 'execute' | 'approve' | 'local'; +export interface McpAccess { role: McpRole } +const READ_TOOLS = new Set(['convoy_list_plans', 'convoy_status', 'convoy_diagnose', 'convoy_list_runs', 'convoy_rollback_preview']); +const EXECUTE_TOOLS = new Set(['convoy_plan', 'convoy_apply', 'convoy_orient']); + +export function canCallTool(access: McpAccess, name: string): boolean { + if (access.role === 'local') return true; + if (READ_TOOLS.has(name)) return true; + if (access.role === 'approve') return name === 'convoy_approve'; + return access.role === 'execute' && EXECUTE_TOOLS.has(name); +} + +export function authorizeTool(access: McpAccess, name: string, args: unknown): void { + if (!canCallTool(access, name)) throw new Error('This credential is not permitted to call ' + name); + const input = args as Record | undefined; + if (access.role !== 'local' && name === 'convoy_apply') { + if (input?.['autoApprove'] === true) throw new Error('Remote execution cannot auto-approve gates. Use a separate approval credential.'); + if (input?.['realRehearsal'] === true) throw new Error('Remote local-process rehearsal is disabled until an isolated worker is configured. Use the trusted local CLI for local rehearsal.'); + if ((input?.['realVpsGhcr'] as Record | undefined)?.['ghcrToken']) { + throw new Error('Use ghcrTokenEnv to reference a server-side credential; do not send a raw GHCR token.'); + } + } +} + +export interface HttpCredential { role: Exclude; token: string } + +export function loadHttpCredentials(env: NodeJS.ProcessEnv = process.env): HttpCredential[] { + const entries: [Exclude, string | undefined][] = [ + ['read', env['CONVOY_MCP_READ_TOKEN']], + ['execute', env['CONVOY_MCP_EXECUTE_TOKEN'] ?? env['CONVOY_MCP_TOKEN']], + ['approve', env['CONVOY_MCP_APPROVAL_TOKEN']], + ]; + const credentials = entries.filter((entry): entry is [Exclude, string] => Boolean(entry[1])) + .map(([role, token]) => ({ role, token })); + if (!credentials.length) throw new Error('Configure CONVOY_MCP_READ_TOKEN, CONVOY_MCP_EXECUTE_TOKEN, or CONVOY_MCP_APPROVAL_TOKEN before starting HTTP.'); + if (credentials.some((c) => c.token.length < 32 || /\s/.test(c.token))) throw new Error('MCP credentials must contain at least 32 non-whitespace characters.'); + if (new Set(credentials.map((c) => c.token)).size !== credentials.length) throw new Error('Use different MCP credentials for each role.'); + return credentials; +} + +export function authenticateHttp(header: string | undefined, credentials: HttpCredential[]): McpAccess | null { + if (!header?.startsWith('Bearer ')) return null; + const supplied = createHash('sha256').update(header.slice(7)).digest(); + const matched = credentials.find((c) => timingSafeEqual(supplied, createHash('sha256').update(c.token).digest())); + return matched ? { role: matched.role } : null; +} diff --git a/src/mcp/http.test.ts b/src/mcp/http.test.ts new file mode 100644 index 0000000..7e5e7ec --- /dev/null +++ b/src/mcp/http.test.ts @@ -0,0 +1,62 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import type { AddressInfo } from 'node:net'; +import { createConvoyHttpServer } from './http.js'; +import { loadHttpCredentials } from './access.js'; + +const readToken = 'r'.repeat(40); +const executeToken = 'e'.repeat(40); +const approveToken = 'a'.repeat(40); + +test('HTTP refuses missing, short, and shared role credentials', () => { + assert.throws(() => createConvoyHttpServer({}), /Configure/); + assert.throws(() => loadHttpCredentials({ CONVOY_MCP_TOKEN: 'short' }), /32/); + assert.throws(() => loadHttpCredentials({ CONVOY_MCP_READ_TOKEN: readToken, CONVOY_MCP_APPROVAL_TOKEN: readToken }), /different/); +}); + +test('HTTP authenticates every request, isolates role toolsets, and handles repeated concurrent calls', async () => { + const env = { CONVOY_MCP_READ_TOKEN: readToken, CONVOY_MCP_EXECUTE_TOKEN: executeToken, CONVOY_MCP_APPROVAL_TOKEN: approveToken }; + const server = createConvoyHttpServer(env); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/mcp`; + const headers = (token?: string) => ({ 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', ...(token ? { Authorization: `Bearer ${token}` } : {}) }); + const rpc = (token: string, method = 'tools/list', params?: unknown) => fetch(url, { method: 'POST', headers: headers(token), body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) }); + try { + assert.equal((await fetch(url, { method: 'POST', headers: headers(), body: '{}' })).status, 401); + assert.equal((await rpc('invalid')).status, 401); + assert.equal((await fetch(url, { method: 'POST', headers: { ...headers(readToken), Origin: 'https://untrusted.example' }, body: '{}' })).status, 403); + assert.equal((await fetch(url, { headers: headers(readToken) })).status, 405); + assert.equal((await fetch(url, { method: 'POST', headers: headers(readToken), body: '{' })).status, 400); + assert.equal((await fetch(url, { method: 'POST', headers: headers(readToken), body: 'x'.repeat(1_048_577) })).status, 413); + const lists = await Promise.all([readToken, executeToken, approveToken, readToken, executeToken].map(async (token) => { + const response = await rpc(token); + assert.equal(response.status, 200); + const data = await response.json() as { result: { tools: { name: string }[] } }; + return data.result.tools.map((tool) => tool.name); + })); + assert(lists[0]!.includes('convoy_status')); + assert(!lists[0]!.includes('convoy_apply')); + assert(lists[1]!.includes('convoy_apply')); + assert(!lists[1]!.includes('convoy_approve')); + assert(!lists[1]!.includes('convoy_rollback_apply')); + assert(lists[2]!.includes('convoy_approve')); + assert(!lists[2]!.includes('convoy_apply')); + for (const token of [readToken, executeToken]) { + const denied = await (await rpc(token, 'tools/call', { name: 'convoy_approve', arguments: { runId: 'fake', decision: 'approve' } })).json() as { result?: { isError?: boolean }; error?: unknown }; + assert(denied.error || denied.result?.isError); + } + for (const [args, message] of [ + [{ autoApprove: true }, /auto-approve/], + [{ realRehearsal: true }, /isolated worker/], + ] as const) { + const denied = await (await rpc(executeToken, 'tools/call', { name: 'convoy_apply', arguments: { planId: 'fake', ...args } })).json() as { result: { isError: boolean; content: { text: string }[] } }; + assert.equal(denied.result.isError, true); + assert.match(denied.result.content[0]!.text, message); + } + env.CONVOY_MCP_READ_TOKEN = 'n'.repeat(40); + assert.equal((await rpc(readToken)).status, 401); + } finally { + server.closeAllConnections(); + await new Promise((resolve, reject) => server.close((err) => err ? reject(err) : resolve())); + } +}); diff --git a/src/mcp/http.ts b/src/mcp/http.ts index b1b59b9..6fb995e 100644 --- a/src/mcp/http.ts +++ b/src/mcp/http.ts @@ -1,92 +1,93 @@ #!/usr/bin/env node -/** - * Convoy MCP server — Streamable HTTP transport. - * - * Use this for CI runners, hosted Convoy instances, and Docker deployments - * where a persistent HTTP endpoint is better than spawning a subprocess per - * session. The stdio transport (src/mcp/index.ts) stays the recommended path - * for Claude Code local development. - * - * Runs stateless: no session IDs, no in-memory state between requests. All - * durable state lives in .convoy/state.db, so multiple HTTP clients can - * interleave tool calls safely. - * - * Usage: - * npm run mcp-http - * CONVOY_MCP_PORT=3739 npm run mcp-http - * - * Connect a client: - * claude mcp add convoy-http --transport sse http://localhost:3738/mcp - */ +/** Streamable HTTP for a single trusted Convoy workspace. See docs/mcp.md. */ import { createServer, type IncomingMessage } from 'node:http'; - +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; - +import { authenticateHttp, loadHttpCredentials } from './access.js'; import { createConvoyServer } from './server.js'; -const PORT = parseInt(process.env['CONVOY_MCP_PORT'] ?? '3738', 10); +class RequestError extends Error { + constructor(public status: number, message: string) { super(message); } +} async function readBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - req.on('data', (chunk: Buffer) => chunks.push(chunk)); - req.on('end', () => { - const body = Buffer.concat(chunks).toString('utf8').trim(); - if (!body) { resolve(undefined); return; } - try { resolve(JSON.parse(body)); } - catch { resolve(undefined); } - }); - req.on('error', reject); - }); + const chunks: Buffer[] = []; + let length = 0; + for await (const chunk of req) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + length += bytes.length; + if (length > 1_048_576) throw new RequestError(413, 'Request exceeds 1 MiB'); + chunks.push(bytes); + } + try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } + catch { throw new RequestError(400, 'Invalid JSON body'); } } -async function main(): Promise { - // Stateless: sessionIdGenerator undefined means no Mcp-Session-Id header is - // sent and no per-session state is buffered. Every POST is an independent - // JSON-RPC round-trip — correct for short-lived CI callers. - const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); - const mcpServer = createConvoyServer(); - await mcpServer.connect(transport); - - const httpServer = createServer(async (req, res) => { - const url = req.url ?? ''; - if (url === '/mcp' || url.startsWith('/mcp?')) { - try { - const body = req.method === 'POST' ? await readBody(req) : undefined; - await transport.handleRequest(req, res, body); - } catch (err) { - if (!res.headersSent) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) })); - } - } - return; - } - if (url === '/health') { +export function createConvoyHttpServer(env: NodeJS.ProcessEnv = process.env) { + // Refuse startup with an exposed, unauthenticated MCP endpoint. + loadHttpCredentials(env); + const allowedOrigins = new Set((env['CONVOY_MCP_ALLOWED_ORIGINS'] ?? '').split(',').map((s) => s.trim()).filter(Boolean)); + return createServer(async (req, res) => { + const path = (req.url ?? '').split('?')[0]; + if (path === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true, transport: 'http', port: PORT, version: '0.0.1' })); + res.end(JSON.stringify({ ok: true, transport: 'http', version: '0.0.1' })); return; } - res.writeHead(404); - res.end(); - }); - - httpServer.listen(PORT, () => { - console.error(`convoy mcp server (http) ready`); - console.error(` MCP endpoint : http://0.0.0.0:${PORT}/mcp`); - console.error(` Health check : http://0.0.0.0:${PORT}/health`); + if (path !== '/mcp') { res.writeHead(404); res.end(); return; } + const origin = req.headers.origin; + if (origin && !allowedOrigins.has(origin)) { + res.writeHead(403); res.end('Origin is not allowed'); return; + } + let access; + try { access = authenticateHttp(req.headers.authorization, loadHttpCredentials(env)); } + catch { res.writeHead(503); res.end('MCP authentication is not configured'); return; } + if (!access) { + res.writeHead(401, { 'WWW-Authenticate': 'Bearer realm="convoy"', 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Valid bearer credential required' })); return; + } + if (req.method !== 'POST') { + res.writeHead(405, { Allow: 'POST' }); res.end(); return; + } + if (Number(req.headers['content-length'] ?? 0) > 1_048_576) { + res.writeHead(413); res.end('Request exceeds 1 MiB'); return; + } + // A stateless transport handles ONE request. Sharing it breaks subsequent + // and concurrent calls and can mix the principals of different requests. + let server: ReturnType | undefined; + try { + const body = await readBody(req); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + server = createConvoyServer(access); + const close = () => { void server?.close().catch(() => undefined); }; + res.once('close', close); + await server.connect(transport); + await transport.handleRequest(req, res, body); + } catch (err) { + if (!res.headersSent && !res.destroyed) { + res.writeHead(err instanceof RequestError ? err.status : 500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err instanceof RequestError ? err.message : 'MCP request failed' })); + } + await server?.close().catch(() => undefined); + } }); +} - for (const sig of ['SIGINT', 'SIGTERM'] as NodeJS.Signals[]) { - process.on(sig, () => { - httpServer.close(() => { - transport.close().catch(() => undefined).finally(() => process.exit(0)); - }); - }); +function main() { + const port = Number(process.env['CONVOY_MCP_PORT'] ?? 3738); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid CONVOY_MCP_PORT'); + const host = process.env['CONVOY_MCP_HOST'] ?? '127.0.0.1'; + const server = createConvoyHttpServer(); + server.requestTimeout = 30_000; + server.headersTimeout = 15_000; + server.listen(port, host, () => console.error('Convoy MCP HTTP listening on ' + host + ':' + port)); + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.once(signal, () => server.close(() => process.exit(0))); } } -main().catch((err: unknown) => { - console.error('convoy mcp http server failed to start:', err); - process.exit(1); -}); +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { main(); } + catch (err) { console.error(err instanceof Error ? err.message : String(err)); process.exitCode = 1; } +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 700fed6..5a189bd 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -12,18 +12,20 @@ * owns it for JSON-RPC frames. Diagnostics go to console.error only. */ import { spawn } from 'node:child_process'; -import { existsSync, mkdirSync, openSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, openSync, closeSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; import { fileURLToPath } from 'node:url'; +import { authorizeTool, canCallTool, type McpAccess } from './access.js'; +import { prepareGhcrConfig, executionEnvironment } from '../core/ghcr-config.js'; + import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import { PlanStore } from '../core/plan.js'; import { RunStateStore } from '../core/state.js'; import type { Approval, Platform, Run, RunEvent } from '../core/types.js'; -import type { RealVpsGhcrOpt } from '../core/stages.js'; import { resolveAnthropicKey, type ByokConfig } from '../core/key-resolver.js'; import { buildPlan } from '../planner/index.js'; import type { IdentityRef } from '../core/identity-store.js'; @@ -164,8 +166,13 @@ const byokSchema = z.object({ .describe('Secret name to fetch (default: ANTHROPIC_API_KEY)'), }).optional().describe('BYOK: supply your own Anthropic API key so Convoy does not bill you for AI calls. Omit to use the server\'s key (if any).'); -export function registerConvoyTools(server: McpServer): void { - server.registerTool( +export function registerConvoyTools(server: McpServer, access: McpAccess = { role: 'local' }): void { + const registerTool: typeof server.registerTool = (name, config, handler) => { + const registered = server.registerTool(name, config, handler); + if (!canCallTool(access, name)) registered.disable(); + return registered; + }; + registerTool( 'convoy_plan', { description: @@ -215,7 +222,7 @@ export function registerConvoyTools(server: McpServer): void { }), ); - server.registerTool( + registerTool( 'convoy_list_plans', { description: 'List saved deployment plans (newest first). Use a returned planId with convoy_apply.', @@ -239,7 +246,7 @@ export function registerConvoyTools(server: McpServer): void { }), ); - server.registerTool( + registerTool( 'convoy_apply', { description: @@ -261,7 +268,8 @@ export function registerConvoyTools(server: McpServer): void { appName: z.string().describe('App name used in logs and Caddy site file'), imageRef: z.string().describe('GHCR image ref without tag, e.g. ghcr.io/myorg/my-app'), ghcrUsername: z.string().describe('GitHub username for docker login'), - ghcrToken: z.string().describe('GitHub token with packages:write (agent) and packages:read (box)'), + ghcrToken: z.string().optional().describe('Deprecated: local stdio only. Prefer ghcrTokenEnv.'), + ghcrTokenEnv: z.enum(['GHCR_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN']).optional().describe('Server environment variable containing the GHCR token (default GHCR_TOKEN).'), buildArgs: z.record(z.string(), z.string()).optional().describe('Docker --build-arg key=value pairs'), composeService: z.string().optional().describe('Compose service name (default: web)'), runMigrations: z.boolean().optional().describe('Run Prisma migrate deploy before rolling (default false)'), @@ -279,6 +287,7 @@ export function registerConvoyTools(server: McpServer): void { }, async ({ planId, autoApprove, realRehearsal, realAuthor, realFly, realVpsGhcr, byok }) => guarded(async () => { + authorizeTool(access, 'convoy_apply', { autoApprove, realRehearsal, realVpsGhcr }); const resolved = resolvePlanId(planId); if ('error' in resolved) return fail(resolved.error); const fullPlanId = resolved.id; @@ -294,15 +303,15 @@ export function registerConvoyTools(server: McpServer): void { ); } - // Spawn `convoy apply` exactly like the web UI does: same tsx - // invocation via the npm script, detached + unref so the pipeline - // outlives this tool call, stdio captured to a per-plan log. + // Run the CLI detached so the pipeline outlives this tool call. + // The parent has already loaded .env. Do not load it again in the + // child: that would restore the MCP credentials stripped below. const logDir = join(REPO_ROOT, '.convoy'); mkdirSync(logDir, { recursive: true }); const logPath = join(logDir, `apply-${fullPlanId.slice(0, 8)}.log`); - const logFd = openSync(logPath, 'a'); + const ghcr = realVpsGhcr ? prepareGhcrConfig(realVpsGhcr) : undefined; - const args = ['run', 'convoy', '--silent', '--', 'apply', fullPlanId]; + const args = ['--import', 'tsx', 'src/cli.ts', 'apply', fullPlanId]; if (autoApprove === true) args.push('--auto-approve'); // The CLI defaults every real stage ON; Convoy's rule is that real // stages are opt-in for agents, so stub each one unless requested. @@ -314,7 +323,7 @@ export function registerConvoyTools(server: McpServer): void { // path flag rather than trying to flatten it into CLI flags. if (realVpsGhcr !== undefined) { const configPath = join(logDir, `ghcr-${fullPlanId.slice(0, 8)}.json`); - writeFileSync(configPath, JSON.stringify(realVpsGhcr as RealVpsGhcrOpt, null, 2), 'utf8'); + writeFileSync(configPath, JSON.stringify(ghcr!.config, null, 2), { encoding: 'utf8', mode: 0o600 }); args.push('--real-vps-ghcr', '--real-vps-ghcr-config', configPath); } @@ -326,15 +335,24 @@ export function registerConvoyTools(server: McpServer): void { ? await resolveAnthropicKey(byok as ByokConfig) : null; - const child = spawn('npm', args, { + const logFd = openSync(logPath, 'a', 0o600); + let spawnError: Error | undefined; + let child: ReturnType; + try { + child = spawn(process.execPath, args, { cwd: REPO_ROOT, env: { - ...process.env, + ...executionEnvironment(process.env), + ...ghcr?.env, ...(byokKey ? { ANTHROPIC_API_KEY: byokKey } : {}), }, stdio: ['ignore', logFd, logFd], detached: true, - }); + }); + } finally { + closeSync(logFd); + } + child.once('error', (error) => { spawnError = error; }); child.unref(); // Poll for the orchestrator-created run row so we can hand back a @@ -344,6 +362,7 @@ export function registerConvoyTools(server: McpServer): void { const startedAtFloor = Date.now(); const deadline = Date.now() + 30_000; while (Date.now() < deadline) { + if (spawnError) return fail(`Could not start apply: ${spawnError.message}`); const fresh = store .listRunsForPlan(fullPlanId) .find((r) => r.startedAt.getTime() >= startedAtFloor - 500); @@ -366,7 +385,7 @@ export function registerConvoyTools(server: McpServer): void { }), ); - server.registerTool( + registerTool( 'convoy_vps_bootstrap', { description: @@ -429,7 +448,7 @@ export function registerConvoyTools(server: McpServer): void { }), ); - server.registerTool( + registerTool( 'convoy_connect', { description: @@ -520,7 +539,7 @@ export function registerConvoyTools(server: McpServer): void { }), ); - server.registerTool( + registerTool( 'convoy_status', { description: @@ -559,7 +578,7 @@ export function registerConvoyTools(server: McpServer): void { ), ); - server.registerTool( + registerTool( 'convoy_approve', { description: @@ -603,7 +622,7 @@ export function registerConvoyTools(server: McpServer): void { ), ); - server.registerTool( + registerTool( 'convoy_diagnose', { description: @@ -658,7 +677,7 @@ export function registerConvoyTools(server: McpServer): void { ), ); - server.registerTool( + registerTool( 'convoy_list_runs', { description: 'List recent runs (newest first) with status, platform, and live URL. Use a runId with convoy_status or convoy_diagnose.', @@ -672,7 +691,7 @@ export function registerConvoyTools(server: McpServer): void { ), ); - server.registerTool( + registerTool( 'convoy_orient', { description: 'Discover what platforms, CI/CD, secrets, and observability a repo already uses. Run this before convoy_plan on an unfamiliar repo.', @@ -694,7 +713,7 @@ export function registerConvoyTools(server: McpServer): void { }), ); - server.registerTool( + registerTool( 'convoy_onboard', { description: 'Capture team deployment preferences for a repo. Call before convoy_plan on a new project. Results persist to .convoy/preferences.json.', @@ -731,7 +750,7 @@ export function registerConvoyTools(server: McpServer): void { }), ); - server.registerTool( + registerTool( 'convoy_rollback_preview', { description: 'Preview a rollback for a Fly.io app: show current release version, target release version, image diff, and a human-readable summary. Returns structured data the calling agent can render or act on. Next step: convoy_rollback_apply to execute, or run `convoy rollback ` at the CLI.', @@ -753,7 +772,7 @@ export function registerConvoyTools(server: McpServer): void { }), ); - server.registerTool( + registerTool( 'convoy_rollback_apply', { description: 'Execute a rollback for a Fly.io app to a specific release version. Run convoy_rollback_preview first to see what will change. This action changes the live deployment.', @@ -781,11 +800,11 @@ function watchUrlFromApp(appName: string): string { return `${WEB_BASE}/runs?flyApp=${appName}`; } -export function createConvoyServer(): McpServer { +export function createConvoyServer(access: McpAccess = { role: 'local' }): McpServer { const server = new McpServer({ name: 'convoy', version: '0.0.1', }); - registerConvoyTools(server); + registerConvoyTools(server, access); return server; }