Skip to content

Commit 858e8a9

Browse files
committed
feat(workflows): support Codex MCP nodes
1 parent 287bb35 commit 858e8a9

15 files changed

Lines changed: 524 additions & 140 deletions

File tree

packages/docs-web/src/content/docs/guides/authoring-workflows.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ nodes:
165165
provider: claude # Per-node provider override
166166
model: haiku # Per-node model override
167167
# hooks: # Optional: per-node SDK hook callbacks (Claude only) — see hooks guide
168-
# mcp: .archon/mcp/servers.json # Optional: per-node MCP servers (Claude only)
168+
# mcp: .archon/mcp/servers.json # Optional: per-node MCP servers (Codex and Claude)
169169
# skills: [remotion-best-practices] # Optional: per-node skills (Claude only) — see skills guide
170170
```
171171

@@ -1173,7 +1173,7 @@ Before deploying a workflow:
11731173
8. **`allowed_tools` / `denied_tools`** — restrict tools per node (Claude only, SDK-enforced)
11741174
9. **`retry:`** — auto-retries transient errors (default: 2 retries / 3 total attempts, 3 s backoff); customize per node
11751175
10. **`hooks`** — attach SDK hook callbacks to Claude nodes for tool control and context injection
1176-
11. **`mcp:`** — attach per-node MCP servers via JSON config (Claude only)
1176+
11. **`mcp:`** — attach per-node MCP servers via JSON config (Codex and Claude)
11771177
12. **`skills:`** — preload skills into Claude nodes for domain expertise
11781178
13. **`agents:`** — inline Claude sub-agent definitions invokable via the `Task` tool
11791179
14. **`effort` / `thinking`** — control reasoning depth and thinking mode per node or workflow (Claude only)

packages/docs-web/src/content/docs/guides/mcp-servers.md

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ DAG workflow nodes support a `mcp` field that attaches MCP (Model Context Protoc
1313
servers to individual nodes. Each node gets exactly the external tools it needs —
1414
GitHub, Linear, Postgres, etc. — without over-provisioning.
1515

16-
**Claude only** — Codex nodes will warn and ignore the `mcp` field.
16+
MCP works with Codex and Claude workflow nodes. Pi nodes still warn and ignore
17+
the `mcp` field.
1718

1819
## Quick Start
1920

@@ -50,6 +51,9 @@ to the AI, and it shuts down when the node completes.
5051
MCP config files are JSON objects where each key is a server name and the value
5152
is a server configuration. Three transport types are supported:
5253
54+
Archon also accepts the common wrapper format `{ "mcpServers": { ... } }`; this
55+
is useful when copying config from tools that already export MCP JSON.
56+
5357
### stdio (default)
5458

5559
Runs a local process. This is the most common type.
@@ -119,8 +123,9 @@ Connects to an SSE endpoint.
119123

120124
## Environment Variable Expansion
121125

122-
Values in `env` and `headers` fields support `$VAR_NAME` references that are
123-
expanded from `process.env` at execution time.
126+
Values in `env` and `headers` fields support `$VAR_NAME` references. They are
127+
expanded from Archon's process environment at execution time. Codex workflow
128+
nodes also include codebase-scoped env vars in that expansion.
124129

125130
```json
126131
{
@@ -165,21 +170,23 @@ A single config file can define multiple servers:
165170
}
166171
```
167172

168-
## Automatic Tool Wildcards
173+
## Provider Tool Wiring
169174

170-
When a node loads MCP servers, tool wildcards are automatically added to `allowedTools`.
171-
For servers named `github` and `postgres`, the node gets:
175+
Claude nodes automatically add tool wildcards to `allowedTools`. For servers
176+
named `github` and `postgres`, the node gets:
172177

173178
- `mcp__github__*`
174179
- `mcp__postgres__*`
175180

176-
This means all tools from those servers are immediately available without manually
177-
listing them. The wildcards merge with any existing `allowed_tools` on the node.
181+
Codex nodes pass the same MCP config as per-node `mcp_servers` overrides to the
182+
Codex SDK, so the servers are available for that node without requiring global
183+
`~/.codex/config.toml` setup.
178184

179185
## MCP-Only Nodes
180186

181-
Combine `mcp` with `allowed_tools: []` to create nodes that can only use MCP tools
182-
and have no access to built-in tools (Bash, Read, Write, etc.):
187+
For providers that support tool restrictions, combine `mcp` with
188+
`allowed_tools: []` to create nodes that can only use MCP tools and have no
189+
access to built-in tools (Bash, Read, Write, etc.):
183190

184191
```yaml
185192
nodes:
@@ -190,7 +197,9 @@ nodes:
190197
```
191198

192199
This is useful for sandboxing — the AI can only interact through the MCP server
193-
and cannot touch the filesystem or run shell commands.
200+
and cannot touch the filesystem or run shell commands. Codex currently does not
201+
support Archon's `allowed_tools` / `denied_tools` restrictions, so this pattern
202+
is enforced for Claude nodes but not Codex nodes.
194203

195204
## Connection Failure Handling
196205

@@ -205,12 +214,12 @@ MCP server connection failed: github (failed)
205214
The node continues executing but without the tools from the failed server.
206215
Check your config file path, server command, and environment variables if this happens.
207216

208-
User-level Claude plugin MCPs inherited from `~/.claude/` (e.g. `telegram`,
209-
`notion`) routinely fail to connect inside the headless workflow subprocess
210-
and are **not** surfaced here — they're not actionable for the workflow author.
211-
They appear only in debug logs as `dag.mcp_plugin_connection_suppressed`. Run
212-
the CLI with `--verbose` (or set `LOG_LEVEL=debug` on the server) if you need
213-
to see them.
217+
User-level plugin MCPs inherited from provider-specific user config routinely
218+
fail to connect inside headless workflow subprocesses and are **not** surfaced
219+
when the workflow did not configure MCP itself — they're not actionable for the
220+
workflow author. They appear only in debug logs as
221+
`dag.mcp_plugin_connection_suppressed` for Claude workflows. Run the CLI with
222+
`--verbose` (or set `LOG_LEVEL=debug` on the server) if you need to see them.
214223

215224
## Workflow Examples
216225

@@ -368,8 +377,8 @@ bun run cli workflow run archon-smart-pr-review "Review PR #123"
368377

369378
## Limitations
370379

371-
- **Claude only** — Codex nodes warn and ignore the `mcp` field. Configure MCP
372-
servers globally in the Codex CLI config instead.
380+
- **Codex tool restrictions** — Codex nodes support `mcp`, but Archon's
381+
`allowed_tools` / `denied_tools` restrictions are still ignored by Codex.
373382
- **Haiku model** — Tool search (lazy loading for many tools) is not supported on
374383
Haiku. You'll see a warning. Consider using Sonnet or Opus for MCP nodes.
375384
- **No load-time validation** — The MCP config file is read at execution time, not
@@ -386,8 +395,8 @@ bun run cli workflow run archon-smart-pr-review "Review PR #123"
386395
| `MCP config must be a JSON object` | Top-level value is array or string | Wrap in `{ "server-name": { ... } }` |
387396
| `undefined env vars: VAR_NAME` | Environment variable not set | Export the variable or add it to your `.env` |
388397
| `MCP server connection failed` | Server process crashed or URL unreachable | Check command/URL, test the server standalone |
389-
| Plugin MCP missing from workflow output | User-level plugin MCPs (from `~/.claude/`) are filtered out of workflow warnings | Run with `--verbose` and look for `dag.mcp_plugin_connection_suppressed` |
390-
| `mcp config but uses Codex` | Node resolved to Codex provider | Set `provider: claude` on the node or switch default |
398+
| Plugin MCP missing from workflow output | User-level plugin MCPs are filtered out of workflow warnings | Run with `--verbose` and look for provider MCP debug logs |
399+
| `allowed_tools` ignored with Codex | Codex provider does not support Archon's tool restrictions yet | Do not rely on `allowed_tools: []` for Codex sandboxing |
391400
| `Haiku model with MCP servers` | Haiku doesn't support tool search | Use `model: sonnet` or `model: opus` instead |
392401

393402
## Finding MCP Servers

packages/providers/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"./codex/provider": "./src/codex/provider.ts",
1414
"./codex/config": "./src/codex/config.ts",
1515
"./codex/binary-resolver": "./src/codex/binary-resolver.ts",
16+
"./mcp/config": "./src/mcp/config.ts",
1617
"./community/pi": "./src/community/pi/index.ts",
1718
"./errors": "./src/errors.ts",
1819
"./registry": "./src/registry.ts"
Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
11
export { ClaudeProvider } from './provider';
22
export { parseClaudeConfig, type ClaudeProviderDefaults } from './config';
3-
export {
4-
loadMcpConfig,
5-
buildSDKHooksFromYAML,
6-
withFirstMessageTimeout,
7-
getProcessUid,
8-
} from './provider';
3+
export { loadMcpConfig } from '../mcp/config';
4+
export { buildSDKHooksFromYAML, withFirstMessageTimeout, getProcessUid } from './provider';

packages/providers/src/claude/provider.ts

Lines changed: 1 addition & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,7 @@ import { parseClaudeConfig } from './config';
3636
import { CLAUDE_CAPABILITIES } from './capabilities';
3737
import { resolveClaudeBinaryPath } from './binary-resolver';
3838
import { createLogger } from '@archon/paths';
39-
import { readFile } from 'fs/promises';
40-
import { resolve, isAbsolute } from 'path';
39+
import { loadMcpConfig } from '../mcp/config';
4140

4241
/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */
4342
let cachedLog: ReturnType<typeof createLogger> | undefined;
@@ -199,96 +198,6 @@ export function getProcessUid(): number | undefined {
199198
return typeof process.getuid === 'function' ? process.getuid() : undefined;
200199
}
201200

202-
// ─── MCP Config Loading (absorbed from dag-executor) ───────────────────────
203-
204-
/**
205-
* Expand $VAR_NAME references in string-valued records from process.env.
206-
*/
207-
function expandEnvVarsInRecord(
208-
record: Record<string, unknown>,
209-
missingVars: string[]
210-
): Record<string, string> {
211-
const result: Record<string, string> = {};
212-
for (const [key, val] of Object.entries(record)) {
213-
if (typeof val !== 'string') {
214-
getLog().warn({ key, valueType: typeof val }, 'mcp_env_value_coerced_to_string');
215-
result[key] = String(val);
216-
continue;
217-
}
218-
result[key] = val.replace(/\$([A-Z_][A-Z0-9_]*)/g, (_, varName: string) => {
219-
const envVal = process.env[varName];
220-
if (envVal === undefined) {
221-
missingVars.push(varName);
222-
}
223-
return envVal ?? '';
224-
});
225-
}
226-
return result;
227-
}
228-
229-
function expandEnvVars(config: Record<string, unknown>): {
230-
expanded: Record<string, unknown>;
231-
missingVars: string[];
232-
} {
233-
const result: Record<string, unknown> = {};
234-
const missingVars: string[] = [];
235-
for (const [serverName, serverConfig] of Object.entries(config)) {
236-
if (typeof serverConfig !== 'object' || serverConfig === null) {
237-
getLog().warn({ serverName, valueType: typeof serverConfig }, 'mcp_server_config_not_object');
238-
continue;
239-
}
240-
const server = { ...(serverConfig as Record<string, unknown>) };
241-
if (server.env && typeof server.env === 'object') {
242-
server.env = expandEnvVarsInRecord(server.env as Record<string, unknown>, missingVars);
243-
}
244-
if (server.headers && typeof server.headers === 'object') {
245-
server.headers = expandEnvVarsInRecord(
246-
server.headers as Record<string, unknown>,
247-
missingVars
248-
);
249-
}
250-
result[serverName] = server;
251-
}
252-
return { expanded: result, missingVars };
253-
}
254-
255-
/**
256-
* Load MCP server config from a JSON file and expand environment variables.
257-
*/
258-
export async function loadMcpConfig(
259-
mcpPath: string,
260-
cwd: string
261-
): Promise<{ servers: Record<string, unknown>; serverNames: string[]; missingVars: string[] }> {
262-
const fullPath = isAbsolute(mcpPath) ? mcpPath : resolve(cwd, mcpPath);
263-
264-
let raw: string;
265-
try {
266-
raw = await readFile(fullPath, 'utf-8');
267-
} catch (err) {
268-
const e = err as NodeJS.ErrnoException;
269-
if (e.code === 'ENOENT') {
270-
throw new Error(`MCP config file not found: ${mcpPath} (resolved to ${fullPath})`);
271-
}
272-
throw new Error(`Failed to read MCP config file: ${mcpPath}${e.message}`);
273-
}
274-
275-
let parsed: Record<string, unknown>;
276-
try {
277-
parsed = JSON.parse(raw) as Record<string, unknown>;
278-
} catch (parseErr) {
279-
const detail = (parseErr as SyntaxError).message;
280-
throw new Error(`MCP config file is not valid JSON: ${mcpPath}${detail}`);
281-
}
282-
283-
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
284-
throw new Error(`MCP config must be a JSON object (Record<string, ServerConfig>): ${mcpPath}`);
285-
}
286-
287-
const { expanded, missingVars } = expandEnvVars(parsed);
288-
const serverNames = Object.keys(expanded);
289-
return { servers: expanded, serverNames, missingVars };
290-
}
291-
292201
// ─── SDK Hooks Building (absorbed from dag-executor) ───────────────────────
293202

294203
/** YAML hook matcher shape (matches @archon/workflows/schemas/dag-node WorkflowNodeHooks) */

packages/providers/src/codex/capabilities.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ProviderCapabilities } from '../types';
22

33
export const CODEX_CAPABILITIES: ProviderCapabilities = {
44
sessionResume: true,
5-
mcp: false,
5+
mcp: true,
66
hooks: false,
77
skills: false,
88
agents: false,

0 commit comments

Comments
 (0)