Skip to content

Architecture

Overview

axm-mcp supplies an MCP server, entry-point discovery, a compact tool catalog, execution wrappers and lifecycle commands. Business operations live in installed tool packages. The server itself registers verify and web_fetch; it publishes no axm.tools entry point for those built-ins.

flowchart LR
    Packages["Installed axm.tools entry points"] --> Discovery["Discovery"]
    Discovery --> Catalog["Tool catalog"]
    Catalog --> Facade["Facade meta-tools"]
    Discovery --> Direct["Direct hot path"]
    Facade --> Wrappers["Shared wrapper factory"]
    Direct --> Wrappers
    Wrappers --> Tools["AXMTool.execute"]

A class entry point is instantiated without arguments. A plain callable is retained as-is. Failed loads are logged and skipped. The registry is a startup snapshot; installing a new package requires restarting the server.

Transport and policy

Stdio runs inside the process launched by the client. The client decides whether to share or restart that process; the server does not enforce one process per conversation.

HTTP keeps one process available to multiple clients. Imported module state, tool instances and any provider caches can be reused. This does not provide durable sessions or cache persistence across process restarts.

Serving policy is independent of transport: dedicated HTTP uses an environment-backed write contract when present; shared HTTP resolves a contract by MCP session identity. Authority travels with that contract: a bound session is restricted to its perimeter, while an unbound session is the local operator and runs without a write perimeter. No default contract is fabricated.

A bound shared session also cannot invoke a capability whose filesystem effects cannot be derived from its payload. That policy is deliberately separate from the catalog of ordinary mutation tools: adding command execution to that catalog would only validate declared paths while leaving the command free to touch anything. UNSCOPED_EXECUTION_TOOLS therefore identifies run_command as an unscopable capability and the wrapper refuses it before the normal write-scope decision. The refusal is keyed on both shared mode and the presence of a contract, so it does not change dedicated mode or the unbound local-operator case.

The header-binding mechanism and its limitations are described in shared contracts.

Port ownership

A listening point is a profile-owned resource, so resolve_http_port() does not decide one. It delegates to axm_config.service_port("mcp"), the single seam every port path in this package funnels through. Production keeps the adopted 9427 unchanged; any other profile gets a port derived from the profile name in the 20000-49151 band, deterministic across restarts so two installations on one machine do not fight over a socket; and the historical AXM_MCP_PORT variable is still honoured, through that resolver's alias registry rather than by a read performed here.

That last point is the reason this package reads no port variable of its own: a concurrent local read would short-circuit the upstream environment > file > default precedence instead of deferring to it. The consequence for an operator is that a self-contained installation running under its own profile starts without anyone supplying a port, where the previous local decision refused outside production.

Every port path funnels through that same seam rather than duplicating it. generate_plist() and lifecycle.install() take port: int | None = None and call resolve_http_port() when the caller supplied nothing, at render time instead of binding a constant at import time. The serve, status and install CLI commands now do the same, and server.serve() resolves there too instead of re-reading AXM_MCP_PORT for its own account — a concurrent local read would short-circuit the upstream precedence the paragraph above defers to. No DEFAULT_PORT constant survives anywhere in the package, so exactly one value decides where the service listens. An explicit port still wins verbatim — the resolution only supplies a value nobody chose, it never pre-empts a choice.

Execution and data

Direct tools and catalog calls are constructed using build_wrappers. In facade mode both receive the same registration policy. The facade uses the asynchronous catalog route so HTTP offloading and locks also apply to tools absent from the direct list.

The wrapper resolves access, unwraps nested kwargs, warns about certain implicit paths, runs the tool, records an external trace when available and renders the result. Trace integration is best-effort; scope refusals occur before normal execution tracing. It is not a durable audit log for every rejected request.

String results and ToolResult text are intended for the model. They do not preserve structured data as an additional MCP channel. Failures with nonempty text keep their diagnostics; data-only results use an envelope. See the exact result behavior.

Concurrency

In HTTP mode synchronous tool bodies run through asyncio.to_thread. A slow synchronous call therefore does not directly occupy the event loop. This is not a bound on provider resource use or a guarantee that every provider is thread-safe.

The async wrapper additionally selects in-process keyed locks:

Calls Key
Git-prefixed tools Explicit path
Session-prefixed dispatcher family Explicit session_id
write_file, edit_file Explicit target path
batch_edit Normalized root plus each operations[].file, deduplicated and acquired in sorted order

The session-prefixed dispatcher family is matched by the literal protocol_ name prefix. This is a wrapper routing rule; it does not declare any such tools in this package.

Without the expected key, the tool still runs in a worker thread but lacks that keyed serialization. These locks coordinate calls within one process, not external writers or other server processes. They do not cover every mutation tool (for example batch_rollback) and cannot substitute for a tool's own atomicity/rollback behavior. ToolCatalog.call() is synchronous and does not take HTTP async locks; acall() is the lock-aware route.

Lock acquisition timeout becomes a resource-busy error. Idle entries are reaped on release. Stdio calls execute inline with the HTTP locking/offload mode disabled.

Implementation map

Module Responsibility
cli, server Process lifecycle, PID handling, HTTP serve and health
settings, daemon, lifecycle Policy/port/PID resolution, supervisor descriptor, launchd install
mcp_app Startup registration and HTTP contract middleware
discovery, schema Entry points and callable signatures
facade Search, describe, execute and capability text
wrapping, concurrency Result/exception handling, access checks, thread offload and keyed locks
session_contracts In-memory identity-to-contract bindings
verify, verify_format Aggregation and human-readable quality output
web_fetch Optional Scrapling adapter

These implementation modules are not all a root-exported SDK. Python API distinguishes the package contract from implementation seams.