← Blog

Universal Access to Capability

The idea of separating core logic from its interfaces isn’t new. Alistair Cockburn described hexagonal architecture — ports and adapters — back in 2005 [1]. The core doesn’t know how it’s being called; adapters translate between the outside world and the domain.

We’ve been trying to apply that idea to tools built for an AI-native development stack. The approach we’ve landed on: write the logic once as a Python library, then project it through thin adapter layers into every consumption context — CLI, MCP [2] server, and REST API.

We started with three surfaces (library, CLI, MCP) and found that adding a fourth (REST) was straightforward enough that we made it the default. Each surface reaches a different consumer, and none of them required reimplementing the underlying logic.

The Projections

The library is the capability. Everything else is a projection — Cockburn’s adapters, applied to the specific surfaces that matter for developer tools today.

Python importfrom quarry import search. Use it in your application, your script, your notebook. No process boundary, no serialization, no protocol overhead. This is the native interface. Tests hit it directly.

CLITyper [3] wrapping the library. Args become function parameters. Return values become formatted output. Humans use it interactively. Shell scripts compose it with pipes. CI pipelines call it from workflow steps. The translation is mechanical: parse, call, format.

MCP serverFastMCP [4] (built by Jlowin and the community) wrapping the library. AI agents discover tools through Anthropic’s Model Context Protocol [2] and invoke them with structured parameters. Local via stdio (Claude Code spawns it as a subprocess) or remote via Streamable HTTP (deployed as a service, agents connect over the network). The two transports aren’t perfectly equivalent — stdio inherits the local user’s filesystem and credentials, while Streamable HTTP runs with its own identity. The library shouldn’t rely on ambient environment for the remote case to work cleanly.

REST APIFastAPI [5] wrapping the library. Mobile apps, web frontends, webhooks, third-party integrations. Standard HTTP, stateless, with an OpenAPI spec generated from type annotations. The interface every networked system already knows how to call.

Each projection serves a different consumer. Humans reach for the CLI. AI agents in MCP-aware hosts reach for MCP. Applications reach for REST or the import. Automation reaches for the CLI or the import. The goal is that each consumer reaches the capability through an interface suited to their context.

Why This Doesn’t Add Much Complexity

Each route, command, or tool definition is a thin mapping — deserialize parameters, call the library function, return the result. The per-function wiring is mechanical and template-driven.

Adding a fourth projection to a tool with three projections isn’t four times the complexity. It’s the same library with one more mapping file. The complexity of the system is the complexity of the library. The projections are boilerplate, and boilerplate that follows a template hasn’t been a maintenance burden for us so far.

This works because projections contain no business logic — no branching on domain state, no alternative code paths. Surface-specific concerns still exist: the REST layer handles authentication middleware and CORS, the CLI shapes errors into human-readable messages and exit codes, the MCP server returns structured errors the model can reason about, streaming responses need surface-appropriate handling. These concerns are real but thin. They don’t grow with the complexity of the domain. They’re per-surface constants, not per-function variables.

The discipline we try to follow: projections translate. They don’t decide.

Local and Remote

Each projection works locally and remotely. This is a deployment decision, not an interface decision.

The MCP server runs as a local subprocess or as a remote service. Same code, same tool definitions, different transport binding. The REST API runs on localhost for development or behind a load balancer for production. The CLI runs on your machine or in a CI runner.

Local vs. remote changes how you reach the capability. It doesn’t change what the capability does — provided the library doesn’t depend on ambient state. A library that reads ~/.config/ implicitly will work over stdio and fail over Streamable HTTP. Explicit dependency injection for credentials, paths, and configuration is what makes the local/remote equivalence hold.

The Forcing Function

This is the part that surprised us. Four projections demand a clean library API, and the pressure comes from different directions simultaneously.

If the CLI can’t express a function call as command-line arguments, the function signature is too complicated. If the MCP server can’t describe a tool’s parameters as a JSON schema, the types are too loose. If the REST API can’t serialize the return value, the data model is wrong. If the Python import requires setup that the other three projections handle silently, the library has hidden dependencies.

Each projection stress-tests the API from a different angle. A function that projects cleanly through all four surfaces has a clear name, typed parameters, a serializable return value, and no hidden state.

Not every function projects to every surface. A search function with structured query objects, filters, and pagination exposes its full capability through the Python import and REST, while the CLI exposes a useful subset — simple queries that map to positional args and flags. The distinction is between accidental complexity (fix the function) and essential complexity (project the surface-appropriate subset).

Here’s what this looks like concretely. In Quarry, the library function:

def search(query: str, db: str = "default", limit: int = 10) -> list[Result]:

The CLI: quarry search "how does auth work" --db myproject --limit 5. The MCP tool: same parameters as a JSON schema, same return type serialized. The REST endpoint: GET /api/search?q=...&db=...&limit=.... Four surfaces, one function, no adapters. When a function is this clean, projection is mechanical. When it isn’t, the right fix is usually to fix the function — not to patch the projection.

Scaffolding the Pattern

punt-kit defines this pattern once. punt init scaffolds a new tool with all four surfaces wired from the first commit. The cost of the pattern — choosing the libraries, writing the templates, documenting the conventions — is paid once. Every new tool inherits the same structure.

We’re a small team and this is still early. The pattern has worked well for our handful of tools, but we haven’t tested it at scale or across trust boundaries. We’re sharing it because the idea of a library core with thin adapter surfaces is well-established in software architecture — we’ve just found a concrete way to apply it that fits the current landscape of agents, CLIs, and APIs coexisting. See also the companion post on choosing the right projection for a decision framework on when to use each surface.

References

  1. Cockburn, A. “Hexagonal Architecture.” 2005. alistair.cockburn.us
  2. Anthropic. “Model Context Protocol.” 2024–present. modelcontextprotocol.io
  3. Ramírez, S. “Typer.” 2020–present. typer.tiangolo.com
  4. Jlowin et al. “FastMCP.” 2024–present. gofastmcp.com
  5. Ramírez, S. “FastAPI.” 2018–present. fastapi.tiangolo.com