MCP Projection
mcp_projection, /mcp Streamable HTTP, schema synthesis, McpPathShim, mcp_exclusive
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.
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 source | JSON Schema fragment | Where |
|---|---|---|
wire_type TEXT / BOOL / ACTION / IMAGE / METRICS | string / boolean / integer / string (base64 PNG) / object | _WIRE_JSON_TYPE |
wire_type DEPTH / POSE / ANY | unconstrained {} | _input_port_property |
config select | enum of option values + default | _config_field_property |
config slider | number + minimum / maximum / multipleOf | |
config toggle | boolean | |
config text / textarea | type 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):
- Route arguments — the model's flat kwargs split into port inputs vs config by name; unknown keys are dropped (
split_args). - Deserialize inputs — per input port's wire_type, the JSON regime of
serialization.deserialize_value(base64 PNG → ndarray for IMAGE,__ndarray__markers restored elsewhere). - Invoke the registered handler — same arity contract as
/call: 3-parameter (auto-generated) handlers getexec_ctx=None; MCP calls never carry cross-nodeset container grants. Buffered events are flushed at call granularity (event_push.flush), matching/call. - Serialize outputs — per output port via
serialize_value(JSON regime), then mapped to MCP content byoutputs_to_content: each IMAGE-port string becomes oneImageContent(base64 PNG, no re-encode); everything else is compacted —__ndarray__markers reduced to dtype/shape stubs (_compact) — into one trailingTextContentJSON.
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
- Non-exclusive (stateless model tools): the session manager runs
stateless=True— every request gets a fresh transport, concurrent clients are fine. - Exclusive (stateful envs): sessions are tracked (
stateless=False), and at most one may be live. A POST without anmcp-session-idheader — the initialize path — while a live session exists is answered409with a readable JSON-RPC error (mcp_projection.py · _reject_second_session). The lock releases on the client's DELETE or on the idle reaper (EXCLUSIVE_IDLE_TIMEOUT_S= 600 s), so a crashed client cannot hold the simulator forever.
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
{}), and containers, display fields, and output-port typing are not projected into MCP at all. Canvas-grade information lives only in Projection A._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.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.git show 68a2f0b^:coding-agent/bridges/nodeset_mcp.py), but is not shipped in-tree._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
| File | Role |
|---|---|
agentcanvas/backend/app/server/mcp_projection.py | Schema synthesis, in-process tools/call, McpPathShim, session gate — the whole projection |
agentcanvas/backend/app/server/server_app.py | Conditional build + shim registration in _build_app; manager lifecycle in _startup/_shutdown |
agentcanvas/backend/app/server/auto_server_app.py | mcp_exclusive resolution (explicit > parallelism-derived) |
agentcanvas/backend/app/server/auto_host.py | --mcp-tools allowlist flag |
agentcanvas/backend/app/components/bases.py | BaseNodeSet.mcp_exclusive ClassVar |
agentcanvas/backend/app/server/test_mcp_projection.py | 12 tests: synthesis, tools/list ↔ manifest reconciliation, call roundtrips, allowlist, session gate, additivity |