Systems & Architecture

MCP is for Strangers

A protocol for crossing a boundary you don't control gets reached for by default now, even when there's no boundary to cross.

Every agent we've shipped this year has needed to call at least one tool that already lives in our own codebase. The reflexive answer for "how does it call that tool" has increasingly been MCP — and increasingly, that reflex has been wrong. Not because MCP is bad, but because it solves a narrower problem than the reflex assumes: crossing an ownership boundary, a different team, a different company, a different runtime. Most of our agents never cross one.

This post walks through two decisions that follow from that observation, and how they relate. The first draws the line for when MCP is actually worth its cost. The second decides what should replace it when it isn't — which turns out to already exist in how every other caller reaches our systems today.

System shape

   REST route          background worker         in-process agent
       │                       │                         │
       └───────────────────────┼─────────────────────────┘
                                │
                                ▼
                     ┌────────────────────┐
                     │     Controller      │   one call path
                     │  (BaseController)   │   one error taxonomy
                     └──────────┬─────────┘
                                │
                                ▼
                     ┌────────────────────┐
                     │  Services / Repo    │
                     └────────────────────┘

     ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄  ownership boundary crossed?  ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄

                     ┌────────────────────┐
                     │     MCP server      │ ──▶ external / third-party
                     └────────────────────┘      consumer
    

The first decision, formalized below, draws the boundary line explicitly — when a tool is worth wrapping in a protocol server, and when it isn't. It's since grown a second and third decision underneath it. Use the selector to move between them; each card notes what it depends on, so jumping straight to a later one still makes sense on its own.

View full decision tree 3 nodes · 3 layers
AD-014                                    [main @ a3f9c1e]
 └─ AD-014.1   extends                    [main @ a3f9c1e]
      └─ AD-014.1.1   extends             [agent-runtime @ f6b21d0]
ADR AD-014

Reserve MCP for Genuine Ownership Boundaries

main @ a3f9c1e Accepted
Problem
Agents are treated as a category that always needs MCP, even when the tool they're calling already lives in the same codebase, language, and deploy as the agent itself.
Solution
Call internally-owned tools directly through the existing atlasboxpy_controller call path — the same controller class REST routes and workers already use — registered in a queryable tool_dictionary. Reserve MCP for tools that genuinely cross an ownership boundary — a different team, a different company, or multiple independent clients.

improves trade-off to watch

Cognitive Load

Lower for the common case: one call path, typed response, instead of holding a protocol schema and a function signature in sync. Rises only if a tool later needs external discovery — added deliberately, not by default.

Performance

Removes a serialization and IPC hop from the agent's reasoning loop. A tool call behaves like any other function call in-process — no round trip, no JSON-RPC overhead, no server startup latency on first use.

Reliability

Neutral to positive, conditional on discipline. Calls inherit the same shared error taxonomy and structured retryable signal every other caller gets, instead of per-agent retry logic invented from scratch. Regresses only if a team bypasses the controller for an undisciplined direct call into a service.

Evolvability

Deferred, and cheaper than it looks: internal tools are registered in a queryable tool_dictionary from the start, giving developers visibility and control without MCP. When a boundary does appear, an MCP server reads its schema from that same registry instead of being authored from scratch.

Decision Rule

Same codebase, runtime, and owner → call it directly through the controller. No server, no schema, no protocol.

Different team, company, or multiple independent clients need the same tool → MCP is earning its keep. Build the server.

Rough secondary signal: fewer than about three independent integrations sharing a tool means the N-to-M problem MCP exists to solve hasn't shown up yet.

A Context

Agents are now a normal category of caller into our systems, alongside REST clients and background workers. The default answer for "how does an agent reach a tool" has become MCP — stand up a server, describe the tool as a schema, give the agent a client.

That default isn't wrong in general. MCP solves a specific problem: letting a client discover and invoke a tool it doesn't own, across a boundary a standardized protocol genuinely helps cross. The friction shows up when that reflex gets applied uniformly, including to tools that were never on the other side of any such boundary.

B Rationale & Evidence

The cost side isn't speculative. Independent write-ups across the industry converge on the same short list of costs when MCP is applied where no ownership boundary exists: added latency from serialization and IPC now sitting inside the reasoning loop, added complexity from turning a function call into a network call, reduced observability, and ongoing operational toil from every server becoming another artifact to keep alive.

We're not the only ones drawing this line. AMD's robotics research team published a physical-AI agent architecture on 25 Aug 2026, calling the robot's existing ROS 2 perception, planning, and control capabilities directly as tools — not through an added protocol layer, justified by interpretability and on-device latency. They take no position on MCP either way; this is a production team's revealed preference, not an opinion. Read the source →

C Consequences
What gets easier
  • No new server to deploy or keep alive for tools we already own.
  • Failures surface in the same stack trace and error taxonomy for every caller.
  • Agents get the same structured retryable signal every other caller gets, for free — deciding what to do with it stays the agent's own job.
  • Developers get a queryable answer to "what internal tools exist and who owns them" via the tool_dictionary, today — not a question that only gets answered once an external consumer forces it.
What we give up
  • No protocol-level runtime discovery — an external client still can't ask "what tools do you have" over the wire. The tool_dictionary gives us our own visibility, not a stranger's client's visibility.
  • Only sound if the controller is actually used, not bypassed for a direct call into a service.
D Alternatives Considered
  1. MCP by default for all agent tool calls. Rejected — pays the full protocol cost on tools that never cross a boundary.
  2. Undisciplined direct calls, no shared infrastructure. Rejected — reintroduces inconsistent error handling.
  3. Direct calls through the controller; MCP reserved for genuine boundaries. (Chosen.)
E When to Revisit
  • A tool used only by our agents needs a second, independent consumer — wrap the controller in an MCP server without touching the implementation.
  • Independent clients needing a given tool cross roughly three.
F References
  • AMD ROCm Blog, "Enabling Physical AI Agents with Lemonade," 25 Aug 2026 — rocm.blogs.amd.com
  • atlasboxpy_controller package, formerly validator_gateway.
  • tool_dictionary schema and query interface (internal) — the agent-callable registry AD-014.1.1 introduces for deprecation-window enforcement is an instance of this same registry, not a separate system.
ADR AD-014.1

Make the Controller the Default In-Process Call Path for Agents

main @ a3f9c1e Needs Discussion

↪ extends AD-014 — decides what replaces MCP once you've opted out of it for a tool.

Problem
Opting out of MCP for internal tools only helps if "call it directly" doesn't quietly become "call it however each agent's author feels like" — inconsistent error handling, no shared observability, no retry logic.
Solution
Any in-process agent imports the same controller class a REST route or worker would use, and calls its methods directly. The controller's own base class already wraps every public method — there's no separate object to construct and no unwrapped, raw method an agent could accidentally call instead.

improves trade-off to watch

Cognitive Load

Zero new concept for engineers who already know the controller from REST and worker call sites — an agent is just another caller, not a new abstraction to learn.

Performance

No overhead beyond the controller's own try/except and formatting logic, already in the hot path for every other caller today.

Reliability

Agent-originated failures come back with the same structured, typed retryable signal every other caller gets — important since agents run unattended and won't notice a silent failure. The retry/backoff decision itself stays the agent's own code, deliberately, rather than a policy engine's.

Evolvability

Agent-callable methods register in the tool_dictionary at call time, so if one later needs to become a publicly discoverable capability, the same controller wraps in an MCP server reading from that existing entry — not a rewrite.

Decision Rule

Agent calls a controller it owns → import the controller class directly and call its methods. No gateway to construct, no recovery engine to attach — the base class it already inherits from handles both concerns.

Agent reaches around the controller into a service directly and wraps it in bespoke error handling → not compliant with this decision.

A Consequences

Every caller — REST route, worker, or agent — now produces identical, typed success and error responses for the same controller. Debugging an agent failure means reading the same error taxonomy an engineer already knows from the REST logs, not a separate agent-specific format.

B References
  • Depends on AD-014 (this document extends it).
  • atlasboxpy_controller package, formerly validator_gateway.
ADR AD-014.1.1

Require a Deprecation Window Before Changing Any Agent-Callable Controller Method

agent-runtime @ f6b21d0 New Proposal

↪↪ extends AD-014.1 — closes a narrower gap than it might first appear. Most structural signature breaks already surface at two layers: Pydantic raises the validation error the moment a call stops matching the expected shape, and a proper atlasboxpy_controller pytest suite is what actually exercises that call path and asserts the req/res relationships and spied call chains needed to catch it before merge. What neither layer reaches — a change that stays valid at both, or a call path the suite doesn't cover — is what actually surfaced once the agent-runtime fork started iterating on controller interfaces faster than its own test coverage kept up.

Problem
Direct in-process calls (AD-014.1) give agents no protocol-level interface negotiation the way MCP's schema discovery would. Pydantic raises a validation error on a structural break, but only if something exercises that call path — a atlasboxpy_controller pytest suite asserting the expected req/res shapes and call chains is what actually surfaces it before merge. What still lands unannounced is a change that stays valid at both layers, or a call path the suite doesn't cover.
Solution
Any controller method reachable by an agent must carry a minimum deprecation window — old and new signatures coexist for at least one release cycle — enforced via the tool_dictionary's agent-callable registry, checked at review time, not a runtime protocol.

improves trade-off to watch

Cognitive Load

Largely automated, at two layers: Pydantic throws the validation error on a structural break; the atlasboxpy_controller pytest suite exercises the call and asserts req/res shapes and call chains to actually catch it. Standard practice, not a new habit. The registry covers behavioral changes and untested paths.

Performance

No runtime cost — the registry check happens at review and CI time, not on the call path. Agents keep the zero-hop performance AD-014.1 already established.

Reliability

Closes the gap neither layer reaches: changes that stay valid through Pydantic and the suite's own req/res and call-chain assertions, and call paths the suite doesn't cover. Structural breaks were already caught pre-merge by Pydantic's error plus the test that exercised it; this adds a checkpoint for what wasn't.

Evolvability

Slightly slower to remove old controller methods than an unconstrained codebase would allow, in exchange for closing the gap left by both layers together — behavioral changes and untested paths, not the structural breaks Pydantic and a atlasboxpy_controller pytest suite already catch between them.

Decision Rule

Changing a controller method's signature and it appears in the agent-callable registry → deprecate first, remove next release cycle.

Method isn't in the registry → normal change process, no window required.

A Consequences

Every agent-callable controller method now has exactly one place its callers can be checked before a breaking change ships — the registry — as a deliberate backstop alongside the existing two-layer defense: Pydantic raises the validation error, and the atlasboxpy_controller pytest suite is what actually exercises the call and asserts the req/res shapes and call chains needed to catch it before merge. The registry covers what neither layer reaches: behavioral changes, and call paths the suite doesn't exercise.

B References
  • Depends on AD-014.1 (this document extends it).
  • Agent-runtime fork RFC (internal).
  • atlasboxpy_controller package (formerly validator_gateway), Phase 8 — Test Suite Completion (internal): the pytest coverage requirements this decision assumes are already in place.
All References (3)
  • AMD ROCm Blog, "Enabling Physical AI Agents with Lemonade," 25 Aug 2026 — rocm.blogs.amd.com — cited in AD-014
  • atlasboxpy_controller package, formerly validator_gateway — cited in AD-014, AD-014.1
  • Agent-runtime fork RFC (internal) — cited in AD-014.1.1

Keep it strategically simple

Skipping the protocol layer isn't the lazy choice, it's the considered one — made after checking whether the boundary the protocol exists to solve is actually there. Most of the time, for most of our agents, it isn't. Reading one decision at a time, rather than scrolling past all three, is deliberate too: the selector defaults to whichever decision is currently Accepted — AD-014 today — not just whichever one happens to be the root, so a reader always lands on the settled state of the system rather than an artifact of how the tree is drawn. A reader who jumps straight to AD-014.1.1 still finds, on the card itself, that it extends AD-014.1, which extends AD-014 — without having had to read both first to make sense of it.