Adding an MCP Tool
This page walks through adding a new tool to the embedded MCP server,
the one bound on MCP_PORT (default 9090) inside the bot process.
By the end you’ll have an end-to-end example: a parameter struct, a
#[tool]-annotated handler, and a working tool call from an MCP
client like Claude Code or the bundled gateway.
If you’re confused about what MCP is or where it sits in the
architecture, read the MCP Server feature page
first. This page assumes you know that the bot exposes a JSON-RPC
endpoint, that tools live on the DiscordTools impl in
src/mcp/tools.rs, and that the
rmcp crate’s
#[tool] and #[tool_router] macros do the heavy lifting.
What “adding a tool” means
Three pieces, in this order:
- A parameter struct that derives
DeserializeandJsonSchemasormcpcan produce a JSON Schema for clients. - An async method on
impl DiscordToolsannotated with#[tool(description = "...")]. - Nothing else. Registration is automatic. The
#[tool_router(router = tool_router)]macro on the impl block discovers every method tagged with#[tool]and wires them into theToolRouter. There is no list to keep in sync. There is no second file to edit.
This is the single most important thing to internalise: write the
function, the macro registers it. If your new tool doesn’t show up
after a rebuild, it’s because the macro didn’t see it — and the cause
is almost always a missing #[tool(...)] attribute or putting the
function inside a separate impl block from the one tagged with
#[tool_router].
Open the file
Every tool in the codebase lives in
src/mcp/tools.rs.
Read the top of the file to see how it’s organised:
- The
useblock importsrmcptypes and theParameterswrapper. - A run of parameter structs at the top —
CreateChannelParams,EditRoleParams,BanParams, etc. Each one is its own#[derive(Debug, Deserialize, JsonSchema)] pub struct. - A few small helpers:
parse_id,channel_type_num,parse_duration_secs, thediscord_call!macro that wraps every Serenity HTTP call with a 10-second timeout. - The
ServerHandlerimpl that wireslist_toolsandcall_toolthrough the router. - The big
#[tool_router(router = tool_router)] impl DiscordToolsblock — every tool lives in this one block, with section dividers like// ===== CHANNELS =====.
Add your new tool to the bottom of an appropriate section, or create a new section if you’re starting a new area.
Step 1: Write the parameter struct
Worked example: a lookup_user_by_name tool that takes a username
substring and returns matching members. Define the params:
#[derive(Debug, Deserialize, JsonSchema)]
pub struct LookupUserByNameParams {
/// Guild/server ID (optional, defaults to configured guild)
pub guild_id: Option<String>,
/// Substring to match against display name or username (case-insensitive)
pub query: String,
/// Max results (1-50, default 10)
pub limit: Option<u8>,
}
A few things to copy from the existing tools:
DeserializeandJsonSchemaare both required. The first parses incoming JSON, the second produces the schema MCP clients see intools/list.- Doc comments on every field become the schema’s per-property descriptions. A client like Claude reads them. Spend the extra thirty seconds on each one.
guild_idis always optional and always at the top when the tool can be run against a guild. Theresolve_guild()helper onDiscordToolsfalls back to the configured guild when it’sNone, which keeps the per-instance MCP setup ergonomic.- IDs are
String, notu64. Discord snowflakes are 64-bit, but JSON numbers aren’t reliably 64-bit on the client side, so the wire format is always strings. Parse tou64inside the handler with theparse_idhelper. - Use
Option<T>for any field that has a default, then apply the default inside the handler with.unwrap_or(...). Don’t fightserdewith#[serde(default = "...")]unless the default is expensive enough to matter.
Step 2: Write the handler
Add the method to the impl DiscordTools block tagged with
#[tool_router]. The body is your code; everything around it is
boilerplate copied from a neighbour:
#[tool(description = "Find members whose display name or username contains the substring (case-insensitive)")]
async fn lookup_user_by_name(
&self,
params: Parameters<LookupUserByNameParams>,
) -> Result<CallToolResult, McpError> {
let p = params.0;
let gid = self.resolve_guild(p.guild_id.as_deref())?;
let limit = p.limit.unwrap_or(10).clamp(1, 50);
let query = p.query.to_lowercase();
let members = discord_call!(self.http.get_guild_members(gid, Some(1000), None));
let matches: Vec<String> = members
.iter()
.filter(|m| {
m.display_name().to_lowercase().contains(&query)
|| m.user.name.to_lowercase().contains(&query)
})
.take(limit as usize)
.map(|m| {
format!(
"{} (@{}) | ID: {}",
m.display_name(),
m.user.name,
m.user.id
)
})
.collect();
let body = if matches.is_empty() {
format!("No members matching '{}'.", p.query)
} else {
format!("{} match(es):\n{}", matches.len(), matches.join("\n"))
};
Ok(CallToolResult::success(vec![Content::text(body)]))
}
Notes on the patterns this copies:
#[tool(description = "...")]is what makes the macro pick up the function. The description is what the MCP client sees in the tool list — write it for an LLM, not a person. State what the tool does and what kind of input it wants. If the tool is privileged (sends messages, deletes data, mutates state) say so in the description, the same waysend_messagedoes (“PRIVILEGED — recommend manual approval”). MCP clients generally surface that text in their approval UIs.params: Parameters<YourParams>is the universal signature for tools that take input. Pullp = params.0at the top.self.resolve_guild(...)is the helper for the optional guild-id pattern. Prefer it over readingparams.guild_iddirectly — it gives you the configured guild as a fallback and keeps the error type consistent.discord_call!(...)wraps every Serenity HTTP call in a 10-second timeout and converts errors intoMcpErrorviaapi_err. Don’t callself.http.<method>().await?directly; you lose the timeout guard and you have to write the error mapping by hand.- The return body is plain text in
Content::text(...). MCP supports structured content but this codebase keeps everything as human-readable text — the consumer is usually an LLM that handles prose just fine. If your tool returns a list, format it line by line; if it returns a count plus a list, prepend the count. Match the existing tools’ shape. - Validation errors use
McpError::invalid_params(...). Internal failures useMcpError::internal_error(...). The helpersapi_errandtimeout_errcover the common cases.
That’s the whole tool. Build:
CONFIG_DIR=instances/local cargo run
Then issue a tools/list against the MCP server (or restart your
Claude Code session and ask it to list discord-bot tools). Your tool
will be there, with the description and the JSON Schema you wrote.
Step 3: Test it from a client
The simplest end-to-end check is an HTTP call with curl:
curl -s -X POST http://127.0.0.1:9090/mcp \
-H 'content-type: application/json' \
-H 'authorization: Bearer YOUR_MCP_TOKEN' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "lookup_user_by_name",
"arguments": { "query": "alice", "limit": 5 }
}
}'
(Drop the authorization header if you didn’t set MCP_AUTH_TOKEN.)
A working response wraps the text body in MCP’s content envelope. A broken one tells you what’s wrong:
Method not found— your tool isn’t registered. The macro didn’t see it. Re-check the#[tool(description = "...")]attribute and that the function lives inside the same impl block as the#[tool_router]attribute.Invalid parameters— your params struct doesn’t match what you sent. Either the JSON Schema published intools/listdisagrees with the call, or a required field is missing. The error message names the field.Discord API error: ...— the underlying Serenity call failed. Most often a permission issue (the bot isn’t allowed to do what you asked) or a bad ID.Discord API request timed out— the call exceeded the 10-secondAPI_TIMEOUTconstant. This is rare for read calls and usually indicates an upstream Discord problem.
Patterns from the existing tools
Most of what you’d need is already in tools.rs. A few patterns worth
knowing about explicitly:
- Channel-scoped operations that don’t strictly need a
guild_idfor the API call still take it as an optional param for clarity. Seesend_message,delete_messages,edit_channel. They pulllet _gid = self.resolve_guild(p.guild_id.as_deref())?;to validate the param shape and discard the result. - Bulk vs single delete —
delete_messagescallsdelete_messageson the channel for >1 ID anddelete_messagefor exactly 1, because the bulk endpoint rejects single-message deletes. Discord API quirk; copy the pattern. - Permission overrides —
set_channel_permissionsaccepts permission bits as decimal strings (because JSON numbers can lose precision), parses them with.parse::<u64>().unwrap_or(0), and builds aPermissionOverwritestruct. Mirror this if you need to pass any 64-bit field over the wire. - Pagination —
list_membersuseslimitplusafter(the last user ID seen). MCP clients are expected to call repeatedly; don’t try to fetch unbounded data in one tool call. - Description tags for privileged operations —
send_messagecontains “PRIVILEGED — recommend manual approval” in its description. Add the same hint to any tool that mutates Discord state in a way you’d want a human to confirm.
When the tool needs more than a Parameters<T>
Most tools take one params struct. If yours takes none — for example,
list_guilds — drop the params argument entirely:
#[tool(description = "List all Discord servers (guilds) this bot is connected to")]
async fn list_guilds(&self) -> Result<CallToolResult, McpError> {
let guilds = discord_call!(self.http.get_guilds(None, None));
// ...
Ok(CallToolResult::success(vec![Content::text(/* ... */)]))
}
rmcp synthesises an empty schema for tools with no params.
If you want to access state that lives on Data (the bot’s main state
struct, not the MCP server’s), you’ll have to thread it through. The
current DiscordTools::new constructor takes only Arc<Http> and a
GuildId. To add database access, add a field for the pool, extend
new, and update the call site in src/mcp/mod.rs where
DiscordTools::new is invoked. There’s no example of this in the
codebase yet — every existing tool only needs Discord HTTP — but the
shape is straightforward: clone the Arc<sqlx::PgPool> into the
struct the same way http: Arc<Http> is cloned today.
Documentation
After your tool ships:
- Add a row to
docs/reference/mcp-tool-catalog.mddescribing the tool, its parameters, and an example call. - Update
CHANGELOG.mdunder[Unreleased]inAdded.
Common gotchas
- Forgot
#[tool(description = "...")]. The function compiles as a regular method, the macro ignores it, and the tool never appears intools/list. - Forgot
JsonSchemaon the params struct. You get a confusing trait-bound error from the macro expansion. Add the derive. - Used
u64for an ID. The wire format will accept it sometimes and lose precision other times. AlwaysStringin the params struct, parsed withparse_idinside the handler. - Skipped
discord_call!. Your tool can hang for minutes on a slow Discord response, blocking the MCP worker. Always wrap. - Returned a 10MB response. MCP clients don’t enjoy this any
more than humans do. Truncate large lists, paginate, or summarise.
list_memberscaps at 1000 per call. - Two
impl DiscordToolsblocks. Only one of them is the#[tool_router]block. Tools in any otherimplare invisible.
Next steps
- MCP Server feature page — what capabilities the bot already exposes and how external clients connect.
- MCP Gateway Routing — how tool calls find the right instance in a multi-bot deployment.
- Adding a Feature Module — when your MCP tool is part of a larger new area of the bot.