Skip to content

Serve the deploy tools on the hosted server, with the bundle uploaded on a ticket - #243

Draft
raymondk wants to merge 1 commit into
deploy-4-streamingfrom
deploy-5-hosted
Draft

raymondk wants to merge 1 commit into
deploy-4-streamingfrom
deploy-5-hosted

Conversation

@raymondk

Copy link
Copy Markdown

Stacked on #242 (deploy-4). The hosted composition: the deploy tools on the MCP endpoint of the imcp2 binary, behind a deploy cargo feature.

What

  • WithDeploy<H> (crates/imcp2-deploy/src/server.rs): the handler both servers mount, moved out of imcp2-local. It serves an inner handler's tools with the deploy tools beside them, the deploy skill next to the inner resources, and appends the DEPLOYING paragraph to the instructions, phrased from what the deploy tools know (how the bundle arrives, how many deploys run at once). LocalServer now wraps WithDeploy<IcTools> and keeps only its login tools.
  • src/deploy.rs in the gateway crate, under #[cfg(feature = "deploy")], with McpServer::with_deploy(DeployConfig); the binary reads MCP_DEPLOY_MAX_CONCURRENT (default 2) and MCP_DEPLOY_MAX_UPLOAD_BYTES (default 64 MiB).

How the bundle arrives

A tool call carries only what the model writes, and the agent's machine cannot hand this server a file. So:

  1. icp_deploy_upload (listed only on a server with an upload store) mints a ticket inside the authenticated call and returns the upload URL, the ticket and the exact curl -T … -H 'x-deploy-upload-ticket: …' line.
  2. The agent runs it. PUT {mcp_path}/deploy/upload sits outside the bearer gate (the ticket is its authentication), streams the body to disk under the cap, refuses a declared length over it unread, and answers 201 with the upload_id.
  3. icp_deploy_bundle takes bundle_upload; the archive is removed with the deploy.

Ticket properties (crates/imcp2-deploy/src/uploads.rs): 256 random bits; spent before a byte of the body is read; five-minute TTL; one pending ticket per session, minting another drops the previous; bound to the minting session, so the upload is deployable by that session alone; sent in a header, not the path, so it stays out of Caddy's access log. An upload waits fifteen minutes for its deploy; expired tickets and uploads are swept on each store access. Files are 0600 under a 0700 directory at {state_dir}/deploys/uploads.

How the deploy call is answered

The endpoint answers a POST with plain JSON, the one message the handler returns; in rmcp's JSON mode a notification emitted before the result would be returned as the result. So the dispatch (fallback of the gated router) reads the JSON-RPC method and tool name off the buffered body and hands a tools/call of icp_deploy_bundle to a second StreamableHttpService configured for SSE responses, still stateless; everything else keeps the JSON service, so existing clients see no change. Both services share one handler factory and one upload store.

A client that goes away

The SSE response body is wrapped (cancel_on_drop) so that its drop, which is what a disconnect does on the server, cancels a token placed in the request's extensions as ClientGone. The deploy tool ties that token to its stop (link_client_gone), for as long as the deploy runs. The deploy stops at its next step and keeps its directory as the record, as #242 made it do for a cancelled call.

Concurrency

IcDeployTools::concurrent(n): a semaphore of n slots across sessions (claimed before the session is even resolved, so a refusal costs nothing) plus one deploy per session. Past either the call is refused, not queued. Local keeps 1.

Tests

  • uploads.rs: ticket lifecycle, session binding, caps declared and undeclared, nothing kept of a refusal, re-minting.
  • src/deploy.rs: the whole loop over the composed router with a minted test token: tools/list stays JSON and lists the deploy tools; icp_deploy_upload returns a ticket; the PUT is refused without a ticket (401), on a wrong one (403), over the cap (413), accepted once (201) and spent; the deploy call is answered as text/event-stream carrying the result, the upload is found for the session and spent by the deploy; an unauthenticated deploy call still gets the 401. Plus cancel_on_drop fires on body drop, and the dispatch predicate.
  • cargo test --workspace (feature off) and cargo test -p imcp2 --features deploy both pass; clippy clean both ways. CI gets a step for the feature; deploy/native/build.sh builds with it.

Caveats

  • Publishing. imcp2-deploy pins icp-cli crates by git, which crates.io refuses, so cargo publish of imcp2 (and publish-crate.yml's dry run) will fail while the optional dependency exists. That waits on the icp CLI's crates being published, as agreed.
  • Plugin memory. The wasmtime store still has no memory limiter; that is the icp-cli change (store limit plus the host-call time refund fix), to be pinned once it exists. Until then a crafted bundle's plugin can grow to the process's MemoryMax.
  • Caddy's read_body 30s applies to the upload PUT; the native README says what that means for the cap.
  • No deploy metrics yet.
  • AuthStore::test_bearer is the same cfg(test) helper Cap the MCP request body behind the bearer gate #241 adds; the hunk is identical, so the rebase once Cap the MCP request body behind the bearer gate #241 lands should be clean.

🤖 Generated with Claude Code

… on a ticket

The hosted server could not deploy: the tool lived in the local binary
alone. Now, behind the gateway crate's `deploy` feature, the MCP endpoint
serves imcp2-deploy's tools beside the core surface, through the one
handler both servers mount (`WithDeploy`, moved out of imcp2-local).

Three things the hosted shape needs that the local one did not:

How the bundle arrives. The agent's machine cannot hand this server a
file, and a tool call carries only what the model writes, so
`icp_deploy_upload` mints a one-time ticket inside the authenticated
call and returns the curl command that PUTs the archive to
`{mcp_path}/deploy/upload`. The ticket is 256 random bits, single use,
good for five minutes, bound to the session that minted it (the upload
it produces is deployable by that session alone), and sent in a header
so it stays out of proxy logs. The route is outside the bearer gate (the
ticket is its authentication) and streams the body to disk under a cap,
a declared length over it refused unread. `icp_deploy_bundle` takes the
upload id as `bundle_upload`; the archive goes with the deploy.

How the deploy call is answered. The endpoint answers a POST with plain
JSON, the one message a handler returns, and a deploy reports each phase
before its result. A `tools/call` of `icp_deploy_bundle` is therefore
dispatched to a second rmcp service configured for Server-Sent-Events
responses, still stateless; every other request keeps the JSON service.

A client that goes away. The event stream's body is wrapped so that its
drop cancels a token the deploy watches (`ClientGone`), and the deploy
stops at its next step, keeping its directory as the record.

Also: one deploy per session plus a server-wide cap (`concurrent`,
default 2 hosted, 1 local), refused past it rather than queued; deploy
directories and uploads under `{state_dir}/deploys`; the knobs
`MCP_DEPLOY_MAX_CONCURRENT` and `MCP_DEPLOY_MAX_UPLOAD_BYTES`; the native
build turns the feature on and CI tests it. The feature keeps `cargo
publish` of imcp2 from working until the icp CLI's crates are published.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

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.

1 participant