MCP Fusion/Protocol and runtime/Runtime architecture
Runtime architecture
How MCP Fusion actually runs: build-time compilation, O(1) request routing, per-request context, the Result monad, the execution pipeline and the transports.
This page describes the engine underneath the fluent API documented in Tools. It is the deep technical reference for the request lifecycle, compiled in packages/core.
Two layers: build once, serve many
MCP Fusion separates the work that belongs at build time from the work that must happen on every request.
Build time. registry.register(builder) calls builder.buildToolDefinition() immediately. That compile pass produces everything the server needs for the life of the process:
- the merged input schema, with the discriminator field and per-action required annotations
- the
<tool_error>-ready validation schemas, onestrictZod schema per action - the pre-compiled middleware chain, one closure per action, global middleware outermost and per-action middleware innermost
- the action map, an O(1)
Mapfrom action key to compiled context
After buildToolDefinition() the builder is frozen: Object.freeze on the actions, and a guard on every mutating method. You cannot change a built tool by accident. mergeActions() is the only sanctioned way to add actions, and it explicitly unfreezes to rebuild.
Request time. The runtime path is fixed and has no assembly step:
contextFactory → discriminator parse → action resolve (Map)
→ arg validation (cached strict schema) → middleware chain (precompiled)
→ handler → Presenter (postProcess) → guards → responseEvery step reads from a precompiled structure. There is no schema rebuilt, no chain composed, no lookup scanned on the request path.
Registry, tool names and actions
A dotted builder name is not a flat tool name. f.query('billing.get_invoice') registers the action get_invoice on the tool billing. Two names with a dot at build time is an error; nesting uses f.router('support.tickets'). Builders that share a namespace merge: three files exporting compliance.scan, compliance.report and compliance.status become one compliance tool with three actions, so you can organize a connector by directory and the agent still sees one coherent surface.
contextFactory: the per-request entry
attachToServer(server, { contextFactory }) runs contextFactory(extra) once per request, before routing. extra is the raw MCP SDK request context: the session id, the _meta object (progress token), the AbortSignal for cancellation and the sendRequest channel for elicitation. Return a fresh object; the framework mutates it in place as middleware enriches it. This is where database handles, tenant ids and the abort signal enter ctx, so a stateless deployment has no cross-request leakage by construction.
If you omit contextFactory, touching ctx throws an explicit error instead of failing mysteriously.
The transports
startServer() supports three transports:
| Transport | Session | Use |
|---|---|---|
stdio | none | local clients, the desktop apps, mcpfusion dev |
http | UUID session id, TTL reaper, body cap, per-session token bucket | a persistent HTTP MCP server |
stateless | none | MCP 2.0 mode: one fresh Server per request, behind any load balancer |
Stateless is the scaling default: no initialize handshake, requests are routed by the Mcp-Method and Mcp-Name headers, and Vercel and Cloudflare adapters run JSON responses with no SSE and no session state. The Vinkius Edge uses this model: your StartServerOptions.state is serialized before the isolate is disposed and restored transparently on the next cold request.
Result monad: control flow without exceptions
Internally the pipeline is Result-based: Success or Failure, each step short-circuits on Failure. Handlers do not see Result; they return data or succeed()/toolError(). What Result buys is predictability: validation, discriminator parsing and dispatch return values, not thrown exceptions, so nothing is lost in a stack trace. A thrown ToolResponse passes through untouched (so throw toolError('NOT_FOUND', ...) works); any other throw is wrapped as INTERNAL_ERROR with a "do not blindly retry" recovery.
Guards around the handler
Three independent guards wrap execution and are zero-cost when not configured:
- Concurrency: a semaphore with a bounded queue. Over capacity returns
SERVER_BUSYwith a retry hint instead of degrading. - Mutation serialization: a destructive action is automatically given a per-action FIFO mutex, so two agents cannot race the same write.
- Egress limit: a byte budget on the response. Oversized text is truncated with a system message telling the model to paginate. Structured
structuredContentis preserved;isErroris never flipped.
See Security pipeline for the input-side guards and State sync for cache and staleness signals.
Observability hooks
registry.enableDebug(observer), enableTracing(tracer) and enableTelemetry(sink) propagate to every builder through the same duck-typed interface. Each hook is inert until set: no timer, no wrapper, no allocation when a subsystem is silent. The same three sinks power mcpfusion inspect, the OTel pipeline and the lockfile's middleware coverage evidence.
Next steps
- Security pipeline: the firewall and its layers
- State sync: cache directives, invalidation, subscriptions
- Testing: exercise this pipeline in memory
