Building an MCP Server in 300 Lines of Python
I kept copying messages between Discord and Claude Code manually. Someone would ask a question in a server, I'd paste it into Claude, get an answer, paste it back. The obvious fix was giving Claude direct Discord access through MCP.
Most Discord integrations use discord.py or a similar bot framework. That's 50+ dependencies and a persistent WebSocket connection for what I needed, which was just "read this channel" and "send this message." The Discord REST API handles both with simple HTTP calls. An MCP server that wraps those calls is maybe 300 lines of Python.
The Architecture
The entire server is one file (server.py) with two dependencies: mcp (Anthropic's MCP library) and aiohttp (async HTTP client). It exposes 8 tools to Claude Code:
send_message/read_messagesfor channelssend_dm/read_dmsfor direct messageslist_guild_channels/get_channel_infofor discoverysearch_messagesfor finding specific contentadd_reactionfor emoji reactions
Communication with Claude Code happens over stdio (not HTTP), which is what MCP expects. The server reads JSON-RPC messages from stdin, processes them, writes responses to stdout. No web server, no ports, no networking between Claude and the MCP server.
Alias Resolution
Discord uses numeric IDs for everything. Channel #general might be 1234567890123456. Nobody wants to type that.
The config file maps friendly names to IDs:
{
"channels": {
"general": "1234567890123456",
"dev-chat": "9876543210987654"
},
"dms": {
"alice": "1111111111111111",
"bob": "2222222222222222"
}
}The resolve_channel() function checks the config first, falls back to treating the input as a raw ID. Claude can say "read the last 10 messages from dev-chat" and it resolves correctly. If Claude has the numeric ID (from a previous list_guild_channels call), that works too.
Why Low-Level MCP, Not FastMCP
The MCP library ships with FastMCP, a higher-level wrapper that simplifies tool registration. I used the low-level mcp.server.Server API instead because Claude Code's MCP client had compatibility issues with some FastMCP patterns at the time.
The low-level API means explicitly registering list_tools and call_tool handlers:
@server.list_tools()
async def list_tools():
return [
Tool(name="send_message", description="...", inputSchema={...}),
Tool(name="read_messages", description="...", inputSchema={...}),
# ... 6 more
]
@server.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "send_message":
return await handle_send_message(arguments)
elif name == "read_messages":
return await handle_read_messages(arguments)
# ...More boilerplate than FastMCP, but it works reliably with Claude Code's MCP client and I have full control over argument validation and error formatting.
Client-Side Search
Discord's server-side search API has rate limits and requires bot permissions that aren't always available. The search_messages tool fetches the last N messages and filters client-side with case-insensitive substring matching.
For a channel with active conversation, fetching 100 messages and filtering is faster than dealing with Discord's search API rate limits. For searching across months of history, it wouldn't scale, but that's not the use case. The use case is "did anyone mention the deploy in the last hour?"
Error Handling
Every Discord API call is wrapped in try/except. Network failures return a structured error message to Claude instead of crashing the server. API errors (wrong channel ID, missing permissions, rate limits) return the status code and Discord's error message.
The server distinguishes between 204 (success, no content) and other 2xx responses. Discord returns 204 for reactions and some delete operations. Trying to parse JSON from a 204 response was a bug that took longer to find than it should have.
What I'd Do Differently
Add message threading support. Discord threads are their own channels with a parent reference. The current tool set treats them as regular channels, which works for reading/writing but doesn't expose the thread relationship. A list_threads tool and thread-aware send_message would be more useful.
The search should support regex. Client-side filtering with substring matching is fine for simple queries but falls apart for anything structural. "Find messages that contain a URL" or "find messages from alice that mention deployment" need regex or multi-field filtering.