MCP Server Development: Build a Built-in MCP Server in Rust
Add a built-in MCP server in Rust when you are extending Octomind itself, from tool definitions and routing to verification.
Add a built-in MCP server in Rust when you are extending Octomind itself, from tool definitions and routing to verification. This contributor guide also covers role activation, error handling, and a complete example.
When to Add a New Server
Add a built-in server when you need:
- Deep integration with Octomind internals (session state, config)
- Functionality that doesn't make sense as an external process
- Tools that require access to the MCP coordinator
For external tools, use a configured stdio or http server; see MCP Tools. For a project
script, see Local Tools.
Terminology: the codebase defines a tool with the
McpFunctionstruct (aname,description, and JSON-Schemaparameters), and calls the runtime invocation anMcpToolCallthat returns anMcpToolResult. "Function" is the static definition; "tool" is the callable runtime surface.
Smallest reference to copy: read
src/mcp/core/functions.rs,src/mcp/core/recall.rs, and how thecoreserver is wired intosrc/mcp/mod.rs. Planning is supervisor-internal and is not a tool-definition example.
Built-in Servers
Four built-in servers are shipped as [[mcp.servers]] entries in config-templates/default.toml (core,
orchestration, runtime, agent):
| Server | Location | Tools |
|---|---|---|
core | src/mcp/core/ | recall when attention or attention governance is enabled |
orchestration | src/mcp/orchestration/ | tap, schedule, monitor |
runtime | src/mcp/runtime/ | mcp, agent, skill, capability |
agent | src/mcp/agent/ | one agent_<name> tool per configured agent (built from config.agents) |
filesystem is referenced by shipped roles but is not declared in the default server array. External tool surfaces come
from the resolved tap/capability or your configuration; inspect their discovered schemas instead of assuming a fixed
tool list (src/agent/registry.rs, src/mcp/server.rs).
Step-by-Step Guide
1. Create Server Module
The example below adds text_metrics, which exposes text_length to count Unicode scalar values in a string. These
names are example code to implement, not existing shipped tools.
src/mcp/
text_metrics/
mod.rs # Function definitions + execute fnStart every new Rust file with the repository's Apache 2.0 header.
2. Implement get_all_functions()
Return a list of McpFunction definitions. The parameters field is a JSON Schema built with serde_json::json!.
get_recall_function() in src/mcp/core/recall.rs is the smallest current example:
use crate::mcp::McpFunction;
use serde_json::json;
pub fn get_all_functions() -> Vec<McpFunction> {
vec![McpFunction {
name: "text_length".to_string(),
description: "Count Unicode scalar values in a non-empty string.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "The text to count",
"minLength": 1
}
},
"required": ["text"],
"additionalProperties": false
}),
}]
}If your function list depends on configuration, take &Config directly as shown under Config-Dependent
Functions.
3. Register Listing, Routing, and the Tool Map
Add a module declaration and three match arms: listing and execution in src/mcp/mod.rs, plus mapping in
src/mcp/tool_map.rs. Listing-only or execution-only wiring leaves a tool unavailable or unroutable.
(a) Declare the module near the other pub mod lines (src/mcp/mod.rs):
pub mod text_metrics;(b) Add a listing arm in server_functions_for so the tool shows up in the system prompt. For a stateless server,
use the cache helper get_filtered_server_functions:
"text_metrics" => {
get_filtered_server_functions("text_metrics", server.tools(), text_metrics::get_all_functions)
}(c) Add an execution arm in route_builtin_tool (src/mcp/mod.rs) that dispatches to your execute function and
maps a hard error into a soft error result:
"text_metrics" => {
let result = text_metrics::execute_tool(call)
.await
.map_err(|e| format!("text_metrics tool failed: {}", e));
match result {
Ok(mut r) => {
r.tool_id = call.tool_id.clone();
Ok(r)
}
Err(msg) => Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
msg,
)),
}
}(d) Add the builtin arm in build_tool_server_map_impl in src/mcp/tool_map.rs:
"text_metrics" => crate::mcp::get_filtered_server_functions(
"text_metrics",
server.tools(),
crate::mcp::text_metrics::get_all_functions,
),Both maps use the first configured server for a duplicate tool name. Choose a distinct tool name to avoid collisions
(build_tool_server_map and build_tool_server_map_impl).
4. Implement Tool Execution
Execute functions take the &McpToolCall (which carries tool_name, parameters, and tool_id) and usually
&Config. They return anyhow::Result<McpToolResult>. Read parameters off call.parameters:
use crate::mcp::{McpToolCall, McpToolResult};
use anyhow::Result;
pub async fn execute_tool(call: &McpToolCall) -> Result<McpToolResult> {
match call.tool_name.as_str() {
"text_length" => {
let text = match call.parameters.get("text").and_then(|v| v.as_str()) {
Some(v) if !v.is_empty() => v,
// Soft (user-facing) failure: return Ok(error result), do NOT bail with Err.
_ => return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"text must be a non-empty string".to_string(),
)),
};
Ok(McpToolResult::success(
call.tool_name.clone(),
call.tool_id.clone(),
text.chars().count().to_string(),
))
}
other => Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
format!("Unknown tool: {}", other),
)),
}
}Existing references for the exact signatures:
pub async fn execute_recall(call: &McpToolCall) -> Result<McpToolResult>(src/mcp/core/recall.rs)pub async fn execute_tap_command(call: &McpToolCall, config: &Config) -> Result<McpToolResult>(src/mcp/orchestration/tap.rs)pub async fn execute_runtime_tool(call: &McpToolCall, config: &Config) -> Result<McpToolResult>(src/mcp/runtime/mod.rs)
Error-handling contract:
- Soft / user-facing failures (missing param, bad input, tool-level rejection): return
Ok(McpToolResult::error(name, tool_id, msg)). The model sees the error text and can retry. - Unexpected handler failures should also be converted to
Ok(McpToolResult::error(...))at the handler boundary.route_builtin_tooldefensively wraps a returnedErr, but a tool implementation should not rely on that fallback for normal failures.
5. Add Config Registration
Register your server in config-templates/default.toml when shipping it. For a development run, add the following to a
sibling mcp-text-metrics.toml in your config directory after compiling the new handler. auto_bind enables it for the
exact role tag shown:
[[mcp.servers]]
name = "text_metrics"
type = "builtin"
timeout_seconds = 30
tools = []
auto_bind = ["developer:general"]tools = [] means all tools from this server are exposed. A non-empty array filters by exact name or trailing-*
prefix pattern (is_tool_allowed_by_patterns), e.g. tools = ["text_*"]. For external servers, the runtime overlay
(the capability tool with action = "enable") can additionally unlock tools that the static filter would otherwise
hide.
Alternatively, bind the server through a role's server_refs and allowed_tools. Add this new role to a sibling
roles.toml in your config directory:
[[roles]]
name = "text_counter"
system = "Use text_length to count the supplied text."
welcome = "Send text to count."
[roles.mcp]
server_refs = ["text_metrics"]
allowed_tools = ["text_metrics:*"]octomind run text_counterA server declaration alone does not enable it for every role. Do not add a partial duplicate [[roles]] entry:
same-name entries replace the whole role.
octomind config --validate
octomind run developer:generalIn that session:
/mcp
Use text_length to count the characters in "hello".The handler receives this internal McpToolCall shape and returns text 5:
{"tool_name":"text_length","parameters":{"text":"hello"},"tool_id":"demo-1"}6. Surface Misuse Hints (Optional)
There is no static hint table to declare. To nudge the model when it misuses a tool, push a hint imperatively from inside your execute function:
if crate::mcp::tool_map::get_server_for_tool("text_length").is_some() {
crate::mcp::hint_accumulator::push_hint(
"Use text_length when you need a Unicode scalar count of supplied text.",
);
}Hints are session-scoped, deduplicated, and drained once per tool round by the session layer (drain_hints()), then
injected as a single user-role message — so they guide the model without polluting individual tool-result strings
(src/mcp/hint_accumulator.rs).
Protocol Compliance
All tools must follow the MCP protocol:
- Return
McpToolResult::error(...)instead of panicking. - Validate all parameters with clear error messages.
- Handle missing/empty/wrong-type parameters gracefully.
- Long-running tools may accept
cancellation_token: Option<tokio::sync::watch::Receiver<bool>>(as theagentserver does). Otherwise cancellation is enforced centrally bytry_execute_tool_call, which races your future against the cancel signal viatokio::select!.execute_recallandexecute_tap_commandtake no token. McpToolResultis an internal wrapper withtool_name,tool_id, and a nested rmcpCallToolResult. Return the success/error helpers; the nested protocol result carries content blocks and an optionalisError. Builtin dispatch is in-process, so this wrapper is not itself an external JSON-RPC response.
Returning metadata alongside text
To attach structured metadata to a successful result, use McpToolResult::success_with_metadata(name, tool_id, text, json_value). The metadata is stored as the result's structured_content; extract_content() appends it to the text as
a [Metadata: ...] block (src/mcp/mod.rs). For example, this can replace the success return inside text_length:
Ok(McpToolResult::success_with_metadata(
call.tool_name.clone(),
call.tool_id.clone(),
text.chars().count().to_string(),
serde_json::json!({"unit": "unicode_scalar", "count": text.chars().count()}),
))Testing
Build a real McpToolCall and pass it to your execute function. Keep test bodies in a sibling file, matching the
repository convention. The production module only declares it:
#[cfg(test)]
#[path = "text_metrics_tests.rs"]
mod tests;In text_metrics_tests.rs, prepend the same license header and check both successful work and invalid parameters.
is_error() is a method:
use super::*;
use crate::mcp::McpToolCall;
#[tokio::test]
async fn counts_unicode_scalars() {
let call = McpToolCall {
tool_name: "text_length".to_string(),
parameters: serde_json::json!({"text": "aé"}),
tool_id: "test-id".to_string(),
};
let result = execute_tool(&call).await.unwrap();
assert!(!result.is_error());
assert!(matches!(
&result.result.content[0],
rmcp::model::ContentBlock::Text(text) if text.text == "2"
));
assert_eq!(result.tool_id, "test-id");
}
#[tokio::test]
async fn invalid_text_is_a_tool_error() {
for parameters in [
serde_json::json!({}),
serde_json::json!({"text": ""}),
serde_json::json!({"text": 42}),
] {
let call = McpToolCall {
tool_name: "text_length".to_string(),
parameters,
tool_id: "test-id".to_string(),
};
let result = execute_tool(&call).await.unwrap();
assert!(result.is_error());
assert_eq!(result.extract_content(), "text must be a non-empty string");
}
}After adding the example module and wiring, run:
cargo test mcp::text_metrics::tests
cargo check --all-targets --all-featuresAlso verify the tool is listed and routed through the configured role using the session example above; handler unit tests alone do not exercise all three registration arms.
Common Questions
| Symptom | Check |
|---|---|
| Tool never appears | Both listing arms, role server_refs/auto_bind, and tool filters |
| Listed tool cannot execute | route_builtin_tool and build_tool_server_map_impl |
| Wrong server handles the call | Duplicate tool names; the first configured server wins |
recall is absent | Both attention and governance are disabled, or the role excludes core |
| External server fails to start | Command/URL, missing environment substitutions, and captured stderr |
Use the session diagnostics before changing routing:
/mcp
/loglevel debugReference Patterns
Config-Dependent Functions
When a server's function list depends on config, implement get_all_functions(config: &Config) directly — there is no
separate wrapper. The agent server is the canonical example: it maps over config.agents to produce one
agent_<name> tool each (src/mcp/agent/functions.rs), and is wired in server_functions_for as:
"agent" => {
let fns = agent::get_all_functions(config);
filter_tools_by_patterns(fns, server.tools())
}Function Caching
Stateless built-in function lists are memoized through get_filtered_server_functions(...), which caches per
server_type + allowed-tools key in INTERNAL_FUNCTION_CACHE (src/mcp/mod.rs). The current runtime and
orchestration lists use this cache. Config-dependent servers (core and agent) skip it and call
get_all_functions(config) each time. clear_function_cache() empties the cache for tests or configuration changes.
Timeouts and Cancellation
timeout_seconds is a server field, not config.timeout. External MCP calls use an idle deadline refreshed by progress
notifications in src/mcp/client.rs. Builtin routing supplies cancellation, but does not automatically wrap every
handler in that server timeout. External calls also have an absolute cap of twenty times the idle timeout. Both hold
while the user answers an elicitation for the call (see below). A builtin that owns a long operation must implement its
deadline and return a soft error on expiry; copy its duration from the resolved server configuration.
This wrapper is a complete example around the handler above; call it from the execution arm if you need that policy:
pub async fn execute_with_timeout(
call: &crate::mcp::McpToolCall,
config: &crate::config::Config,
) -> anyhow::Result<crate::mcp::McpToolResult> {
use crate::mcp::McpToolResult;
let Some(server) = config.get_server_config("text_metrics") else {
return Ok(McpToolResult::error(
call.tool_name.clone(), call.tool_id.clone(), "text_metrics is not configured".into(),
));
};
match tokio::time::timeout(
std::time::Duration::from_secs(server.timeout_seconds()),
execute_tool(call),
).await {
Ok(result) => result,
Err(_) => Ok(McpToolResult::error(
call.tool_name.clone(), call.tool_id.clone(), "text_metrics timed out".into(),
)),
}
}Session-Ownership Checks for Dynamic Tools
If you add dynamic or runtime-registered tools (e.g. agent_* tools or dynamic servers), be aware that
execute_tool_without_cancellation (src/mcp/mod.rs) enforces session ownership: the global tool map can contain tools
registered by other sessions, so in a session context it verifies that a dynamic tool either is config-defined or
belongs to the current session, returning a "belongs to another session" error otherwise. Static built-in tools
(recall, tap, schedule, etc.) and project-local tools under the synthetic local server bypass this check.
Server Stderr Capture
For stdio servers, stderr lines are drained into a private SERVER_STDERR map (Arc<RwLock<HashMap<String, StderrBuffer>>> in src/mcp/process.rs) by background reader tasks and surfaced in initialization diagnostics. There
is no public get_server_stderr accessor.
Initialization Progress
To surface init progress to the UI, pass a callback to initialize_servers_for_role_with_callback(config, Some(&callback)) (src/mcp/mod.rs). It receives McpInitProgress::Starting { servers } before initialization and
McpInitProgress::Completed { server, success, function_count } as each external server finishes. Builtins need no
process startup. For example, inside an async function returning anyhow::Result<()>:
let callback = |progress: crate::mcp::McpInitProgress| {
crate::log_debug!("MCP initialization: {:?}", progress);
};
crate::mcp::initialize_servers_for_role_with_callback(config, Some(&callback)).await?;Session Context for External Servers
External servers receive the calling session in capabilities.experimental.session (build_client_info in
src/mcp/client.rs). Modern (2026-07-28) connections carry it in every request's _meta; legacy connections carry it
once in initialize. One connection to a server is shared by every session of the process, so each tool call and
each tools/list overrides the connect-time value with the calling session's context.
Tool lists are cached per server (FUNCTION_CACHE in src/mcp/server.rs) until the server restarts or sends
tools/list_changed. A list the server marks cacheScope: private is fetched once per session and dropped in
cleanup_session; any other list is fetched once and shared by every session.
| Field | Value |
|---|---|
role | Role domain (developer for developer:general) |
spec | Role spec (general), empty for a plain role |
project | octolib::utils::path_to_id(workdir): 16 hex chars hashed from the normalized git remote, or from the canonical path without one. A stable key, not for display |
session_id | Octomind session id; empty outside a session |
workdir | The session's anchor directory (absolute), not the current workdir override |
git | Whether workdir itself is a git repository root (not a parent repo) |
shell | Whether the session may call this server's shell tool right now (boolean). false when the role's tool filter for this server leaves shell out. Offer features that run commands (octofs's check parameter on edit tools) only when it is true. A legacy connection sends the value it had at connect time |
context_epoch | Generation of the session's model context (unsigned integer, modern connections only). It changes whenever the model loses tool results it was served: compaction, in-place trims, /run history rewrites, results discarded by a cancel, session (re)initialization. Values never repeat |
Rules for server authors:
- Resolve the context per request and key session state by
session_id. Never apply it once per connection: the next request can belong to a different session. - Use
projectwhen an opaque stable key is enough. For a readable key (github.com/org/repo, orlocal/<parent>/<dir>without a remote), deriveoctolib::utils::path_to_scope(workdir); both functions read the same git remote, so the two keys always name the same project. - Treat an absent
experimental.sessionas a client without sessions (Claude Code, other hosts) and fall back to the server's own root. - If the tool list depends on the session (parameters or allowed values that differ per session), mark the
tools/listresultcacheScope: private. A public list is shown to every session. - If a result is answered relative to what the server returned before (octofs delta views: "unchanged since your
last view"), keep that state per
session_idand drop it whencontext_epochchanges — the model may no longer hold it. Without acontext_epoch(legacy connections, other hosts) never answer relative to an earlier result.
Octobrain (memory scope from path_to_scope(workdir), private tool list naming the session's repos), octofs
(per-session workdir and view cache) and octocode (each session's directory served like mcp --auto) follow these
rules.
Elicitation
An external server's elicitation/create is answered by whoever answers for the session that made the tool call
(src/mcp/elicitation.rs). Each entry point registers that answerer with set_answerer, because it differs per
entry point and per client: the interactive CLI registers Answerer::Terminal (the reedline prompt in
src/session/chat/elicitation.rs); the WebSocket server lets it follow output ownership (SessionStream::set_owner in
src/websocket/stream.rs): Answerer::Client while the owning connection's bind listed elicitation_v1, none
otherwise, and with no live owner the session's open questions end; the ACP agent registers Client when the client's
initialize advertised clientCapabilities.elicitation. A Client question goes out as ServerMessage::Elicitation
on the session's notification channel (ACP translates it to its own elicitation/create), waits in a pending table, and
is answered through resolve. cleanup_session resolves a session's open questions as cancel; sessions with no
answerer decline.
Routing a question to its session (calls_awaiting_elicitation in src/mcp/client.rs) uses IN_FLIGHT_CALLS, the
registry of in-flight tools/call rounds keyed by server and request id:
- 2026-07-28 servers return the question as an
input_requiredround of the call itself, so it is answered in the call's own task and session (fulfill_input_requests), between rounds, with no round timer running. - Older servers over Streamable HTTP push it on the response stream of its call; rmcp tags it with that call's
request id (
InboundStreamOrigin::OutboundRequest). - Older servers over stdio carry no association (SEP-2260 defines no wire field): the question is routed only when every in-flight call on that server belongs to one session, otherwise declined.
A call made inside with_non_interactive (subagents, detached MCP tasks) is never asked on. While a question is open,
its call's awaiting_user counter holds both the idle deadline and the absolute cap; when the round ends its ended
token withdraws the question even if the server never cancels it. Capabilities follow the same rule:
per-request capabilities offer elicitation only when the calling session has an answerer; a legacy handshake, which
speaks for the whole shared connection, offers it when the process calls accept_elicitations (interactive CLI,
octomind server, octomind acp). Legacy servers are offered protocol 2025-11-25.