Building a Trello MCP Server with Python and FastMCP

I use Trello for everything — project tracking, blog ideas, house stuff. I also use Claude Code for most of my development work. At some point I thought: wouldn't it be useful if Claude could actually read my boards, rather than me copying card titles into the conversation by hand? Ten minutes of reading later, I'd decided to write an MCP server.
MCP — the Model Context Protocol — is the standard that lets AI assistants talk to external tools. Claude Code supports it. Trello has a REST API. The gap between those two things turns out to be surprisingly thin.
FastMCP makes the server part easy
The server is built with FastMCP, a Python library that strips out most of the ceremony. You create a server, decorate functions with @mcp.tool(), and FastMCP handles the protocol plumbing.
The full server is about 150 lines. Here's the shape of it:
from mcp.server.fastmcp import FastMCP
import httpx, os
mcp = FastMCP("trello-mcp")
BASE = "https://api.trello.com/1"
def _auth() -> dict:
key = os.environ.get("TRELLO_API_KEY", "")
token = os.environ.get("TRELLO_TOKEN", "")
if not key or not token:
raise RuntimeError("TRELLO_API_KEY and TRELLO_TOKEN must be set")
return {"key": key, "token": token}
def _get(path: str, **params):
r = httpx.get(f"{BASE}{path}", params={**_auth(), **params}, timeout=10)
r.raise_for_status()
return r.json()
Authentication is just two environment variables passed as query parameters on every request. Trello's API is old enough that this is standard — no OAuth dance, no refresh tokens, just a key and a token appended to the URL. Unglamorous, but it works.
The tools themselves are thin wrappers around the Trello REST API. Reading boards:
@mcp.tool()
def list_boards() -> list[dict]:
"""List all Trello boards accessible to the authenticated user."""
boards = _get("/members/me/boards", fields="id,name,url,closed")
return [{"id": b["id"], "name": b["name"], "url": b["url"], "closed": b["closed"]}
for b in boards]
The docstring becomes the tool description that Claude sees. Keep it accurate — Claude uses it to decide when to call the tool. The fields parameter on the Trello request is worth including; without it you get back a large JSON blob with dozens of fields, most of which you don't need.
The write tools follow the same pattern. Creating a card:
@mcp.tool()
def create_card(list_id: str, name: str, desc: str = "", pos: str = "bottom") -> dict:
"""Create a card in a list. pos can be 'top', 'bottom', or a number."""
result = _post("/cards", idList=list_id, name=name, desc=desc, pos=pos)
return {"id": result["id"], "name": result["name"], "url": result["url"]}
Updating a card is the one slightly awkward tool, because you want to update only the fields you pass — not clobber everything else with None. The solution is obvious once you see it but I had to think about it for a moment:
@mcp.tool()
def update_card(card_id: str, name: str = None, desc: str = None,
pos: str = None, list_id: str = None) -> dict:
"""Update a card's name, description, position, or list (move it)."""
data = {}
if name is not None: data["name"] = name
if desc is not None: data["desc"] = desc
if pos is not None: data["pos"] = pos
if list_id is not None: data["idList"] = list_id
result = _put(f"/cards/{card_id}", **data)
return {"id": result["id"], "name": result["name"], "url": result["url"]}
Build up a dict of only the fields that were provided, then unpack it into the request. Simple.
The entry point is two lines:
def main():
mcp.run()
mcp.run() defaults to stdio transport — Claude Code spawns the server as a subprocess and communicates over stdin/stdout pipes. The server stays running for the session.
The part that actually took time
Writing the server took about an hour. Wiring it into Claude Code took the rest of the afternoon.
The FastMCP documentation and various blog posts suggest adding your server to ~/.claude/mcp.json. This file doesn't work. Or more precisely, Claude Code CLI doesn't read it. The correct mechanism is claude mcp add:
claude mcp add trello \
-e TRELLO_API_KEY=your-api-key \
-e TRELLO_TOKEN=your-token \
-- uv --directory ~/repos/nakomis/mcps/trello-mcp run trello-mcp
This writes the configuration to ~/.claude.json (no directory, not ~/.claude/mcp.json) and scopes it to your current project. Run claude mcp list to confirm the server connects before restarting.
The -e flags must come before the -- separator, not after. Putting them after appends them as arguments to the command, which passes them to uv instead of setting them as environment variables. uv ignores them silently. This is not a great failure mode.
Two other things that wasted time:
The uv PATH problem. When Claude Code spawns a server subprocess, it uses a restricted PATH. On macOS with Homebrew, uv lives at /opt/homebrew/bin/uv — outside that restricted PATH. Using uv as the command in the config works fine when you test it in a terminal; it fails silently when Claude Code tries to start the server. Use the full path.
Expired tokens. Trello API tokens can be generated with varying expiry times. The one I'd set up previously had expired. The server connects (the subprocess starts), but every API call returns 401. If the MCP loads but every tool call fails, check your token first.

What you get
Once it's running, Claude can read and write your Trello boards directly. The full set of tools covers boards, lists, and cards — listing, creating, updating, moving, archiving, searching, and managing labels. Enough to actually use it as a task management interface rather than just a read-only window.
The first thing I did after getting it working was ask Claude what blog topics I had queued up. It listed them. I picked one. This post is the result.
Martin Harris writes about connecting real hardware to abstract cloud infrastructure. If you've ever soldered something to an API Gateway, you're in the right place.
If you find this blog useful, please consider a donation.
