Published Sep 21, 202611 min read
Building Your Own MCP Connector: Why MCP Fusion Is the Point
What sits between your data and an agent's perception, and why that gap is an architecture: the MVA split that closes the egress, the presenter that decides what an agent sees, self-healing errors, state the agent can feel, and a deploy that ships all of it as one hashable bundle.

By Renato Marinho
Founder · Vinkius
An MCP connector is a promise. You are promising a system that does not share your context, your history, or your intent that a call to billing.void_invoice means exactly what you meant it to mean. The database row is not the answer. It is raw material, and between it and the agent sits a layer of architecture that most hand-rolled servers skip.
An agent is stochastic by construction: it hallucinates parameters, misformats inputs, retries without thinking, and loses context between calls. A raw server treats each tool call as independent, and that single omission is what turns a working demo into a system that corrupts data, burns tokens, and fails in ways no one can trace. Sending raw JSON to an agent creates four structural failure modes, context starvation, action blindness, perception inconsistency, and security leakage, and these are deficits that no amount of prompt engineering can fix.
That is the thesis of this post. If the failure modes are structural, the fix has to be structural. MCP Fusion makes that fix with a pattern it calls MVA, and the pattern is a separation of responsibilities that application code already knows, just pointed at a different consumer:
- The Model owns what the data is and what may leave the process.
- The Presenter owns what the agent perceives about it.
- The Tools own the verbs: the queries, mutations, and actions.
One rule keeps the separation honest, and it is a security property, not a style rule: direction. Tools import presenters, presenters import models, models import the core, and nothing imports backwards. The layer that touches your data must never be the layer an agent can steer.
Model: where the wire ends
In a normal application, a schema validates input. In a connector, the schema has to close the output too, because the consumer on the other side of the wire is not a colleague reading code. It is a language model that will act on whatever it is handed. defineModel is where that boundary is drawn, and its four declarations do different jobs:
m.casts declares the fields, their types, and their descriptions. Those descriptions are not documentation for a human. They compile, just in time, into the interpretation rules the agent receives with each response. m.hidden declares the fields that never reach the wire: password hashes, internal flags, tenant markers. m.guarded declares the fields that can never come in from an agent. m.fillable declares the input profiles, create, update, and filter, and a tool's parameters are derived from those profiles rather than re-typed by hand.
The consequence is the one that matters in production. When a migration adds a column to the table, that column does not leak. It stays out of the wire until someone puts it in the model and someone, on purpose, puts it in the presenter. A raw server has no such property. Its output is the row, serialized, which means a password hash, an internal flag, and a tenant ID all arrive in the agent's context window the moment a new field lands in the schema.
Presenter: the perception you never showed
The V in MVA is not for a human eye. It is the package the agent perceives, and it is assembled from four layers.
Data that survived the firewall. Before anything is serialized, the presenter runs its schema over the raw result in strip mode: whatever the database returned, the agent sees only the declared surface. This is egress control at RAM level, not a view layer, not a template, not a convention. A field the schema does not know cannot cross.
Rules, delivered just in time. The field descriptions on the model compile into system rules attached to this response, for this entity. The agent is not carrying a global prompt of thousands of tokens; it receives the interpretation rules for what it actually asked for. The domain knowledge lives in one place and ships only when the domain is in play.
A working limit. The presenter declares how many items this agent may see in one response and what it is told when the list is truncated. A list of ten thousand rows is not data to an agent. It is a denial of service wearing a data costume. The limit is part of the perception, and the truncation notice is what stops the agent from pretending it saw more than it did.
Affordances. suggestActions tells the agent what it can do next with what it just saw. The docs call it HATEOAS for agents, and this is where action blindness dies: the response is not a payload, it is a position in a workflow. Server-rendered chart and diagram blocks are part of the same package, and they are deterministic: the framework renders them, the agent reads them, and no model in the loop generates the pixels.
Tools: verbs with intent
A tool in this framework is not a named function with a schema. It is a semantic verb with a default intent. f.query is read-only. f.mutation is destructive by default. f.action is neutral. Those are not metadata labels. They drive what the platform treats as safe to retry, what the observability pipeline marks, and what a governance tool flags when a read turns into a write.
From the verb, the chain is deliberately small. .fromModel pulls the input shape out of the model's fillable profile, so the tool's parameters are derived from the same declaration that closes the egress. .returns attaches a presenter to the response. .proxy writes the handler for you: it infers the HTTP method from the verb, resolves the path parameters from the input, and unwraps the response envelope. The .with steps are reserved for domain-specific inputs a model cannot express.
f.router groups verbs under a prefix and inherits middleware and tags to each one. f.middleware derives a typed context downstream, and the tenant identifier comes from a verified credential in that context, which is why the docs can say, flatly, that the agent cannot override it. Concurrency caps and egress byte limits attach to the same chain. And when a workflow needs a prompt instead of a tool, definePrompt builds it from the same presenter: the rules become the system message, the data and UI become the user block. One source of truth, two surfaces.
Errors that steer, state the agent can feel
A raw server answers a bad call with a flat string, and the agent's response to a flat string is to retry, with the same input, and again. That loop is where token budgets go to die, and where a wrong refund gets attempted four times.
The framework's answer is a self-healing envelope. f.error builds it from a specific code, a message, a suggestion, a list of actions the agent can take instead, optional details, and a retry window:
<tool_error code="InvoiceNotFound">
<message>Invoice "INV-999" does not exist.</message>
<recovery>Call billing.list_invoices first to find valid IDs.</recovery>
<available_actions>billing.list_invoices</available_actions>
</tool_error>
Specific codes beat generic ones. AlreadyPaid tells the agent something BAD_REQUEST cannot, and the recovery line removes the guesswork that makes retry loops expensive.
State is the other sense a language model does not have. After a mutation, the agent still believes the list it fetched before the mutation is current. The framework's answer is state sync signals in the response, borrowed from HTTP caching: a tool marks its result immutable, volatile, or causal. An immutable result can be trusted, a volatile one says re-query me, and a causal mark says that after this mutation, these other verbs must be re-queried. It is causality, not time, which is exactly what an agent without a clock needs.
Deploy: the connector as a bundle
A connector built this way ships as a single artifact, and the CLI does the whole job. mcpfusion deploy bundles the server into one self-contained file: all dependencies inlined, Node builtins replaced by stubs that exist only to satisfy the bundler and are never called, and the transport itself stubbed, because the platform supplies it. The bundle passes a size gate of 1.5 MB raw, which is a budget, not a spec, then gets compressed and hashed, and goes to the edge. If the hash matches the deployed one, the platform reports an instant restore: the same bytes, no reload.
Two steps are worth understanding, because they are what make the architecture auditable. The CLI runs a second, introspective compile of the bundle: it extracts the tool contracts, the prompts, and the credential schema, and writes them into a capability lockfile, a deterministic snapshot of the connector's behavioral surface. That lockfile is git-diffable, and a fusion lock --check in CI is the gate: the surface your code actually exposes is compared to the surface you committed, and the diff is classified as breaking, risky, safe, or cosmetic. The protocol has no mechanism for detecting drift, and the framework gives you one. That is the difference between a deployment you cannot audit and one a reviewer can read.
The same bundle speaks the current era of the protocol: stateless, per-request, behind any load balancer. And the registry that built it runs unchanged on stdio and on the 2025 era of HTTP, which is how local development and production stay on the same code.
Edge deployment has three constraints, and all of them are design decisions: the tool set is registered explicitly, because discovery scans a filesystem that does not exist there; there are no native addons; and nothing in the bundle touches the process. Explicit imports and explicit registration, and the server is edge-legal.
Why build with MCP Fusion
The question behind all of the above is the one worth answering directly: why not write a plain MCP server and add safety where it becomes painful?
Because the failure modes are structural, and structural fixes live in the framework, not in the handler. A raw server has six absences, and this one has six presences:
- It leaks whatever it returns. This one closes the egress at the model layer, so a new column cannot reach an agent until someone declares it.
- It enforces nothing. This one freezes the registry after attach and keeps the pipeline order by construction: the safety is enforced, not conventional.
- It cannot see its own drift. This one hashes the behavioral surface into a lockfile and classifies each change before merge.
- It answers errors with strings. This one answers with recovery instructions and the next available actions.
- It is blind to time. This one carries causal invalidation signals in the responses.
- It is one surface, wherever it is hosted. This one is one registry across stdio, HTTP, the Vinkius edge, and serverless targets, with observability that maps to SOC 2 controls and can forward to a SIEM.
Two multipliers change the economics of the work itself. You can generate the connector from an existing contract: an OpenAPI spec or a Prisma schema becomes a complete server, with the egress, the tenant isolation, and the memory protection baked into the generated code, in one command. And the tests are the real pipeline: the testing package runs your connector in RAM, through the same validation, middleware, handler, presenter, and egress that production runs, with zero tokens and full determinism. You assert that the data has no secret field, that the rules arrived, that the error classified.
The honest cost is that this is an architecture, not a helper library. The MVA split takes a few days to internalize, and the bundle budget keeps the dependencies lean. What you are buying is the boundary between your data and an agent's perception, enforced by the framework instead of by review. For anything that will run in production with other people's agents on the other side of the wire, that is the point.
One connector, from spec to edge
The whole workflow, end to end:
mcpfusion create invoices --vector openapi
mcpfusion remote --server-id <uuid from the dashboard>
mcpfusion deploy
For a bare start instead of a generated one, the same three commands with --vector vanilla. Then the minimum of three declarations:
defineModel('Invoice', m => {
m.casts({
id: m.uuid().label('Invoice ID, a UUID'),
total: m.number().label('Total, integer cents'),
status: m.string().label('open, paid, or voided'),
});
m.hidden(['webhookSecret', 'internalFlags']);
m.fillable({ create: ['id', 'total'], filter: ['status'] });
});
const router = f.router('billing');
router.mutation('void_invoice')
.withString('id')
.returns(invoiceUI)
.invalidates('billing.*')
.proxy('invoices/:id/void');
const tester = createMCPFusionTester(registry, {
contextFactory: () => ({ tenantId: 't_777' }),
});
const result = await tester.callAction('billing', 'void_invoice', { id: 'INV-7' });
expect(result.data).not.toHaveProperty('webhookSecret');
expect(result.uiBlocks.length).toBeGreaterThan(0);
The test runs the same pipeline production runs and proves, without a single token, that the egress held. Deploy, check the lockfile in CI, and the connector is a hash in a repository, a diff in a pull request, and an isolated program at the edge. The same object is all three.
The runtime that finally executes that program, sealed and snapshot-restored, is the subject of the post on V8 isolates.
