The MCP Server as a Projection of the Tool Registry

, about 17 minutes

A customer wants to drive your product from outside it. Declaring your tools again in the new protocol's vocabulary ships in a week and leaves you with two definitions of what your system can do, drifting apart. What it takes to make the external surface a rendering of the internal one instead.

The request always arrives in the same shape. A customer wants MCP, or an agent framework, or a partner integration, and underneath the vocabulary it is one thing: let something outside drive the capabilities the product already has.

You already have those capabilities wired up. There is a tool interface, implementations, a dispatcher, probably a registry, and an assistant inside the product that uses all of it. So the obvious move is to declare the tools again in the new protocol’s vocabulary and forward each one to a service, or worse, back through your own HTTP API. It ships in a week, and it looks correct.

What it creates is a second definition of what the product can do, and from then on the two drift. A tool changes on one side and not the other. Authorisation is checked in both places, slightly differently, and one of them is wrong about a case nobody has hit yet. The external catalogue advertises tools it cannot actually run, and since a model does not read release notes, it discovers the lie by spending turns on calls that fail. Every bug gets fixed twice.

None of that is a protocol problem. Each of those is the same problem wearing different clothes, which is two definitions of one thing.

What we build instead rests on a single rule, narrow and load-bearing:

There is exactly one registry of what the system can do, and one admission point through which anything outside the core may invoke it. Every model-facing protocol surface is a rendering of that pair. It adds transport, and nothing semantic.

In practice, tools/list is a projection of the registry’s exposed subset and tools/call is a thin mapping onto the invoker. The class that implements both carries this comment, and the comment is the design:

There is nothing to port: the tool interface is already MCP-shaped, and the invoker already enforces exposure, the intent allowlist, ownership and limits. So this layer adds nothing semantic. That is the parity guarantee — the external surface cannot drift from the first-party one, because it is the same registry behind the same admission point.

Cannot is a strong word, and it has to be earned. The rest of this paper is how we earned it, with Quillsmith, our long-form writing system, as the worked example.

What the registry has to be first

A projection is only as good as the thing being projected. Three properties have to hold before an external surface can be a thin rendering rather than a rewrite.

The first is the cheapest decision on the list, and it gets made very early, usually before anyone is thinking about external surfaces at all. Per tool, MCP wants a stable name, a description written for a model, a JSON Schema for the arguments, and an execution entry point. If the internal tool abstraction carries those four things, the projection is a field mapping. If it carries a C# delegate and a hand-written prompt fragment, it is a rewrite, and a rewrite is a port. Model-facing schema is an interoperable currency. Store tool metadata in that currency from day one and every future protocol is a mapping. Store it in a bespoke shape and every future protocol is a translation project.

The second is that a tool definition has two audiences, and they need different facts. The model reads the name, description and schema. The host is the thing deciding whether to offer the tool, whether to ask the user for confirmation, and how to render it in a picker, so it needs to know whether this is read-only, whether it can destroy user content, whether it is idempotent, and whether it may be offered to an outside caller at all. We keep those on the same interface and never send them to the model:

ToolExposure Exposure => ToolExposure.Internal;   // where it may be offered
string? Title    => null;                          // display name in host pickers
bool ReadOnly    => false;
bool Destructive => false;
bool Idempotent  => false;

Two details in those five lines matter more than they look. The defaults point the restrictive way. A new tool that nobody annotated is Internal, not read-only, and possibly destructive, so forgetting to annotate cannot widen the exposed surface or claim that a write is safe. The failure mode of omission is a tool that is invisible externally, which is recoverable and noticed immediately. The opposite default fails by quietly offering a destructive tool to a third-party model.

And exposure is a property of the tool, not of the transport, which is what makes structural statements true. We can say that an import operation structurally cannot delete a project and mean it in the strong sense. The deletion capability is Internal, so it is not reachable from any outside caller through any protocol, present or future. That guarantee is not a policy document, a prompt instruction or a review checklist. It is the type system plus one filter.

The third property is one admission point. The registry answers what exists. Something else has to answer may this caller run this, now, on this data, and if each transport answers that for itself, they will answer it differently. Our invoker has one signature and one job list.

Task<ToolResult> InvokeAsync(
    Guid userId, Guid projectId, string toolName, JsonElement arguments,
    Guid? sessionThreadId = null, CancellationToken ct = default);

Behind it, in order, the invoker checks that the tool exists, that it is exposed for outside calls, that its operation is on the allowlist, that the target belongs to the user, and that the user is inside their usage limits. Then read tools run their own body, while operation tools become queued work on the same path the product’s own CLI has always used. That last clause carries more than it seems to. There is no external execution model because there is no second queue.

The ownership check has a history. It started as an inline query inside the invoker, and the two protocol-specific tools that bypass the invoker each asked the question again with raw database access. That is three answers to one question, and incidentally the single impurity that would have turned a future deployment split into a refactor. It is now one interface with one implementation and one deliberate behaviour. Missing and not yours return the same answer, because distinguishing them tells an unauthorised caller which identifiers exist.

The projection itself

One registry, one admission point, two renderings The tool registry holds 61 registered tools. All calls pass through a single admission point that resolves, authorises and invokes. From it, two renderings are projected: the product's own assistant, which sees the full dispatch surface, and the MCP server, which sees a filtered view of 35 tools — 31 projected from the registry plus 4 protocol-specific additions. The MCP server adds no semantics. Tool registry one definition of what the system does 61 registered Admission point resolve · authorise invoke every call, both surfaces Product assistant full dispatch surface MCP server filtered view · adds no semantics 35 published = 31 projected + 4 protocol-specific two renderings

One registry, one admission point, two renderings, as of 14 September 2026. All 61 registered tools resolve through a single admission point; the product’s own assistant sees the full dispatch surface, and the MCP server sees a filtered view of 35 — 31 projected plus 4 protocol-specific additions.

The whole architecture is that picture. Everything else is what it costs to make the left-hand box true and the right-hand boxes honest.

With the preconditions holding, the external catalogue is a query

public IReadOnlyList<IChatTool> ExposedTools() =>
    _registry.GetAll()
        .Where(t => t.Exposure is ToolExposure.Dispatch or ToolExposure.Both)
        .Where(t => HeadlessToolPolicy.IsHeadlessInvocable(t.Name))
        .ToList();

and the catalogue handler maps each result onto the protocol’s Tool shape, copying the annotations into the hints hosts render.

That is very little code, and it still contains the two decisions people most often get wrong. The first is to use handlers rather than attribute discovery. Most MCP SDKs offer a compile-time route, where you decorate methods with attributes and let the server enumerate them. We deliberately do not use it. The catalogue is a filter over a runtime registry and cannot be enumerated at compile time, and that is the point. Attribute discovery would bring the second definition back in by the side door, as a set of decorations someone has to keep in step with the registry by hand.

The second is that the catalogue must be the invocable subset, no more and no less. This is the rule we broke first. Early on, the catalogue advertised the whole dispatch surface while the call handler demanded a project identifier for everything, so the tool that creates a project was listed and structurally uncallable. The system’s own documented entry flow was impossible to execute.

The lesson generalises past the bug. A catalogue entry the caller cannot invoke is worse than a missing one. A missing tool costs a capability. A listed-but-unusable tool costs turns, because the model tries it, fails, re-reads the description, tries a variant, and fails again. It is one of the few places in an agentic system where an honest smaller surface outperforms a generous larger one, and it is worth stating as a design rule because the instinct runs the other way.

Run the filter against Quillsmith as it stood on 14 September 2026 and this is what comes out. The container registers 61 tools. Of those, 35 are annotated as externally exposed. Four of the 35 are then excluded as permanently conversation-bound: a project switch, an AI narration of data the caller already has as data, and two query tools that are compositions of what the account-scoped reads already expose. The project switch is the clearest of the four. A headless caller names its target on every call, so there is no session state left to switch. That leaves 31 projected, plus 4 protocol-specific additions, for a published catalogue of 35 tools.

The catalogue totals 35 and the annotated set also totals 35, and that coincidence is worth naming because it invites a re-check that leads nowhere. They are not the same 35. Four annotated tools are excluded and four protocol-specific tools are added. The sets overlap in 31 members and the arithmetic cancels.

Nor is the exclusion list a set of tools we have not got around to. Each of the four is excluded for a reason that names a property of headless calling. The set that was once excluded pending work has since emptied, because the operations that had no headless form grew executors, and the policy class was renamed to say that what is left is permanent. It is a small rename and a real piece of honesty. A set called “not yet” and a set called “never” ask to be read differently, and conflating them hides work.

What the protocol makes you build anyway

A projection that claims to add nothing is usually lying. Five things in our surface are genuinely new, and every one exists because of a fact about the protocol rather than a fact about the product. All but the status tool were found by running against real hosts, not by reading the specification. If you take one section of this as a shopping list, take this one.

One protocol fact generates most of the list. MCP has no server-initiated channel, so the server cannot push to the model, ever. Our product runs operations that take minutes, and the first-party clients learn about completions over a realtime connection an external host does not have. Everything asynchronous therefore has to be drained on a tool call, and the two items that cost the most to learn both come out of that.

A bounded wait, because a tool call cannot hold a connection. Long work is start-then-poll by design, but polling a database in a loop is a poor experience when the events already flow on an internal bus, so we multiplex that bus into per-call channels keyed by operation. The wait tool returns the moment the work terminates, and otherwise gives up when its window closes.

The window is 60 seconds, and the number is measured rather than chosen. At a previous 120-second ceiling, roughly one wait in three died with a transport drop while the server was perfectly healthy. Hosts abandon tool calls held much longer than a minute. The correct response to that is not a retry policy. It is a window that ends comfortably inside what hosts will hold, plus a description that tells the model to call again.

The internals are unglamorous and worth copying. The subscription is taken before the caller reads current state, so an event firing in between is not lost. The channel is bounded and drops the oldest entry on overflow, because dropping an event is acceptable and blocking the event bus is not; a watcher that misses one still falls back to persisted state when its window closes. And terminality is decided by a shared predicate rather than a local switch. Before that predicate existed, four event sinks each decided differently when work was over, and a fifth per-sink opinion about what “finished” means is the kind of duplication this architecture exists to prevent.

The pending-state footer. Every result carries one appended line saying what is running, which conflicts are open, and which tool to call for detail. It costs a handful of tokens and it is, per unit of effort, the highest-leverage thing on the surface. With no push channel, tool results are the only text the model reliably reads.

Two host realities shaped it, both discovered against real clients rather than the specification. A host may render the structured payload instead of the content blocks, so a footer emitted as a second text block never reached the model at all. The one line announcing pending work was invisible in the primary host. It now lives inside the payload as well. And the specification says the structured payload is an object, and hosts enforce it. A tool that returned a bare array was reported as malformed and was simply unusable.

The footer also taught us something about cost. It initially built a full status snapshot (a completions walk, a composer call and a ledger read per recent plan, the recommendation envelope, the project row) to print two numbers. It now calls a dedicated summary read. Anything that runs after every tool call has to be costed as though it runs in a loop, because it does.

The transport’s own arguments. The product’s internal assistant takes the working context from the conversation, so no tool schema mentions it. A headless caller has no conversation, so every project-scoped call needs the identifier explicitly. The naive fix is to add the argument to every tool. That is wrong, because it is a property of this surface and not of the operation, and writing it into the tools would push transport concerns into the domain. So the projection injects it, into the schema it renders and into the required list, with a description, and leaves the tools alone. The same mechanism carries an optional session identifier that the first call mints and every response echoes back.

The defect that led here is worth telling. Without the injection, a benchmark model called a read tool, was told the identifier was required, and had to invent the argument. It guessed correctly, which is worse than guessing wrong. The surface worked by luck. A model cannot see a requirement that is enforced in code and absent from the schema, and an undocumented-but-enforced argument is indistinguishable from a broken tool.

Descriptions that are true only in a conversation. Some internal tools carry descriptions like “omit to act on the one this conversation is about”. In a conversation that is true and load-bearing. Outside one it is a lie, and the executor will refuse the call. The fix is a per-surface override. The headless rendering states the stricter truth, with a tighter required array and a rewritten description, while the conversational schema keeps its text.

This is the one place where the projection genuinely rewrites content, and it is confined to a lookup table with an entry per affected tool. Bounding it that way keeps it auditable. You can read every divergence between the two surfaces on one screen, which is not something you can say about a ported implementation. The table takes an edit to both fields or neither, because a tightened required array next to an untouched “omit to act on the current one” is two contradictory instructions to a model that reads both.

The status tool itself. It reports running operations, what finished since the caller’s last cursor, and open findings by kind and severity. It is the one item on the list that is not a missing internal capability. Internally it is a push, and the tool exists only because the protocol has nowhere to push to.

Those five, and the comments justifying them, are most of the MCP assembly, which comes to roughly a thousand lines. The tool implementations, the actual capabilities, are not in it, have never been in it, and would not move if the surface were deleted tomorrow. That ratio is the result the architecture is for.

Keeping it honest

An architectural claim that is not tested is an architectural aspiration. Four tests turn this one into a property.

The first derives the catalogue from the registry, independently of the handler, and asserts it equals the invocable subset. A tool that becomes invocable without becoming visible, or visible without becoming invocable, fails the build.

The second requires every exposed tool to carry what a host renders, meaning a display title, a description, and annotations that do not contradict each other, since nothing should be both read-only and destructive. Host directories require per-tool annotations. More to the point, an unannotated tool defaults to unsafe and then shows up in a confirmation prompt with no name.

The third asserts by name that the protocol-specific tools are the only ones with no internal analogue. It guards against the slow failure, which is not a big bang but accretion. One tool “only makes sense externally”, then another, until the surface has a private capability set and the projection claim is quietly false. Adding another means editing a test that says, in effect, justify this.

The fourth guards the deployment seam. The MCP assembly is allowed to reference the application layer and the domain models, and nothing below them, so no persistence and no web host. The compiler drops unused references, so the assertion is exact. It fails the moment a handler reaches past the application layer, which is how the two reach-arounds it replaced got in.

That last test encodes a decision about the future. If the external surface ever needs its own deployment, it moves as a second host head over the same application assemblies and the same database, never as a remote client of the product’s own REST API. The remote-client version reintroduces an HTTP loopback, turns parity-by-construction back into parity-by-discipline, and breaks the bounded wait outright, because the event broker subscribes to an in-process bus. Keeping the split cheap is a composition change; letting the boundary rot makes it a refactor. One test is a low price for the difference.

What the shape forces operationally

Four decisions follow from the projection, and the first is where we lost time.

The transport keeps no session affinity. The default tracks state between requests, which means a client that does not echo the session header gets a protocol error on its second call. That is what happened to us. Statelessness costs one thing, server-to-client messages, and this surface sends none. There is no progress pump, no sampling, no server events. What it buys is a load balancer that needs no sticky sessions, which is the difference between “we can scale this” and “we can scale this after a rewrite.”

Every call resolves its user from the presented credential. There is no configured service account, no impersonation path, and no way for a caller to act as anyone but itself. Two credential shapes, a session token and a personal access token, resolve through one scheme to one claim, so everything downstream reads a user identifier and does not know which produced it. Adding a third shape is an addition to that switch, not a change to anything beneath it. Authentication is not configurable either. An unauthenticated caller gets a challenge, not a tool list, and there is no flag to disable the surface, because a flag is a thing that can be in the wrong state and an endpoint that is inert without credentials cannot be.

Usage limits are enforced at the admission point, so an external caller cannot route around a quota that binds the product’s own assistant. There is one deliberate exception. Control operations, such as stopping a plan or undoing what another one did, are not limit-gated, because a user who has hit their daily limit must still be able to stop work and reverse it. Governing controls with the same budget as the work they govern is a trap that looks like consistency.

The last is payload frugality, which sounds like micro-optimisation and is not. Optional blocks are omitted rather than sent as nulls, because a null field on every poll is tokens paid for no information. Enumerations serialise as strings. The text encoder is the relaxed one, because every escape sequence is tokens the host pays for. On a surface whose status tool is called in a loop, that is the difference between an integration that is pleasant to run and one that is expensive to run.

When not to do this

The pattern has preconditions, and we would rather state them than oversell it.

It requires a real internal tool layer. If the product’s AI features are prompt templates with bespoke parsing, there is no registry to project, and the honest first step is to build one. That is worth doing on its own merits, but it is a larger piece of work than “add MCP”.

It requires the internal surface to be good, because a projection inherits its source’s quality. Vague descriptions, leaky schemas and inconsistent naming are all faithfully projected. In practice this turns out to be a benefit. Building the external surface puts pressure on the internal one and exposes the places where a tool’s contract was being carried by conversational context rather than written down. Every override in our divergence table started life as an internal tool that was less self-describing than it appeared.

And it assumes the external surface should offer the same capabilities. If the external product is deliberately narrower or differently shaped, say a partner integration with its own domain vocabulary, then a translation layer is the correct design and none of this applies. The test is whether the two surfaces are meant to stay level. If yes, make them one thing. If no, make them two things and say so.

How we apply this for clients

Quillsmith is where we run this architecture at full scale before recommending it to anyone else, and client work is where we apply it. Most of the value lands before MCP is mentioned.

A tool-layer review asks three questions. Does a single registry exist? Is there one admission point, or does each caller check authorisation itself? Are host-side concerns separated from model-facing schema? The answers predict what an integration will cost, long before anyone writes protocol code.

What usually follows is consolidation, pulling scattered tool definitions and duplicated authorisation into one registry behind one entry contract. That is the work that makes every later surface cheap, and it pays for itself the first time an authorisation rule changes. Only then do protocol surfaces become projections, MCP today and whatever the next agent protocol turns out to be on the same terms. Once the preconditions hold there is nothing left to invent, which is what makes the work estimable at all. The guarantees get written as build-breaking assertions, so the architecture survives the team that wrote it.

If you have an internal tool layer and a customer asking to drive it from outside, the question worth asking first is not which protocol. It is do we have one definition of what this system can do, or are we about to create a second one.


We build and maintain software systems, and develop the Quillsmith applications. This paper describes production architecture rather than a reference design; the code fragments are from the shipping system.

All papers