Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 29 additions & 21 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <credential for this client's role>` |

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

Expand All @@ -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 }
Expand All @@ -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",
Expand All @@ -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.
3 changes: 2 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env node
import { resolveGhcrConfig } from './core/ghcr-config.js';
import { Command } from 'commander';
import pc from 'picocolors';

Expand Down Expand Up @@ -2301,7 +2302,7 @@ async function runApply(planId: string, opts: ApplyOpts): Promise<void> {
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);
Expand Down
18 changes: 18 additions & 0 deletions src/core/ghcr-config.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
24 changes: 24 additions & 0 deletions src/core/ghcr-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { RealVpsGhcrOpt } from './stages.js';

export type GhcrConfigInput = Omit<RealVpsGhcrOpt, 'ghcrToken'> & { 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)));
}
48 changes: 48 additions & 0 deletions src/mcp/access.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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<string, unknown> | 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<McpRole, 'local'>; token: string }

export function loadHttpCredentials(env: NodeJS.ProcessEnv = process.env): HttpCredential[] {
const entries: [Exclude<McpRole, 'local'>, 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<McpRole, 'local'>, 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;
}
62 changes: 62 additions & 0 deletions src/mcp/http.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<void>((resolve, reject) => server.close((err) => err ? reject(err) : resolve()));
}
});
Loading
Loading