ADR 1: The agent is a peer of the server, not a layer above it
Accepted, 2026-08-25.
Context
Section titled “Context”The project plan had the deep agent as ios_mcp/agent/, and its first draft
drew it as a seventh layer stacked on top of the six in
ARCHITECTURE.md. Both were wrong, for two separate reasons.
The layer number does not match the import graph. ARCHITECTURE.md draws
policy as layer 6, above the MCP server at 5 and the session at 4. That is a
presentation choice, not a dependency: ios_mcp/policy/ is imported by
ios_mcp/session.py:38-40 and by ios_mcp/server/context.py, and PolicyGate
is constructed inside IosSession.__init__ at session.py:70. Nothing sits
above the gate. Drawing the agent at layer 7 implied the gate mediates between
the agent and the tools, when in fact an agent passes through the same gate on
the same code path the server does.
A subpackage cannot hold the boundary that matters. The value of layers 1
to 4 is that they are a plain async library with no MCP imports, so any
consumer can use them. An agent bound to LangGraph and to one model provider,
living inside the same distribution, undercuts that: ios-mcp would carry a
provider-shaped dependency even behind an optional extra, and “the agent must
not reach past the public API” would be a second honour-system rule policed by
review, exactly like the first one, which had no test.
Decision
Section titled “Decision”ios_agent is a separate distribution in a uv workspace, at agent/.
ios-agent depends on ios-mcp; nothing points the other way.
tests/unit/test_layering.py enforces both directions statically.
The agent is described as a second consumer of IosSession, a peer of the MCP
server. ARCHITECTURE.md’s diagram already shows “MCP clients (Claude Code,
Claude Desktop, a future agent service or iOS app)” above the stack. The agent
belongs in that row.
Alternatives rejected
Section titled “Alternatives rejected”ios_mcp/agent/ as layer 7, which is what the plan said. Simplest, no
build changes, and wrong on the import graph as above. The boundary would have
been a directory convention.
A sibling top-level package in one wheel (ios_agent/ beside ios_mcp/,
agent deps in an [agent] extra). Most of the dependency benefit for almost
none of the plumbing, and an import test still works. Rejected because the two
still ship together: anyone installing the MCP server receives agent source
they did not ask for, so the separation stays a convention rather than a fact.
A separate repository. The strongest boundary and the most faithful demonstration, but it splits the eval harness across repos, and the measured argument the eval harness carries is the deliverable of this phase.
Consequences
Section titled “Consequences”pip install ios-mcpnever sees LangGraph. Verified against the built wheel: it contains onlyios_mcp/and declaresanyio,fastmcp,httpx,pydantic-settings,pydantic, pluspillowunder thevisionextra.- The public surface is now written down, as
_PUBLIC_SURFACEintests/unit/test_layering.py:session,config,errors,actions.result,perception.digest,devices.base,devices.pool. Widening it is a deliberate act that shows up in a diff. - The long-documented “layers 1 to 4 must not import MCP” invariant is enforced for the first time. It had never had a test.
- The MCP-backed eval variant must reach the server as a client over a
transport rather than importing
ios_mcp.serverin-process. That is the honest version of the claim anyway: importing the server would prove nothing about whether it works for anyone else. - Two
pyproject.tomlfiles, oneuv.lock, andmypynow runs over both packages.uv syncinstalls both, because the eval suite drives both.