This page has four layers:How we work today(MCP client and authenticated stdio server coexist; the internal Web surface retains independent owner authentication), How others work (references/ , examining nine frameworks, including two explicit non-implementations and two negative examples), What we implemented(six tools, an empty allowlist by default, a low-authority external tier, stdio startup authentication, and specification-aligned cancellation and progress), Ideal state(the boundaries of existing ACP, gaps in inbound webhooks and SDKs, and why this iteration does not expand them). This page is the authoritative design and implementation evidence for this capability.
The MCP client and authenticated stdio server now coexist. The internal control surface still has 198 FastAPI routes and approximately 100 WebSocket actions. OwnerAuthMiddleware uniformly enforces process tokens, cookie/Bearer credentials, Host/Origin checks, and WebSocket authentication before accept. The MCP server instead uses an independent 0600 token, credential-fingerprint client identity, fixed paired scope, an empty allowlist by default, and a non-interactive gate; it does not reuse the Web owner token.
Consuming external tools and exposing fixed tools are independent entry points. Both are implemented and share controlled capabilities through the Runtime registry.
AgentTool.execute has the signature (call_id, args, cancel: asyncio.Event | None, on_update). Its argument dictionary, cancellation event, and incremental callback provide all three mechanisms required by the protocol server. The server uses an explicit CallToolRequest handler to connect these parameters to the MCP 1.29.0 stdio transport.FastAPI and WebSocket surfaces still have no versioning commitment, but are protected by OwnerAuthMiddleware and mapped to full owner authority after successful authentication. This authentication is neither an MCP client identity nor a low-authority external protocol contract.
| Surface | Location | Size | Contract |
|---|---|---|---|
| FastAPI routes | apps/server/openprogram_server/server.py in create_app(); routes in apps/server/openprogram_server/_webui/routes/ | 198 routes / approximately 25 modules | Two routes have exact-equality assertions |
WebSocket /ws | _websocket_handler; actions from apps/server/openprogram_server/_webui/ws_actions/ | Approximately 100 actions and 120 event types | None |
| Tool registry | openprogram/programs/_runtime.py in _registry | Indexed by tool name | No external contract |
OwnerAuthMiddleware already protects Web HTTP, SSE, and WebSocket, but authentication grants the singleton owner’s full authority. The MCP server must authenticate external clients separately, enforce an empty allowlist by default, and assign low-authority scope. It must not provide or reuse the Web owner token for clients.wrap_with_approval returns a execute is replaced with a _gated_execute in AgentTool copy. External callers must use these stages rather than bypass them.
CancelToken, never per-session: threading.Event plus the retired flag; a stop arriving after a turn ends cannot affect the next turn.add_pre_invocation_hook and set_cancellation_check; each Agent method entry and each Runtime.exec aborts.execution_id, persists cancellation intent first, then wakes the corresponding owner and pending questions. ACP, MCP, Channels, and Web share one Control Service; session requests without an execution identity do not activate it.ToolResult has a first-class is_error: bool.AgentToolResult already has a first-class is_error: bool; Runtime, approval, the agent loop, and MCP conversion read this field, whiledetails retains diagnostic information only.AgentEventToolEnd has a first-class is_error.is_error is a top-level typed bit;details retains only fixed diagnostic fields such as reason_code, denied, timeout .tests/component/webui/runtime/test_diagnostic_mcp_route_contracts.py locks down /api/doctor in {"results", "all_ok"}, /api/mcp/servers ’s 15-key status dictionary and the 404 shape for a missing server. All three tests create a bare FastAPI() and then register(app), so OwnerAuthMiddleware is outside the path. Assertions use exact equality; adding a field fails them.references/ were examined individually: four provide MCP servers and three provide ACP servers. Two explicitly do not (pi-mono states “No MCP.”; pi-ai is itself a provider library). Two negative examples: weclaw’s /api/send has no authentication and auto-approves ACP permissions; opencode’s serve only warns, without blocking, when no password is set.
| Framework | MCP server | ACP (agent side) | Inbound HTTP / webhook | Client SDK |
|---|---|---|---|---|
| claude-code | None (snapshot contains only BashTool/) | None | None | None |
| claude-code-leaked | claude mcp serve, stdio, All built-in tools, empty permission context | None | claude server, automatically generates sk-ant-cc-* bearer | Agent SDK uses the control protocol |
| codex-cli | codex mcp-server, stdio, Only codex / codex-reply | None; uses its own app-server | app-server: stdio / WS / unix, token file / SHA-256 / JWT | TS @openai/codex-sdk + Python |
| openclaw | Three stdio servers plus MCP-over-HTTP (bearer + loopback) | openclaw acp, through Gateway | Gateway HTTP API + HMAC webhook plugin | @openclaw/sdk (private) |
| opencode | None — client only | opencode acp | opencode serve, HTTP Basic; Only warns when no password is set | @opencode-ai/sdk, generated from OpenAPI |
| hermes-agent | hermes mcp serve, FastMCP stdio, Ten tools | hermes acp plus ACP registry entries | HMAC webhook adapter + bearer OpenAI-compatible API | None — integrators use MCP / ACP / HTTP directly |
| pi-ai | None | None | None | Not applicable; it is a provider client library |
| pi-mono | None; README explicitly states "No MCP." | None | None; only in-process pi.send() | Package for embedding only |
| weclaw | None | Only ACP client, automatically approves every permission request | POST /api/send, No authentication | None |
| OpenProgram | openprogram mcp serve, authenticated stdio, six fixed wrappers, empty allowlist by default | openprogram acp, stdio; existing session, permission, and event mappings | Internal Web routes, owner token/cookie authentication | No dedicated SDK; uses the standard MCP SDK |
threadId, codex_elicitation, codex_mcp_tool_call_id, codex_event_id, receiving ReviewDecision.ElicitResult (missing action / content).allow-once | allow-always | deny. openclaw also supports push through the experimental capability bit claude/channel/permission .tasks/cancel explicitly reports unsupported.extra.signal into callTool(params, signal).AbortController for every call butnever connects it to notifications; hermes has no cancellation.notifications/progress ; its own streaming uses custom notification shapes.None of the surveyed frameworks emits standard progress externally.{isError: true, content:[text]} when a tool executes but fails, reserving JSON-RPC errors for protocol failures (unknown tool name, malformed arguments).We adopt this distinction.POST /api/send has no authentication, and its ACP client unconditionally approves every session/request_permission ; opencode’s serve runs without authentication when OPENCODE_SERVER_PASSWORD is unset, merely printing a warning. claude-code-leaked’s mcp serve exposes all built-in tools with an empty permission context and isNonInteractiveSession: true, with no interactive question path at all. This iteration’s acceptance criteria exclude all three approaches.openprogram mcp serve uses stdio as its only transport and exposes six tools. tool_call filters using an allowlist that is empty by default. External callers receive the paired low-authority tier and are never interactive. Startup authentication uses an environment credential with no disable switch. Cancellation maps to existing per-turn tokens; progress is driven by on_update ; errors follow the distinction above. Everything below is tied to implementation commit 885d8f15.
codex uses two tools, hermes ten, and openclaw nine. Exposing every tool, as claude-code-leaked does, is deliberately excluded. We use six.
| Tool | Purpose | Capability | Host side effects |
|---|---|---|---|
sessions_list | List session ids, titles, and update times | reply | None |
session_get | Retrieve one session’s messages | reply | None |
prompt_send | Start a turn in a session and return its result (session id optional) | reply | Session append only |
prompt_cancel | Cancel an in-flight turn started by this caller | reply | None |
tools_list | List tools and schemas allowed by the caller’s tier | reply | None |
tool_call | Invoke one tool by name | Depends on the tool | Uses the allowlist and approval stages |
register , no configuration rewriting, no credential access, and no directly exposed file tools. Callers wanting file reads invoke tool_call with the file-tool name, passing the same allowlist and approval stages. prompt_send combines codex’s codex/codex-reply into one call.Three sequential gates; failure at any gate prevents execution. The allowlist filters exposure and does not replace approval.
External callers occupy the lowest-authority row in the sandbox designrequest-source table:
| Request source | principal | authority_tier | Missing-field handling |
|---|---|---|---|
| Authenticated local Web / CLI / TUI | principal_id=owner, interaction=interactive | owner, holding approval.request | The entry point must construct it explicitly; invalid authentication is rejected |
| Paired channel account | Instance owner; speaker stored separately | paired, holding reply and memory.source.append | Messages without a tier are rejected without downgrading |
| External MCP client | Instance owner; client id stored separately | paired intersected with the exposed-tool allowlist | Reject connection if identity is missing or fails validation |
| continuation / subagent | Explicitly inherit owner | Inherit the caller’s tier unchanged; never increase authority | Missing fields are a state error: deny |
| Cron trigger | Owner approved at creation | Job capability fixed at approval | Do not recompute as interactive at trigger time |
openprogram mcp token create atomically writes the token file and prints it once;serve never implicitly generates a token.openprogram mcp serve compares the environment variable against the token file in constant time before reading any MCP message. Missing credentials, incorrect permissions, or mismatch cause a nonzero exit; stderr is fixed to Error: MCP server authentication failed and stdout is empty.initialize.clientInfo is neither read nor recorded and does not participate in authentication, identity, authority, routing, or results. The token never enters JSON-RPC, logs, errors, or session content.paired and never receives owner authority.mcp 1.29.0 and protocol 2025-11-25; the SDK negotiates compatible versions without custom handshake fields.openprogram/mcp/ ;does not reuse client modules (translation runs in the opposite direction), but shares _runtime.py and approval stages — those are the intended reuse points.| Condition | Response | Reason |
|---|---|---|
| Unknown tool name | JSON-RPC error, method level | Protocol failure |
| Arguments fail schema validation | JSON-RPC error, invalid params | Protocol failure |
| Missing or incorrect startup environment token | Nonzero process exit before the MCP lifecycle | Authentication precedes stdin reads; errors go only to stderr |
| Tool is not allowlisted | JSON-RPC error, treated as unknown tool name | Do not reveal unauthorized tools’ existence |
| Tier lacks the capability | isError: true, body identifies the missing capability | Tool exists but is unavailable for this call |
| Approval denied or unavailable | isError: true, retaining only a fixed gate reason | Deny without an owner present; do not pass through arbitrary tool details |
| Tool executed and failed | isError: true, fixed sanitized body and reason code | Preserve typed errors without leaking exceptions, traces, or tool-reported reasons across the boundary |
| External SDK cancels an in-flight request | ErrorData(code=0, message="Request cancelled", data=None); the application returns no tool result | SDK 1.29.0’s notifications/cancelled owns the response; OpenProgram only stops, cleans up, and audits |
Ordinary prompt_cancel | Active turns on the same connection return cancelled=true; foreign/completed returns false | This is one of the six wrappers, not the SDK cancellation notification |
is_error is now a first-class field of AgentToolResult . Runtime normalization, approval refusal, the agent loop, MCP client/server conversion, and cache eligibility all use this typed bit. Legacy details["is_error"] remains only an input-compatibility bridge, no longer canonical state for in-repository producers.tests/integration/programs/test_mcp_server.py launches openprogram mcp serve, using the MCP SDK’s own client, covering these protocol and security behaviors: missing or incorrect tokens fail before handshake; with the correct environment token, the SDK completes initialize; clientInfo ’s arbitrary self-reported values do not change authority. MCP tools/list returns exactly six fixed entry points; its tools_list result equals exactly the intersection of the configured allowlist, current registry, and paired scope. An intersection-allowed read-only tool_call returns content; unknown or unexposed tools are treated as nonexistent. Exposed tools outside scope and calls requiring approval return isError instead of hanging. notifications/cancelled causes the SDK to return a standard JSON-RPC cancellation error for the original prompt_send ; assertions verify no later tool result / isError. Tests also verify subsequent side effects stop, active requests and questions are cleared, and an audit event is recorded. Progress notifications arrive if and only if progressToken is supplied, preserving per-request ordering. Normal, error, and cancellation paths all terminate the progress consumer within bounds; teardown must not swallow external parent cancellation. Acceptance uses real MCP 1.29.0 ClientSession through a real openprogram mcp serve subprocess, covering six wrappers, typed errors, concurrency, progress, cancellation, and protocol-only stdout frames. Handwritten frames supplement raw stdout checks only.ACP already has a stdio server entry point. This iteration preserves it without expanding the protocol surface. Inbound webhooks lack fixed job capabilities, and an SDK lacks a surface stable enough to promise compatibility. The latter two enter design only once their security and compatibility prerequisites are satisfied.
openprogram acp already provides a stdio entry point through openprogram/acp/server.py , with session, permission-request, and event mappings. It currently uses owner authority and interactive permission semantics. This is separate from the independent MCP client identity, fixed paired scope, and non-interactive gate in this design.Layer three is complete at implementation commit 885d8f15 , with independent specification and quality reviews resolving all remaining findings. The MCP server, existing MCP client, ACP stdio, and internal Web surface have independent entry points and authentication boundaries.
| Item | Status | Implementation and evidence |
|---|---|---|
openprogram mcp serve stdio server | Implemented | openprogram/mcp/server/server.py; authentication completes before entering SDK stdio |
| Minimum set of six tools | Implemented | sessions_list, session_get, prompt_send, prompt_cancel, tools_list, tool_call; SDK tools/list returns exactly these six entries |
mcp_server.exposed_tools allowlist, empty by default | Implemented | Discovery and execution each compute configured allowlist ∩ live registry ∩ paired capability |
mcp token create + stdio startup environment authentication, with no disable switch | Implemented | Independent 0600 token; OPENPROGRAM_MCP_TOKEN constant-time comparison; identity is the first 16 lowercase hexadecimal characters of the credential’s SHA-256 |
External callers’ authority_tier row | Implemented | Fixed paired, speaker_kind=client, interaction=non-interactive; clientInfo does not participate in authentication or authorization |
source=mcp non-interactive + hard-constraint gate | Implemented | Dangerous process/worktree/out-of-root writes are hard-denied first; approval and ordinary questions never wait; rejection results are typed and sanitized |
| SDK cancellation → request cleanup and audit | Implemented | Exact request/session/connection ownership; set thread/tool events, clean up process, Runtime, questions, and run-control, then record mcp.request.cancelled |
notifications/progress driven by on_update | Implemented | Only send when progressToken is present; monotonic per-request ordering; bounded consumer cleanup for normal/error/cancel paths, preserving parent cancellation |
AgentToolResult with first-class is_error | Implemented | Runtime failure, refusal, and wire CallToolResult.isError use the typed bit end to end; sensitive bodies/details are sanitized at the MCP boundary |
| End-to-end protocol tests | Implemented | Real MCP 1.29.0 ClientSession + CLI stdio subprocess, protocol 2025-11-25; covers all six tools, clientInfo invariance, typed errors, question decline, progress, concurrency, cancellation, audit, and stdout isolation |
| ACP stdio server | Already exists; not expanded here | apps/cli/python/openprogram_cli/_impl/commands/acp.py and openprogram/acp/server.py; shared-type regressions only |
| Inbound webhook | Outside this iteration | Depends on fixed job capabilities from sandbox batch I and step 05B |
| Client SDK | Outside this iteration | The standard MCP SDK already serves the client role. Consider a dedicated OpenProgram SDK only when real integrations prove the frozen six-tool surface too coarse. |
885d8f15; Task 8 final specification review PASS, 0 remaining findings, quality review PASS, 0 remaining findings. Five release gates before Task 9 edits: MCP server focused 195 passed; shared authority / permission / hard-constraint / questions / ACP / Runtime regressions 198 passed; documentation build 453 pages; link check 0 broken; git diff --check PASS. After editing, repeated the same five commands: MCP server focused 195 passed; shared regressions 198 passed; documentation build 453 pages; link check 0 broken; git diff --check PASS.53f77d74 moved the server implementation from a separate top-level directory into openprogram/mcp/server/, without retaining a duplicate compatibility package. CLI, protocol, token files, and mcp_servers.json data paths are unchanged. Verification includes 174 server unit/component tests, 65 integration and Web Use regressions, 76 Runtime HTTP safety inventory and structural contracts, Ruff, documentation links, and wheel contents. Specification and quality reviews both passed.OwnerAuthMiddleware already protects the singleton owner’s HTTP, SSE, and WebSocket, but does not authenticate external MCP clients, enforce the tool allowlist, or assign the low-authority scope defined here. The independent MCP server implements these controls without reusing the Web owner token.