MCP server: current state, nine-framework comparison, and minimum capability set

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.

Layer one · How we work today
Layer two · How others work
Layer three · Implemented minimum capabilities
Layer four · Ideal state
LAYER 1

How we work today

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.

01Direction: MCP client and server coexist

Consuming external tools and exposing fixed tools are independent entry points. Both are implemented and share controlled capabilities through the Runtime registry.

The two MCP directions The two solid lines represent the outbound MCP client and inbound MCP stdio server. OpenProgram openprogram/mcp/ ← client, eight modules openprogram/mcp/server/ ← server, implemented Both directions share the _registry tool registry External MCP server drawio, filesystem… supports stdio / HTTP / SSE Implemented External MCP client Claude Desktop, another agent, IDE Implemented
Existing reuse points: 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.

02Three internal surfaces, authenticated Web boundary

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.

Caller → OwnerAuthMiddleware → internal routes Every path validates Host first; protected paths then validate a cookie or Bearer credential. Browser HttpOnly cookie + Origin / CSRF native HTTP / SSE / WS Bearer required; Origin optional Non-loopback bind Explicit effective Origin + token OwnerAuthMiddleware canonical Host / Origin cookie / Bearer CSRF / no-store Attach owner authority after authentication 198 FastAPI routes Protected by default; internal shapes are not external protocol commitments WebSocket /ws · approximately 100 actions Middleware authenticates before accept(); approximately 120 server-to-client event types Tool registry _registry register / get / filter_for; three switches: toolset, expose, defer MCP cannot reuse the full-owner Web token: it requires independent client identity, an empty allowlist by default, and low-authority scope
SurfaceLocationSizeContract
FastAPI routesapps/server/openprogram_server/server.py in create_app(); routes in apps/server/openprogram_server/_webui/routes/198 routes / approximately 25 modulesTwo routes have exact-equality assertions
WebSocket /ws_websocket_handler; actions from apps/server/openprogram_server/_webui/ws_actions/Approximately 100 actions and 120 event typesNone
Tool registryopenprogram/programs/_runtime.py in _registryIndexed by tool nameNo external contract
The authentication boundaries are independent: 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.

03Approval stages: nine, in fixed order

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.

Nine decisions in _gated_execute Evaluate left to right and return at the first match. Red boxes precede bypass; green boxes permit execution. 01 hard constraints Before rules and bypass 02 Rule deny / ask deny > ask > allow 03 Mandatory-approval tools Ask even under bypass 04 Bypass short circuit Default for Web chat 05 · 06 Rule allow Read-only allowlist 07 acceptEdits Plus path safety checks 08 Auto classification LLM risk assessment 09 Blocking approval card question.asked · once / always Current default mismatch: PermissionMode defaults to ask, while Web chat defaults to bypass and spawned sub-agents use hardcoded bypass in sub_agent_run.py. Authority uses a two-value authority_tier enum: TIER_CAPABILITIES defines access, not request lists. Missing/unknown tiers deny all. External callers use the lowest tier.

04Cancellation and errors: unified at the MCP boundary

CANCELLATION

Per-turn tokens; two entry points have different semantics

openprogram/agent/run_control.py
Model
Per-turn CancelToken, never per-session: threading.Event plus the retired flag; a stop arriving after a turn ends cannot affect the next turn.
Global hook
Install at import: add_pre_invocation_hook and set_cancellation_check; each Agent method entry and each Runtime.exec aborts.
Unified entry point
Cancellation accepts only the canonical 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.
ERROR

is_error is a first-class field in all three layers

openprogram/programs/_runtime.py · agent/types.py
Tool layer
ToolResult has a first-class is_error: bool.
Framework layer
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.
Event layer
AgentEventToolEnd has a first-class is_error.
Concrete payload
is_error is a top-level typed bit;details retains only fixed diagnostic fields such as reason_code, denied, timeout .
Existing contract tests: 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.
LAYER 2

How others work

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.

05Nine-framework comparison

FrameworkMCP serverACP (agent side)Inbound HTTP / webhookClient SDK
claude-codeNone (snapshot contains only BashTool/)NoneNoneNone
claude-code-leakedclaude mcp serve, stdio, All built-in tools, empty permission contextNoneclaude server, automatically generates sk-ant-cc-* bearerAgent SDK uses the control protocol
codex-clicodex mcp-server, stdio, Only codex / codex-replyNone; uses its own app-serverapp-server: stdio / WS / unix, token file / SHA-256 / JWTTS @openai/codex-sdk + Python
openclawThree stdio servers plus MCP-over-HTTP (bearer + loopback)openclaw acp, through GatewayGateway HTTP API + HMAC webhook plugin@openclaw/sdk (private)
opencodeNone — client onlyopencode acpopencode serve, HTTP Basic; Only warns when no password is set@opencode-ai/sdk, generated from OpenAPI
hermes-agenthermes mcp serve, FastMCP stdio, Ten toolshermes acp plus ACP registry entriesHMAC webhook adapter + bearer OpenAI-compatible APINone — integrators use MCP / ACP / HTTP directly
pi-aiNoneNoneNoneNot applicable; it is a provider client library
pi-monoNone; README explicitly states "No MCP."NoneNone; only in-process pi.send()Package for embedding only
weclawNoneOnly ACP client, automatically approves every permission requestPOST /api/send, No authenticationNone
OpenProgramopenprogram mcp serve, authenticated stdio, six fixed wrappers, empty allowlist by defaultopenprogram acp, stdio; existing session, permission, and event mappingsInternal Web routes, owner token/cookie authenticationNo dedicated SDK; uses the standard MCP SDK

06Four protocol approaches and their tradeoffs

In-protocol approval · push

codex: elicitation/create

exec_approval.rs · patch_approval.rs
Mechanism
Push exec and patch approvals back to the MCP client with custom correlation field threadId, codex_elicitation, codex_mcp_tool_call_id, codex_event_id, receiving ReviewDecision.
Tradeoff
Low latency, butrequires client elicitation support; the source itself notes that this payload does not yet conform to ElicitResult (missing action / content).
In-protocol approval · pull

openclaw / hermes: poll a pair of tools

permissions_list_open · permissions_respond
Mechanism
Expose pending approvals as data through two tools, with decision allow-once | allow-always | deny. openclaw also supports push through the experimental capability bit claude/channel/permission .
Tradeoff
Works with generic clients without elicitation support, providing a compatible fallback at the cost of another round trip and the polling interval.
Cancellation

Only codex implements it fully

notifications/cancelled · active_turn_registry.rs
codex
Look up the turn in the active-turn registry by request id and stop routing events to it;tasks/cancel explicitly reports unsupported.
openclaw
The plugin-tools server passes the SDK’s extra.signal into callTool(params, signal).
Others
claude-code-leaked creates a new AbortController for every call butnever connects it to notifications; hermes has no cancellation.
Progress and errors

No standard progress usage; error handling has converged

notifications/progress · isError
Progress
codex only logs received notifications/progress ; its own streaming uses custom notification shapes.None of the surveyed frameworks emits standard progress externally.
Errors
All return {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.
Two negative examples: weclaw’s 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.
LAYER 3

Implemented minimum capabilities

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.

07Six tools, each mapped to one capability

codex uses two tools, hermes ten, and openclaw nine. Exposing every tool, as claude-code-leaked does, is deliberately excluded. We use six.

ToolPurposeCapabilityHost side effects
sessions_listList session ids, titles, and update timesreplyNone
session_getRetrieve one session’s messagesreplyNone
prompt_sendStart a turn in a session and return its result (session id optional)replySession append only
prompt_cancelCancel an in-flight turn started by this callerreplyNone
tools_listList tools and schemas allowed by the caller’s tierreplyNone
tool_callInvoke one tool by nameDepends on the toolUses the allowlist and approval stages
Excluded capabilities: no equivalent of 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.

08Allowlist first, tier second, approval stages last

Three sequential gates; failure at any gate prevents execution. The allowlist filters exposure and does not replace approval.

Each external tool_call must pass three gates in order Downward red dashed lines indicate rejection exits; each rejection has its own protocol response. External MCP client tool_call(name, args) Startup token validated Gate one · exposure allowlist mcp_server.exposed_tools Empty unless owner-allowlisted Gate two · authority_tier Paired tier ∩ exposure allowlist Interaction is never interactive Gate three · existing approval stages permission_mode fixed to ask; not configurable per request Calls requiring approval are denied without a local owner present JSON-RPC error, treated as unknown tool name Caller learns nothing about unauthorized tools isError: true Body identifies the missing capability isError: true, with rejection reason Neither silent approval nor indefinite blocking source=mcp is non-interactive; hard constraints precede all three gates. Risky tools and out-of-root writes are always denied; no question may block awaiting an answer.

External callers occupy the lowest-authority row in the sandbox designrequest-source table:

Request sourceprincipalauthority_tierMissing-field handling
Authenticated local Web / CLI / TUIprincipal_id=owner, interaction=interactiveowner, holding approval.requestThe entry point must construct it explicitly; invalid authentication is rejected
Paired channel accountInstance owner; speaker stored separatelypaired, holding reply and memory.source.appendMessages without a tier are rejected without downgrading
External MCP clientInstance owner; client id stored separatelypaired intersected with the exposed-tool allowlistReject connection if identity is missing or fails validation
continuation / subagentExplicitly inherit ownerInherit the caller’s tier unchanged; never increase authorityMissing fields are a state error: deny
Cron triggerOwner approved at creationJob capability fixed at approvalDo not recompute as interactive at trigger time

09Authentication: validate at stdio startup, with no in-protocol token

TOKEN

Generate once; constant-time comparison

<state_dir>/mcp_server_token (0600) ↔ OPENPROGRAM_MCP_TOKEN
Generation
openprogram mcp token create atomically writes the token file and prints it once;serve never implicitly generates a token.
Validation
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.
Protocol boundary
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.
Authority semantics
The environment credential permits only a low-authority stdio connection. Every request maps to fixed paired and never receives owner authority.
TRANSPORT

stdio as the only transport

openprogram mcp serve · module openprogram/mcp/server/
Why
Every surveyed implementation supports stdio first; itinherits the trust boundary of the launching process and does not expose the internal full-owner Web API as an integration surface.
Specification baseline
The first version pins the repository’s current mcp 1.29.0 and protocol 2025-11-25; the SDK negotiates compatible versions without custom handshake fields.
HTTP
After the stdio contract stabilizes, design Streamable HTTP, OAuth, Origin validation, and deployment lifecycle separately, without reusing the Web owner token.
Module location
A top-level module alongside 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.

10Cancellation, progress, and errors follow the MCP specification

Protocol messages map to existing mechanisms rather than introducing new ones notifications/cancelled Look up session_id and token by MCP request id Existing per-turn CancelToken Stop effects; clear request/questions; audit The cancel parameter of execute Wire cancel sets the same request event notifications/progress Send only if the client supplied progressToken The on_update callback of execute Turn-level: tool start and tool finish; no token-level streaming No token: discard callbacks Progress is optional for correctness
ConditionResponseReason
Unknown tool nameJSON-RPC error, method levelProtocol failure
Arguments fail schema validationJSON-RPC error, invalid paramsProtocol failure
Missing or incorrect startup environment tokenNonzero process exit before the MCP lifecycleAuthentication precedes stdin reads; errors go only to stderr
Tool is not allowlistedJSON-RPC error, treated as unknown tool nameDo not reveal unauthorized tools’ existence
Tier lacks the capabilityisError: true, body identifies the missing capabilityTool exists but is unavailable for this call
Approval denied or unavailableisError: true, retaining only a fixed gate reasonDeny without an owner present; do not pass through arbitrary tool details
Tool executed and failedisError: true, fixed sanitized body and reason codePreserve typed errors without leaking exceptions, traces, or tool-reported reasons across the boundary
External SDK cancels an in-flight requestErrorData(code=0, message="Request cancelled", data=None); the application returns no tool resultSDK 1.29.0’s notifications/cancelled owns the response; OpenProgram only stops, cleans up, and audits
Ordinary prompt_cancelActive turns on the same connection return cancelled=true; foreign/completed returns falseThis is one of the six wrappers, not the SDK cancellation notification
Completed shared-type revision: 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.
Acceptance-test structure: 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.
LAYER 4

Ideal state

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.

11Three gaps and why they remain outside scope

EXISTING · OUT OF SCOPE

Existing ACP stdio server

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.
No expansion in this iteration: do not change ACP transport, protocol capabilities, or authorization semantics, or merge MCP tokens, allowlists, or tools into ACP. Run existing ACP regression tests when revising shared Runtime types only.
DEFERRED · WEBHOOK

Inbound webhook

hermes demonstrates the required controls: route-specific HMAC secrets validated at startup, per-route rate limits, an idempotency cache against replay, and body-size checks before reading. These are minimum requirements. The gap is that a webhook is apush trigger with no owner present on arrival, placing it in the same category as cron triggers: it requires a job capability approved and fixed at registration, not a tier calculated on delivery.
Not implemented now: the prerequisite is the fixed job capability from sandbox batch I and step 05B, handled separately. MCP already covers pull-based integration, for which there is a concrete requirement.
DEFERRED · SDK

Client SDK

codex provides TS and Python, opencode generates TS from OpenAPI, openclaw provides a private TS package, and claude-code-leaked provides an Agent SDK. hermes provides no SDK, letting integrators use MCP / ACP / HTTP directly. An SDK is a compatibility commitment, while 198 routes and approximately 100 actions remain an internal surface changing with the frontend; only two have fixed shape assertions.
Not implemented now: the order is deliberate — implement MCP first, because for every language with an MCP SDK, that SDK isthe client library. This is why hermes can be integrated without a dedicated SDK. A dedicated SDK is justified only when actual integrations prove MCP tools too coarse.
Where the stable surface is: publish only the six MCP tools above in this iteration; ACP retains its existing contract, while webhooks and a dedicated SDK remain unpublished. FastAPI and WebSocket remain internal surfaces, do not become MCP transports, and gain no HTTP, OAuth, or Web routes.

12Implementation status

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.

ItemStatusImplementation and evidence
openprogram mcp serve stdio serverImplementedopenprogram/mcp/server/server.py; authentication completes before entering SDK stdio
Minimum set of six toolsImplementedsessions_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 defaultImplementedDiscovery and execution each compute configured allowlist ∩ live registry ∩ paired capability
mcp token create + stdio startup environment authentication, with no disable switchImplementedIndependent 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 rowImplementedFixed paired, speaker_kind=client, interaction=non-interactive; clientInfo does not participate in authentication or authorization
source=mcp non-interactive + hard-constraint gateImplementedDangerous 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 auditImplementedExact 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 ImplementedOnly 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_errorImplementedRuntime 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 testsImplementedReal 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 serverAlready exists; not expanded hereapps/cli/python/openprogram_cli/_impl/commands/acp.py and openprogram/acp/server.py; shared-type regressions only
Inbound webhookOutside this iterationDepends on fixed job capabilities from sandbox batch I and step 05B
Client SDKOutside this iterationThe 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.
Reviewed implementation evidence: implementation baseline 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.
Directory ownership cleanup: 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.
Explicit exclusions: this implementation adds no HTTP, SSE, Streamable HTTP, OAuth, Web route, Web owner-token reuse, ACP transport/contract expansion, dedicated client SDK, or expanded MCP SDK dependency range. The external protocol surface remains authenticated stdio and the six wrappers above.
The current Web boundary is not the MCP server entry point: 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.