← All articles

Hexagonal Architecture for MCP Plugins

Every plugin in the I-Machine platform is structured as a hexagonal unit : Pure domain, application service with explicit ports, and adapters at the edges. The pattern is older than the codebase ; What this article describes is how we apply it to a plugin host serving an MCP server, a native ImGui client, and an Angular web client from the same business logic, across more than forty plugins.

The pattern, briefly

Hexagonal architecture organises a unit of code into three concentric layers :

  • Domain. Pure logic and data types. No I/O, no framework imports, no network, no UI. Trivially testable.
  • Application service (sometimes called “use cases” or “view-model”). The orchestration layer. It depends on the domain and on a set of ports (interfaces) that describe what it needs from the outside world. Still testable without a network.
  • Adapters. The implementations of the ports for a specific environment. The MCP server adapter exposes the application as tools ; The ImGui adapter renders it as panels ; The Angular adapter projects it to the browser. Each adapter is replaceable.

The flow of dependencies is one-way : Adapters depend on the application layer, the application layer depends on the domain, the domain depends on nothing. No layer ever imports a layer outside itself.

What a hexagonal plugin looks like

Take a concrete plugin from our codebase : The AssistantPlugin on the server, paired with an AssistantClient on the user side. It implements a ReAct-style agent loop with conversation history and pluggable LLM backends.

The directory layout reflects the layers :

plugins/AssistantPlugin/
  domain/              # ReActStep, ToolCall, ConversationId…
  app/                 # AssistantLoop, BrainFactory, ConversationRepo
  ports/               # IBrainPort, IConversationStore, IToolBridge
  adapters/
    mcp/               # tools.cpp — exposes app to MCP
    persistence/       # SQLiteConversationStore : IConversationStore
    brain/             # LocalBrain, ClaudeBrain : IBrainPort

client_plugins/AssistantClient/
  domain/              # ConversationVm, MessageVm…
  app/                 # ChatViewModel, ConversationListVm
  ports/               # IAssistantServerProxy
  adapters/
    server_proxy/      # AssistantServerProxy : IAssistantServerProxy
    ui/                # ChatPanel, BackendSelector, ConversationList…

Reading the layers, the contract is obvious : Domain has no imports outside std; app imports domain plus ports ; Adapters import everything they need to connect a port to the real world. The compilation system enforces this by giving each layer its own target with explicit include directories, you can’t accidentally import an adapter from the app layer because the include path doesn’t exist.

Why this pays off in practice

Replacing front-ends without touching logic

Half of our plugins originally shipped with only the ImGui native UI. When we added Angular, the application layer didn’t change. The new adapter implemented the same IServerProxy port over HTTP-to-WebSocket bridging, and the same view-models that drove ImGui panels drove Angular components via Observables. Without the hexagonal split this would have been a rewrite ; With it, it was a port implementation.

Tests that don’t depend on a server

The application service of each plugin is the level at which we’d want to write tests, it’s where the interesting logic lives. Because it depends only on ports, we pass in fakes that implement those ports without touching the network, the database, or the LLM. A test for the agent loop creates a FakeBrainPort that returns scripted responses, a FakeToolBridge that returns canned tool results, and verifies that the loop produces the right conversation transcript.

Reusable infrastructure

The same adapter shapes recur across every client plugin : A ServerProxy, a StatusMessage bus, a tool result parser, a remote file lister. They live in shared headers (source/include/Mcp/Client/ToolResultParser.h, PluginWindowBase.h, StatusMessage.h, RemoteFileLister.h) and every plugin reuses them rather than reinventing the wheel.

What you have to actually do for it to hold

Hexagonal pays compound interest over the lifetime of a plugin system, but the discipline isn’t automatic. Three things we make non-negotiable :

Accept the file count. A plugin that would be three files in a flat layout becomes ten or fifteen. That’s not overhead, that’s the layers being explicit instead of tangled. For a one-shot plugin with two tools, you don’t need the full split ; We keep a lightweight pattern for those. For anything that will be touched by more than one person over more than one quarter, you want the structure.

Design ports against the application’s needs, not the adapter’s shape. The trap is to make a port a thin wrapper around what the adapter happens to expose. That defeats the whole point, the port should reflect what the application needs, not what the adapter has. Get this wrong and the adapter leaks back through the port ; The app layer ends up coupled to it anyway, and the pattern is a decorative ceiling instead of a load-bearing wall.

Enforce the import direction in CI. Nobody accidentally adds a domain → adapter import the day they join. They add it three months in, under time pressure, because it’s the shortest path to fix something. A small CI script that scans imports per layer is twenty lines of Python and the single most effective discipline we have. Without that gate, the pattern erodes within a few weeks.

Where we go from here

Most plugins ship fully on the pattern. The remaining few are older artefacts in mid-migration ; The migration checklist is short : Extract domain types, extract ports, move adapter code, point the MCP tool layer at the application service, delete the old direct implementation.

For more context on how plugins fit into the larger MCP infrastructure, see Self-Hosted MCP Infrastructure for Enterprise and Plugin Hot-Reload in C++.

Talk to us about MCP infrastructure for your organisation. Get in touch.