Skip to content

ADR-004-F: MCP at Every Domain Boundary

Status: Accepted Date: 2026-04-16 Decision Makers: Gautham Chellappa Depends on: ADR-002-F (Modular Monolith), ADR-003-F (Three-tier agents)

Context

Agents need a uniform way to interact with 19 domain OTP apps. Without a contract, each domain grows a bespoke interface that agents must special-case. With a thin contract (direct function calls), the contract becomes coupled to Elixir internals — extracting a domain to another language would require rewriting every agent caller.

Model Context Protocol (MCP) is emerging as the open standard for agent-to-tool contracts — typed tool schemas, categorised actions, framework-injected context. Anthropic sponsors MCP and Claude natively supports it. Adopting MCP at every domain boundary gives us three properties at once.

Decision

Every domain OTP app exposes one MCP server with typed tools. Agents interact with domains only via these tools. Inside a single BEAM node, MCP is an Elixir behaviour — tool calls are direct function calls for µs latency (no JSON-RPC overhead). The protocol shape (typed schemas, category metadata, context injection) is preserved; only the transport is elided.

apps/finnest_<domain>/lib/finnest_<domain>/mcp/
├── server.ex              # MCP server registration
└── tools/
    ├── list_shifts.ex     # one file per tool
    ├── propose_assignment.ex
    └── ...

Tool shape (gold standard):

defmodule Finnest.Roster.MCP.Tools.ListShifts do
  use Finnest.Agents.MCP.Tool,
    name: "roster_list_shifts",
    domain: :roster,
    category: :read,
    description: "..."

  input :date_from, :date, required: true
  input :date_to, :date, required: true
  input :site_id, :uuid, required: false

  output_schema %{ shifts: [...] }

  def call(params, %{org_id: org_id} = _ctx) do
    # org_id from context, NEVER from params
  end
end

Four tool categories (AW-12): :read, :propose, :execute, :restricted.

org_id injected by framework from session.metadata — agents cannot provide it (AI-03).

Alternatives Considered

Alternative Rejected because
Direct Elixir function calls (agents import context modules) Tight coupling to Elixir; extraction in Phase 3+ requires agent rewrite; no typed tool-schema metadata for LLM function-calling
gRPC between agents and domains Not MCP-standard; adds protobuf toolchain; LLMs don't speak protobuf natively
REST HTTP calls (in-process loopback) JSON parsing overhead for every call; no typed contract; invents a protocol worse than MCP
JSON-RPC over local socket even within one node ~100µs per call vs ~1µs for direct; unnecessary at current scale. Part 6 decision: defer until domain extraction justifies the transport

Consequences

Positive:

  • Uniform agent-to-domain contract — agents don't need 19 bespoke interfaces
  • Typed tool schemas enable Claude's function-calling to use tools without glue code
  • Domain extraction (D12 phase 3) requires only a transport swap (behaviour → JSON-RPC adapter) — agent code unchanged
  • Permission matrix and IRAP restrictions centralised at MCP framework level
  • Agent audit trail naturally emerges from the framework (AW-14)

Negative:

  • Developers must write an MCP tool entry for every capability agents need
  • Tool schema drift risk if two tools in different domains diverge on naming conventions (mitigated by gold-standard template)
  • Slight overhead (context injection, logging) per tool call vs raw function call — negligible

Tipping points for re-evaluation:

  • MCP standard changes significantly in backward-incompatible ways — we'd likely pin a known-good version
  • A domain is extracted to another language (D12 phase 3) — MCP transport swap is the planned path; this is a strengthening, not a rejection

Relationship to Guardrails

Enforces: AR-16 (MCP at every domain boundary), AI-03 (tenant injection at MCP layer), AI-04 (agent processes never reused across tenants).