Skip to content

feat(acp)!: own stateless MCP operations and bound transports - #376

Draft
benbrandt wants to merge 8 commits into
mainfrom
work/mcp-over-acp-stateless
Draft

benbrandt wants to merge 8 commits into
mainfrom
work/mcp-over-acp-stateless

Conversation

@benbrandt

@benbrandt benbrandt commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Draft: MCP 2026-07-28 only, with major-version API changes

Merged companion schema/RFD: agentclientprotocol/agent-client-protocol#2223

Builds on merged rmcp upgrade #372 and supersedes the connection-oriented checkpoint #375. This transport has no legacy MCP compatibility mode.

Implementation

  • Server-addressed, request-scoped MCP calls and notifications; remove the MCP connect/disconnect lifecycle and reverse requests.
  • Explicit inner MCP result/error carriers, distinct from outer ACP binding failures. Preserve MCP codes, null data, and extensions without interpreting them as ACP authentication errors. Traces retain error-domain provenance.
  • Reusable request-native services. The rmcp application service is initialized lazily once; each request owns execution, cancellation, notifications, and supervised cleanup.
  • Scoped mutable and concurrent tools acknowledge actual closure cleanup before the logical request ID and admission are released. Queued cancellation does not poison the runner.
  • Clone-safe bounded frame/application queues, shared byte charges through forwarding and deferred work, retained response and pending-request metadata charges, and cancellation-safe capacity waits. Imported frames must meet destination limits.
  • Explicit owned versus passive connection drivers preserve half-close response draining without waiting for an unrelated remote sender. Cancellation that overtakes an unpublished request is settled locally rather than followed by publishing uncancelled work.
  • One loopback HTTP listener per ACP connection with server-bound HMAC bearer credentials, no per-server route allocations, validation before bounded body reads, and admission owned through response-body consumption/drop.
  • Native-tool HTTP re-export strips only schema-position transport header annotations. Direct calls make no hidden tools/list requests; standard routing/version headers and endpoint authentication remain enforced.

Latest cancellation and ownership fixes (ae1cfac)

  • Advisory cancellation: an HTTP cancellation POST acknowledgment no longer discards the original request's method, byte lease, or response route. A later successful session/new or session/fork response still opens its session stream. Pending metadata remains bounded until a real terminal response, transport failure, or teardown. A peer that never responds continues to occupy pending capacity; HTTP202 is not an operation-completion signal.
  • Consistent control classification: canonical bare and successor-wrapped cancellation use the shared core classifier for HTTP scoping and ordered-POST bypass. Cancellation/response-only control batches bypass; mixed data batches do not. A real wrapped-cancel POST unblocks a pending session POST without acquiring a session header.
  • Atomic connector ownership: one protected task is admitted before invoking the factory, then owns backend execution, response forwarding, teardown, scoped cleanup, and logical-ID release. This removes the two-task partial-admission path. Low-capacity and completion regressions cover one free slot, no free slots, ID reuse, backend destruction before response, accepted terminal output preceding driver error, late-notification suppression, and escaped sender handles.

The HTTP fork regression uses a second serialized control POST as an acknowledgment barrier, not a timing sleep. Focused review found no remaining blocking regression in these fixes. All local validation below passes; GitHub CI must confirm this checkpoint before merge.

Earlier raw-error preservation, fatal HTTP overflow teardown, transactional POST admission, and protected connection-shutdown fixes remain in place. No additional public API or protocol-schema change was required for this checkpoint.

Review order

  1. Migration guide and companion wire-schema PR.
  2. Native service contract and cleanup regression.
  3. Transport ownership, queue admission, and transport-close tests.
  4. HTTP re-export contract and real rmcp HTTP coverage.

Verification

All Cargo validation used disabled incremental caching and stripped dev/test debug info. On this checkpoint:

  • Full just test passed, including integration tests and doctests.
  • Strict cargo clippy --workspace --all-targets --all-features --locked -- -D warnings passed with no temporary lint allowances.
  • All eight CI feature-powerset partitions passed locally with warnings denied (66 feature configurations across the workspace).
  • Rust 1.88 cargo check --workspace --all-targets --all-features --locked passed with warnings denied.
  • Strict core Clippy passed for wasm32-wasip1, wasm32-wasip2, and wasm32-unknown-unknown with all features.
  • Native real-rmcp example ran successfully.
  • Formatting, mdbook build, and git diff --check passed. The book tool emitted only its installed preprocessor-version compatibility warning.

Release gates — keep draft

  • The companion protocol PR is merged. Replace Git-pinned schema revision e5c36d2671fd355f983533bc83b5feb7981d25a6 with the released matching schema before package publication.
  • Coordinate dependent SDK major releases. The commit and PR are marked breaking; this PR does not publish packages or manually finalize release versions.
  • Limits account for SDK-owned serialized payloads and admitted work, not every allocation inside user code or the network stack. The SDK supervises supported cancellation and waits for owned cleanup; detached application work cannot be forcibly terminated. These safeguards do not add a cancellation support requirement to the transport capability.
  • This is native-tool re-export, not preservation of another HTTP gateway's parameter-header authorization policy. It is not a claim of complete MCP/HTTP conformance for every optional feature.

stream.write_all(request.as_bytes()).await.unwrap();
let mut response = String::new();
stream.read_to_string(&mut response).await.unwrap();
assert!(response.starts_with("HTTP/1.1 200"), "{response}");
stream.write_all(request.as_bytes()).await.unwrap();
let mut response = String::new();
stream.read_to_string(&mut response).await.unwrap();
assert!(response.starts_with("HTTP/1.1 200"), "{response}");
Comment thread src/agent-client-protocol-polyfill/src/mcp_over_acp/http.rs Fixed
Comment thread src/agent-client-protocol-polyfill/src/mcp_over_acp/http.rs Fixed
Comment thread src/agent-client-protocol-polyfill/src/mcp_over_acp/http.rs Fixed
Comment thread src/agent-client-protocol-polyfill/src/mcp_over_acp/http.rs Fixed
.to_string();
let headers = "Authorization: Bearer secret\r\nAccept: application/json, text/event-stream\r\nContent-Type: application/json\r\nMCP-Protocol-Version: 2026-07-28\r\nMcp-Method: wrong/method\r\n";
let mismatch = exchange(address, "POST", headers, &body).await;
assert!(mismatch.starts_with("HTTP/1.1 400"), "{mismatch}");
Comment thread src/agent-client-protocol-polyfill/src/mcp_over_acp/http.rs Fixed
assert!(mismatch.starts_with("HTTP/1.1 400"), "{mismatch}");
assert!(mismatch.contains("-32020"), "{mismatch}");
let batch = exchange(address, "POST", headers, "[]").await;
assert!(batch.starts_with("HTTP/1.1 400"), "{batch}");
Separate MCP outcomes from ACP failures, reuse request-native services, join tool cleanup before releasing admission, and bound retained frames, replies and HTTP response bodies. Preserve passive transport half-close semantics and test real rmcp HTTP workflows.

BREAKING CHANGE: Channel now carries budgeted frames, ConnectTo returns ConnectionDriver, and native MCP uses request-scoped services with explicit outcome carriers. Coordinate the schema and dependent SDK major releases before publishing.
@benbrandt benbrandt changed the title feat(acp): implement stateless MCP-over-ACP feat(acp)!: own stateless MCP operations and bound transports Sep 25, 2026
let task = tokio::spawn(run_http_listener(listener, state));
let auth = format!("Authorization: Bearer {token}\r\n");
let legacy = exchange(address, route, "GET", &auth, "").await;
assert!(legacy.starts_with("HTTP/1.1 405"), "{legacy}");
let legacy = exchange(address, route, "GET", &auth, "").await;
assert!(legacy.starts_with("HTTP/1.1 405"), "{legacy}");
let delete = exchange(address, route, "DELETE", &auth, "").await;
assert!(delete.starts_with("HTTP/1.1 405"), "{delete}");
exchange(address, route, "POST", "Origin: http://evil.test\r\n", "{}").await;
assert!(
invalid_origin.starts_with("HTTP/1.1 403"),
"{invalid_origin}"
exchange(address, route, "GET", "Origin: http://evil.test\r\n", "").await;
assert!(
invalid_get_origin.starts_with("HTTP/1.1 403"),
"{invalid_get_origin}"
"{invalid_get_origin}"
);
let invalid_auth = exchange(address, route, "POST", "", "{}").await;
assert!(invalid_auth.starts_with("HTTP/1.1 401"), "{invalid_auth}");
invalid_auth
.to_ascii_lowercase()
.contains("www-authenticate: bearer"),
"{invalid_auth}"
);
let mismatch = exchange(address, route, "POST", &headers, &body).await;
assert!(mismatch.starts_with("HTTP/1.1 400"), "{mismatch}");
assert!(mismatch.contains("-32020"), "{mismatch}");
Cover outer-id discrimination and separate v1/v2 method enums, and clarify that the binding adds no stronger cancellation support requirement.
Keep cancellation observable through readiness gates, bound all live tasks and frame sink reservations, and join protected MCP cleanup through connection termination. Repair cold HTTP session setup, release consumed output permits, abort rejected WebSocket input, and retain measured session metadata. Add regressions for the review findings and fix the minimal-feature build.
Keep raw JSON-RPC errors protocol-neutral so connector-backed MCP preserves explicit null and extension fields. Reserve HTTP queue capacity before publishing metadata and tear down idle agents after fatal router failure. Add permanent regressions for all three review findings.

BREAKING CHANGE: RawJsonRpcMessage::Response now carries RawJsonRpcResponse with a boxed RawJsonRpcError instead of an ACP-specific schema response. Typed ACP request consumers retain the existing Error API.
Keep bounded HTTP response context until a terminal reply or teardown, and consistently bypass ordered POSTs for wrapped cancellation. Admit each connector operation as one protected task before construction, retaining ownership through backend shutdown and scoped cleanup. Add deterministic late-success, low-capacity, terminal-drain, and escaped-sender regressions.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants