Adding an MCP Action
July 31, 2026 · View on GitHub
axon-mcp exposes the entire Axon service surface as a single axon
tool, action-dispatched (action + optional subaction), never one MCP tool
per operation. This guide describes the real request-enum + dispatch-match +
handler pattern in crates/axon-mcp/src/.
See also: crate guide crates/axon-mcp/src/CLAUDE.md, behavior contract
docs/pipeline-unification/surfaces/tool-contract.md.
The core rule: every action routes to exactly one axon-services
entrypoint, and the tool schema is generated from shared axon-api DTOs —
never invent a duplicate action DTO or route around axon-services into a
domain crate's internals.
Step 1: Add the request variant
axon-api::mcp_schema::AxonRequest (crates/axon-api/src/mcp_schema.rs) is
the enum every dispatched action belongs to:
pub enum AxonRequest {
Status(StatusRequest),
Jobs(JobsRequest),
Memory(MemoryRequest),
Query(QueryRequest),
// ...
}
Add your new action's request type here (e.g. YourAction(YourActionRequest))
and define YourActionRequest/its subaction enum (if the action has
subactions, follow MemoryRequest/MemorySubaction's shape) alongside the
other request DTOs in this module — this is the wire schema shared by MCP,
REST, and CLI, not an MCP-only shape.
Step 2: Write the handler
Handlers live in crates/axon-mcp/src/server/handlers_<group>.rs — group by
domain, not one file per action (handlers_memory.rs, handlers_source.rs,
handlers_jobs.rs, handlers_extract.rs, handlers_query.rs,
handlers_system.rs). handlers_memory.rs::handle_memory is a clean,
complete reference example:
impl AxonMcpServer {
pub(super) async fn handle_memory(
&self,
req: MemoryRequest,
) -> Result<AxonToolResponse, ErrorData> {
let subaction = memory_subaction_label(req.subaction.unwrap_or(MemorySubaction::Remember));
let ctx = self
.base_service_context()
.await
.map_err(|e| internal_error(format!("initialize memory context: {e}")))?;
let data = memory_svc::dispatch(&ctx, req)
.await
.map_err(map_memory_error)?;
Ok(AxonToolResponse::ok("memory", subaction, data))
}
}
The shape to copy:
- Build a
ServiceContext(self.base_service_context()), mapping any context-construction failure tointernal_error(...). - Call the matching
axon_services::<domain>::dispatch(&ctx, req)— the handler's only job is to bridge the MCP wire type into the service call, never to reimplement logic locally. - Map
ClientActionError(the shared service error type) into the MCPErrorDatavia a smallmap_<domain>_errorhelper — route retryable/ internal errors tointernal_error, everything else toinvalid_params, matchingmap_memory_error's pattern exactly. - Return
AxonToolResponse::ok(<action-name>, <subaction-label>, data)— the shared envelope every action's success response uses.
Step 3: Wire the dispatch match
crates/axon-mcp/src/server.rs's axon(...) tool entrypoint parses the raw
action/subaction fields, builds an AxonRequest via parse_axon_request,
then dispatches:
let response = match request {
AxonRequest::Status(req) => self.handle_status(req).await?,
AxonRequest::Memory(req) => self.handle_memory(req).await?,
// ...
AxonRequest::YourAction(req) => self.handle_your_action(req).await?,
};
Add your new variant's arm here. The match is exhaustive — the compiler will
catch a missing arm the moment you add a new AxonRequest variant.
Removed actions stay in the enum but cannot dispatch
When an action is removed (folded into another action, e.g. embed/
ingest/scrape/crawl/code_search/vertical_scrape folding into
source), remove the AxonRequest variant from normal parsing/schema and add
an explicit pre-parse guidance entry in parse_axon_request. Removed action
tokens must fail closed before handler dispatch:
fn removed_action_guidance(action: &str) -> Option<&'static str> {
match action {
"crawl" => Some("use action=source with scope=site"),
"scrape" => Some("use action=source with scope=page"),
"embed" | "ingest" => Some("use action=source"),
_ => None,
}
}
Follow this pattern when retiring an action rather than leaving a hidden alias or an unreachable dispatch arm.
Step 4: Regenerate the tool schema
The MCP tool's input/output schema (crates/axon-mcp/src/schema.rs /
crates/axon-mcp/src/server/tool_schema.rs) is generated from the
axon-api DTOs, not hand-maintained. After adding a new action/request type:
just gen-mcp-schema
This keeps docs/reference/mcp/tool-schema.md (the generated runtime
snapshot) in sync with the actual dispatch surface.
Step 5: Tests
Add a sidecar _tests.rs per the repo convention. Look at
crates/axon-mcp/src/server/handlers_source_tests.rs and
crates/axon-mcp/src/server/tool_schema_tests.rs for the pattern —
handler-level tests exercise dispatch + error mapping; schema tests assert
generated schema shape and removed-action absence.
cargo test -p axon-mcp
Boundary reminders
- No source pipeline behavior or provider/store/domain internals in this
crate — route through
axon-services. - No duplicate action DTOs, and no CLI clap types or web router types here.
- No concrete Qdrant/TEI/LLM/SQLite clients.
- Allowed dependencies:
axon-api,axon-error,axon-core,axon-authz,axon-observe,axon-services, rmcp/MCP transport crates. Forbidden: domain crate internals bypassing services, provider clients, the CLI command parser, or the web router — enforced bycargo xtask check-layering. - Error envelopes must align with REST and CLI JSON output — every response is a structured envelope, not ad hoc MCP-only shape.