AgentCanvas / Pages / Developer Guide / Design Docs / Operations / MCP Projection
2026-08-13

The MCP projection makes every server-mode nodeset an MCP server: when the mcp SDK is importable in the server's env, the same introspection that feeds GET /manifest also mounts a native /mcp Streamable HTTP endpoint, so Claude Code, codex, or any MCP client calls the nodeset's tools with zero per-tool code and zero node-class changes. Its hard promise: the projection is additive — with or without /mcp, the manifest protocol behaves identically (guarded by app/server/test_mcp_projection.py). How-to-use lives in Isolated Runtime Environments §3.2; the host it extends is Server Mode.

How the code does it — one introspection, two faces of the same process.

auto_host process (nodeset's server_python env) node class declarations node_type · PortDef in/out · ui_config · config auto_server_app.py · get_functions function registry + manifest server_app.py · _register_functions / get_manifest Projection A — manifest protocol GET /manifest · POST /call/{fn} · /health server_app.py · _build_app msgpack + __ndarray__ pass-through Projection B — /mcp mcp_projection.py · build_mcp_server lowlevel Server → StreamableHTTPSessionManager stateless | stateful + mcp_exclusive gate (409) McpPathShim — /mcp · /mcp/ backend registry → proxy nodes registry.py · generate_proxy_nodes MCP clients claude mcp add --transport http · codex no mcp SDK (py3.8 host) or mcp 2.x → Projection B absent, A untouched (server_app.py · _build_app guard)

1. What it does

The mental model (carried from the implementation plan): one source of truth, two projections. A nodeset's node classes already declare everything a tool surface needs — node_type, description, typed ports, config fields. Server mode has always projected those declarations once, into the framework-facing manifest protocol. The MCP projection adds a second, model-facing projection of the same declarations: each manifest function becomes an MCP tool with a synthesized JSON-Schema inputSchema, and tools/call executes the very handler /call would. Nothing is unified at the protocol level — the manifest keeps its msgpack channel and its superset of information; MCP is a second reading of the same source, generated, never hand-written.

2. The smallest instance

Two commands — no bridge process, no per-tool code, no node edits:

# 1. host any nodeset (env needs the mcp SDK: pip install "mcp==1.27.0")
python -m app.server.auto_host --file workspace/nodesets/model/model_sam.py \
    --class SamNodeSet --port 9200 --mcp-tools segment_auto

# 2. point any MCP client at it
claude mcp add --transport http sam http://127.0.0.1:9200/mcp

3. Schema synthesis

An MCP tool's inputSchema is a pure function of the manifest function dict: properties = input ports ∪ config fields, required = ports with optional=False, and a port wins a name collision with a config field (mcp_projection.py · build_input_schema).

Manifest sourceJSON Schema fragmentWhere
wire_type TEXT / BOOL / ACTION / IMAGE / METRICSstring / boolean / integer / string (base64 PNG) / object_WIRE_JSON_TYPE
wire_type DEPTH / POSE / ANYunconstrained {}_input_port_property
config selectenum of option values + default_config_field_property
config slidernumber + minimum / maximum / multipleOf
config toggleboolean
config text / textareatype inferred from the default (bool before int); label fields are skipped

The --mcp-tools flag (auto_host.py · main) is a comma allowlist matched against the full node_type or its __-suffix (mcp_projection.py · select_functions). It narrows only the MCP tool list — /manifest and /call are never affected.

4. The call path

tools/call is the /call route's semantics, in-process — no HTTP self-call. Steps (mcp_projection.py · build_mcp_server):

  1. Route arguments — the model's flat kwargs split into port inputs vs config by name; unknown keys are dropped (split_args).
  2. Deserialize inputs — per input port's wire_type, the JSON regime of serialization.deserialize_value (base64 PNG → ndarray for IMAGE, __ndarray__ markers restored elsewhere).
  3. Invoke the registered handler — same arity contract as /call: 3-parameter (auto-generated) handlers get exec_ctx=None; MCP calls never carry cross-nodeset container grants. Buffered events are flushed at call granularity (event_push.flush), matching /call.
  4. Serialize outputs — per output port via serialize_value (JSON regime), then mapped to MCP content by outputs_to_content: each IMAGE-port string becomes one ImageContent (base64 PNG, no re-encode); everything else is compacted — __ndarray__ markers reduced to dtype/shape stubs (_compact) — into one trailing TextContent JSON.

Failure shapes are model-readable, not protocol errors: a handler exception returns a structured {"error", "type"} TextContent; a FireList result (a graph-engine protocol an MCP client cannot dispatch) returns a structured refusal.

5. Session policy — mcp_exclusive

Whether concurrent MCP clients are safe is a property of the nodeset, so it is declared there: BaseNodeSet.mcp_exclusive (bases.py), resolved at server construction (auto_server_app.py · AutoServerApp.__init__) — an explicit True/False wins; None derives from the existing parallelism contract, because "replicated" is precisely the declaration that the nodeset is stateful (env scene state, simulator handles).

class MyEnvNodeSet(BaseNodeSet):
    parallelism = "replicated"  # stateful env -> derived mcp_exclusive=True
    mcp_exclusive = None        # None = derive; pin True/False to override

6. Mount and lifecycle

Mount. /mcp is dispatched by McpPathShim, a thin ASGI middleware added in server_app.py · _build_app — not app.mount, because Starlette's Mount 307-redirects the bare /mcp path to /mcp/, an extra round trip for redirect-following clients and a hard failure for ones that don't re-POST. The shim sends both spellings straight to the transport, which routes by HTTP method, not path. Being outermost middleware, /mcp traffic also bypasses the CORS layer — the surface is local-client only.

Lifecycle. StreamableHTTPSessionManager.run() must wrap the serving period. The startup hook enters it through an AsyncExitStack and the shutdown hook closes it; both handlers execute in the same lifespan task under Starlette's default lifespan, which is what makes entering an anyio task group in one and closing it in the other safe (server_app.py · _startup / _shutdown).

Degrade ladder. Both failure modes leave Projection A byte-identical: if import mcp fails (the pinned py3.8 hosts), a log line notes the projection is disabled; if the build itself raises (e.g. an incompatible SDK), it is caught, logged, and the server runs manifest-only.

7. Where it deviates from the mental model

The two projections are not equals — B is lossy. The model says "two projections of one source", but the manifest is the superset: wire-type semantics collapse to coarse JSON types (DEPTH/POSE/ANY are unconstrained {}), and containers, display fields, and output-port typing are not projected into MCP at all. Canvas-grade information lives only in Projection A.
Output ports flow verbatim — graph-sized payloads reach the model. _compact stubs only __ndarray__ markers; base64 carried in TEXT ports passes through untouched. Real case: model_sam__segment_points always returns its embedding envelope (≈5.6 MB base64) plus 256 KB logits per candidate — enough to overflow an MCP client's tool-output budget. The shipped mitigation is choosing context-sized tools via --mcp-tools (e.g. segment_auto, whose envelope is KB-scale); port-level output curation is a planned view layer, not built.
SDK bound: mcp 1.x only. The projection is written against the mcp 1.x lowlevel API (verified on 1.27.0). mcp 2.0 removed the Server.list_tools/call_tool decorators, so under 2.x the build fails and the server degrades to manifest-only. 2.x compatibility is not built.
py3.8 hosts have no /mcp. Nodesets pinned to py3.8 envs (the Habitat line) cannot import the SDK; they serve Projection A only. The out-of-process projector that would give them an equivalent MCP face exists as a verified spike recoverable from git history (git show 68a2f0b^:coding-agent/bridges/nodeset_mcp.py), but is not shipped in-tree.
The exclusion gate reads SDK internals. It counts live sessions as entries of the manager's private _server_instances dict whose transport is not is_terminated — necessary because the SDK deliberately keeps DELETE-terminated transports in that dict (only crashed sessions are evicted). An SDK upgrade can move this ground.

8. Key files

FileRole
agentcanvas/backend/app/server/mcp_projection.pySchema synthesis, in-process tools/call, McpPathShim, session gate — the whole projection
agentcanvas/backend/app/server/server_app.pyConditional build + shim registration in _build_app; manager lifecycle in _startup/_shutdown
agentcanvas/backend/app/server/auto_server_app.pymcp_exclusive resolution (explicit > parallelism-derived)
agentcanvas/backend/app/server/auto_host.py--mcp-tools allowlist flag
agentcanvas/backend/app/components/bases.pyBaseNodeSet.mcp_exclusive ClassVar
agentcanvas/backend/app/server/test_mcp_projection.py12 tests: synthesis, tools/list ↔ manifest reconciliation, call roundtrips, allowlist, session gate, additivity
AgentCanvas docs