017: MCP Proxy via Per-Step stdio Subprocess for Tool Interception
Status: Accepted Date: 2026-05-23 Issue: F099 Supersedes: N/A Superseded by: N/A
Context
AWF orchestrates AI agents (Claude, Gemini, Codex, OpenCode, OpenAI Compatible) that invoke file system and shell tools as part of workflow execution. Currently those tool calls are entirely opaque to AWF: the agent CLI receives a prompt, runs, and returns output — AWF cannot intercept, audit, or extend the tool set the agent uses.
F099 must solve three problems simultaneously:
- Interception: Make AWF’s 6 built-in tools (
Read,Write,Edit,Bash,Glob,Grep) available to agents via a structured protocol, so that tool calls are observable and auditable (OTel spans, structured logs). - Extension: Allow AWF gRPC plugins (existing
ports.OperationProviderimplementations) to expose their operations as agent tools without agents knowing about AWF’s plugin model. - Multi-provider support: The mechanism must work across five agents with different injection APIs — four stdio CLIs and one HTTP provider — without requiring provider-specific tool-call logic in the domain or application layers.
Two protocol-level questions are load-bearing beyond this feature:
- Which protocol governs the host–agent tool call contract? The answer locks in an external-facing API that plugin SDK authors and workflow authors will depend on.
- What process topology delivers that protocol? The answer determines crash isolation, subprocess lifecycle complexity, and client compatibility across all five providers.
Candidates
Protocol
| Option | Pros | Cons |
|---|---|---|
| MCP 2024-11-05 (Model Context Protocol) | Already supported by Claude, Gemini, Codex, OpenCode; JSON-RPC 2.0 base; standardized tools/list + tools/call semantics; schema-first tool definitions | Subset selection required; not all features needed |
| Custom JSON-RPC over stdio | Full control over schema | No CLI support out-of-box; every provider needs a custom adapter; no ecosystem tooling |
OpenAI tools[] HTTP format only | Native to OpenAI Compatible provider; well-documented | Not supported by stdio CLIs (Claude, Gemini, Codex, OpenCode); two protocols required anyway |
Process Topology
| Option | Description | Files changed | Risk |
|---|---|---|---|
| A: In-process MCP server | AWF embeds the MCP server as a goroutine; agents connect via UNIX socket | ~10 | High — UNIX socket transport is nonstandard for Claude/Gemini CLI; stdio is the documented path |
B: Per-step subprocess awf mcp-serve | AWF spawns awf mcp-serve --config=<tmp> per step; agents connect via stdio JSON-RPC | ~15 | Medium — subprocess lifecycle, but proven pattern from RPCPluginManager |
| C: External MCP server via go-plugin gRPC | Proxy as a go-plugin gRPC plugin loaded by AWF | ~25 | High — unnecessary extra layer; harder to debug; changes the plugin model |
Decision
Protocol: Adopt MCP 2024-11-05 (latest stable as of 2026-01-01). Implement only the required subset: initialize, initialized, tools/list, tools/call, shutdown. Prompts, resources, notifications/progress, and sampling are out of scope and deferred.
Process topology: Option B — per-step subprocess awf mcp-serve. One awf mcp-serve process is spawned per step where mcp_proxy.enable: true. The subprocess serves MCP over stdin/stdout. The parent awf run process spawns it via ToolProxyService.Start() and tears it down via ToolProxyService.Close().
Server implementation: The MCP server implementation initially lived in pkg/mcpserver/ but was migrated to internal/infrastructure/mcp/ in F104 to adopt the official github.com/modelcontextprotocol/go-sdk (see ADR 019). The adapter wraps the official SDK while maintaining identical user-facing behavior and continues to enforce zero internal/ imports at the adapter boundary via lint rules and AST-based architecture tests.
OpenAI Compatible exception: The HTTP provider cannot use stdio; instead, ToolRouter is invoked in-process and its tool definitions are injected as tools[] in the Chat Completions request. This is an explicit split: stdio providers use subprocess MCP, HTTP provider uses in-process tools[].
Key rules established:
pkg/mcpserverdepends on Go stdlib only — nointernal/imports, no framework deps.ToolProviderport in domain;BuiltinToolProvider+PluginToolAdapterin infrastructure;ToolRouter+ToolProxyServicein application.- Tool names follow
<plugin>_<op>(snake-case, single underscore) to satisfy MCP client name constraints. Dots are forbidden (Claude rejects them). - Collision detection is fatal at step startup (registration time), not at call time.
- Subprocess lifecycle uses goroutine + buffered channel + 5s SIGTERM→SIGKILL deadline, matching
RPCPluginManager.connectWithTimeoutexactly. awf mcp-serveisHidden: true— not user-facing; no stability guarantees independent of AWF binary version.USER.MCP_PROXY.*validation codes extend the error taxonomy (exit code 1) with six new codes:UNKNOWN_KEY,UNKNOWN_PLUGIN,UNKNOWN_OPERATION,NAME_COLLISION,EMPTY_PROXY,UNSUPPORTED_PROVIDER.
Consequences
What becomes easier:
- Tool calls from all five agent providers are observable: each
tools/callproduces an OTel span and a structured zap log line. - AWF gRPC plugins can expose operations to agents with no changes to the plugin manifest —
PluginToolAdapterwraps the existingports.OperationProvider. - New tools can be added by implementing
ports.ToolProviderwithout touching any agent provider code. - External consumers can embed
pkg/mcpserverto build custom MCP-enabled tooling. - Subprocess crash isolation: a panic in
awf mcp-serveis visible to the parent as a subprocess exit error but does not crashawf run.
What becomes harder:
- Each step with
mcp_proxy.enable: truespawns an extra Go process (~10 MB RSS). At AWF’s current scale this is acceptable; at high parallelism it requires monitoring. - Codex and OpenCode have no
--tools ""equivalent. Built-in tools cannot be disabled via flag injection; the proxy coexists with native tools and emits a startupWARNlog. This is an accepted limitation documented in the YAML validation. - MCP protocol version upgrades require coordinated changes to
pkg/mcpserver, the hiddenmcp-servesubcommand, and the per-provider config injection. The committed MCP version (2024-11-05) becomes the wire contract. pkg/mcpserverbecoming public means adding new MCP methods (e.g.,notifications/progress) is a semver-visible change.- The OpenAI Compatible provider requires a separate in-process tools path (
tools[]+tool_choice+ multi-turn tool-call loop), maintained in parallel with the stdio subprocess path.
Constitution Compliance
| Principle | Status | Justification |
|---|---|---|
| Hexagonal Architecture | Compliant | Domain port ports.ToolProvider; application ToolRouter/ToolProxyService; infrastructure adapters; pkg/mcpserver has zero internal/ imports; .go-arch-lint.yml extended with pkg-mcpserver and infra-tools components scoped appropriately |
| Go Idioms | Compliant | context.Context on all blocking ops; goroutine+buffered-channel+select for subprocess lifecycle; errors.Is/fmt.Errorf wrapping throughout |
| Minimal Abstraction | Compliant | No ToolPolicy/ToolMiddleware/ToolCache ports — decorator pattern is available if needed but not added prematurely; single function-value extension on cliProviderHooks (not a new interface) |
| Error Taxonomy | Compliant | Six new USER.MCP_PROXY.* codes extend the existing taxonomy; no new exit code category required (all are user/configuration errors, exit code 1) |
| Security First | Compliant | Bash tool delegates to ShellExecutor (existing shell-escaping, secret masking); subprocess uses SIGTERM→SIGKILL (no zombies); tmp config file written atomically (PID+timestamp suffix) |
| Test-Driven Development | Compliant | Table-driven unit tests per component; AST-based architecture tests for pkg/mcpserver import invariant; make test-race required on all application/infrastructure new code |
| Documentation Co-location | Compliant | doc.go per new package; YAML schema documented in mcp_proxy.go struct comments |