Giridhar Chettiar

Full Stack Developer and AI enthusiast with a passion for creating intuitive, high-performance applications.

Quick Links

  • Home
  • About
  • Projects
  • Blogs
  • Contact

Let's Connect

  • GitHub
  • LinkedIn
  • Instagram
  • Email

Contact

  • giri.chettiar@gmail.com
  • Adelaide, Australia

© 2026 Giridhar Chettiar. All rights reserved.

Privacy PolicyTerms of ServiceSitemap
Logo
CONTACT
Logo
HOMEABOUTPROJECTSREVIEWSBLOGSIDEAS
CONTACT
Logo
HOMEABOUTPROJECTSREVIEWSBLOGSIDEASCONTACT
AI & ML
June 29, 2026
21 min read

MCP — The How

Part 3 of a 3-part series on MCP. The why and the what are done — now we build. From an empty file to a working server with FastMCP, watch a single request cross the wire, then flip one line to go remote and ship it to the cloud. Including the afternoon it broke in production and the one-word fix. Six playable diagrams; real, runnable code.

Giridhar Chettiar
Giridhar Chettiar
Author
MCP — The How
Contents11 sections
  1. 1The three ways in
  2. 2Act I · Borrowing servers you didn't build
  3. 3FastMCP, or: you won't write the boilerplate
  4. 4Act II · From an empty file to a working tool
  5. 5Watching a single request cross the wire
  6. 6Act III · The one line that makes it remote
  7. Shipping it: deploy to FastMCP Cloud

MCP — The How

3-Part Series
Part 1
The Why
← Read it
Part 2
The What
← Read it
Part 3
The How
You are here

Two posts ago I told you why the plug exists. Last post I unscrewed the faceplate and named every wire — Host, Client, Server, the three primitives, the JSON-RPC, the transport. Good plumbing. But a diagram you can't run is just a nicer-looking diagram.

So this is the one where I stop pointing and start typing. We're going to use a server somebody else built, build one from an empty file, watch a single sentence I type turn into a database row, and then flip exactly one line to put that server on the internet. I'll show you the afternoon it broke in production too, because the bug is more instructive than the happy path.

Everything here traces back to the same CampusX trilogy I've been crediting all series — but the code is real, pulled from the actual repos, and I've made the diagrams playable so you can drive each step instead of watching me.

The three ways in

Before any code, the map that made it click for me. There are only three ways an AI ever talks to a tool, and they differ by who you bring to each side of the connection:

01 · borrow / borrow
Ready-made client → existing server
Use Claude Desktop as the client and plug into a server someone already published — Google Drive, GitHub, filesystem. Zero code. This is most people's whole relationship with MCP.
02 · borrow / build
Ready-made client → your server
Keep Claude Desktop as the client, but the server is yours — your tools, your data. This is the sweet spot, and where most of this post lives.
03 · build / build
Your client → your server
Write both ends yourself. Powerful, rarely necessary, and a whole post of its own. We'll wave at it and move on.

This post is options ① and ② — the two that pay rent. We'll start by borrowing, because it's the fastest way to feel the protocol working, then build the server that makes ② yours.

Act I · Borrowing servers you didn't build

The fastest MCP win costs zero lines of code: point a client you already have at a server someone already wrote. The client here is Claude Desktop, and it gives you two doors into a server. Same destination, very different ergonomics — tap between them:

Click, don't configure. Settings → Connectors. Anthropic curates a gallery — Asana, Box, Gmail, Google Drive, Filesystem — each with a + to add it. Some need one setting; the Filesystem connector, for example, asks you to pick "Allowed Directories", then exposes 11 tools (read_file, write_file, edit_file, …). Cloud ones sign you in with OAuth. No JSON, no transports, no terminal.
who it's forMost Claude Desktop users are non-technical. They want Claude to talk to their apps, not run servers or edit files. Connectors wrap a server behind an OAuth flow and managed hosting — easy, safe, consistent.
The manual door. Settings → Developer → "Local MCP servers" → Edit Config opens claude_desktop_config.json. You declare each server: a command to launch it, its args, and any secrets in env. Restart Claude and the tools appear. This is how anything not in the gallery gets in — including a remote/API server like X (Twitter), which you add here, "without connectors":
# a local server (Manim) — launched as a process
{
  "mcpServers": {
    "manim-server": {
      "command": "/absolute/path/to/python",
      "args": ["/path/to/manim-mcp-server/src/manim_server.py"]
    },
    // a remote/API server (X) — npx package + secrets in env
    "twitter-mcp": {
      "command": "npx",
      "args": ["-y", "@enescinar/twitter-mcp"],
      "env": { "API_KEY": "…", "ACCESS_TOKEN": "…" }
    }
  }
}
Tap each door — Connectors is the click path, Config file is the JSON path.

So why doesn't everything go through the easy door? Because Connectors are officially built, hosted and secured by Anthropic — and that doesn't scale to "every server anyone could ever write." MCP is an open standard precisely so you don't need a gatekeeper's blessing to publish a server. Connectors are the curated lobby; the config file is the door for everyone else. The full catalogue of community servers — filesystem, weather, the lot — lives in the awesome-mcp-servers list, and any of them drops into that config in about a minute.

That's borrowing. It's genuinely useful and it's where I'd start anyone. But the moment you want Claude to touch your data — your tickets, your notes, your expenses — nobody's published that server. You have to build it. Good news: it's shorter than this section.

FastMCP, or: you won't write the boilerplate

When MCP launched, Anthropic shipped an official Python SDK (mcp). It works, and it's the reference, but writing a server with the raw SDK means hand-registering every tool, hand-writing its JSON schema, and hand-dispatching every call. A community helper called FastMCP — from Prefect's founder Jeremiah Lowin — collapses all of that into one decorator. It's now a standalone package (pip install fastmcp), and it's what we'll use. The contrast is the whole pitch:

from mcp.server.lowlevel import Server
import mcp.types as types

server = Server("demo-sdk")

@server.list_tools()
async def list_tools():
    return [types.Tool(
        name="add", description="Add two numbers",
        inputSchema={"type":"object", "properties":{
            "a":{"type":"number"}, "b":{"type":"number"}},
            "required":["a","b"]})]

@server.call_tool() async def call_tool(name, arguments): if name == "add": return arguments["a"] + arguments["b"]

You declare the tool list, write the schema by hand, then route the call yourself. Every new tool touches three places.
from fastmcp import FastMCP

mcp = FastMCP("Demo")

@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two numbers."""
    return a + b
Same tool. Six lines. The decorator reads your type hints to build the schema, the docstring becomes the description, and dispatch is automatic. FastMCP is to MCP what Flask is to WSGI — the ergonomic layer you actually write in.
Toggle the two — same "add" tool, written both ways.

Act II · From an empty file to a working tool

Let's build something real: an expense tracker Claude can write to in plain English. First the project, using uv (the fast Python package manager):

~/expense-tracker $ uv init . ~/expense-tracker $ uv add fastmcp + fastmcp 2.12.3 ~/expense-tracker $ fastmcp version FastMCP version: 2.12.3

Now main.py. I'm going to grow it one decision at a time — step through it, and watch the highlighted lines accrete into a server:

Step 1 — name the server
from fastmcp import FastMCPmcp = FastMCP("ExpenseTracker")
Two lines and you already have a (toolless) MCP server. FastMCP("ExpenseTracker") is the handle every tool will hang off.
Step 2 — somewhere to put the money
import os, sqlite3
DB_PATH = os.path.join(os.path.dirname(__file__), "expenses.db")
def init_db():    with sqlite3.connect(DB_PATH) as c:        c.execute("""            CREATE TABLE IF NOT EXISTS expenses(                id INTEGER PRIMARY KEY AUTOINCREMENT,                date TEXT NOT NULL, amount REAL NOT NULL,                category TEXT NOT NULL,                subcategory TEXT DEFAULT '', note TEXT DEFAULT ''            )""")init_db()
Plain SQLite — no ORM, no server. One table, six columns. init_db() runs at import so the table always exists.
Step 3 — your first tool
@mcp.tool()def add_expense(date, amount, category, subcategory="", note=""):    '''Add a new expense entry to the database.'''    with sqlite3.connect(DB_PATH) as c:        cur = c.execute(            "INSERT INTO expenses(date,amount,category,subcategory,note)"            " VALUES (?,?,?,?,?)",            (date, amount, category, subcategory, note))        return {"status": "ok", "id": cur.lastrowid}
That's the whole thing. @mcp.tool() turns a normal Python function into something Claude can call. The docstring is the description Claude reads to decide when to call it — so write it for the model, not just yourself.
Step 4 — read it back
@mcp.tool()
def list_expenses(start_date, end_date):
    '''List expense entries within an inclusive date range.'''
    with sqlite3.connect(DB_PATH) as c:
        cur = c.execute("SELECT id,date,amount,category,subcategory,note"
            " FROM expenses WHERE date BETWEEN ? AND ? ORDER BY id",
            (start_date, end_date))
        cols = [d[0] for d in cur.description]
        return [dict(zip(cols, r)) for r in cur.fetchall()]

@mcp.tool()
def summarize(start_date, end_date, category=None):
    '''Summarize expenses by category in a date range.'''
    # GROUP BY category, SUM(amount) — same shape as list
Three tools now: add, list, summarize. Notice each is just a function — MCP is the boring part; the SQL is the actual work.
Step 5 — a resource, not a tool
@mcp.resource("expense://categories", mime_type="application/json")def categories():    # read fresh so you can edit the file without restarting    with open(CATEGORIES_PATH, encoding="utf-8") as f:        return f.read()
A resource is read-only context Claude can pull in, not an action it performs (that's the primitive distinction from Part 2). This one hands Claude a categories.json so it tags "coffee" as food consistently instead of inventing a new bucket each time.
Step 6 — turn the key
if __name__ == "__main__":    mcp.run()
mcp.run() with no arguments = STDIO. Claude Desktop will launch this script and talk to it over stdin/stdout — the humble pipe Part 2 ended on. Remember this line. It's the only thing that changes when we go remote.
Step through 1 → 6. The highlighted lines are what each step adds.

That is a complete, working MCP server — about twenty lines that matter. Before wiring it into Claude, you sanity-check it with MCP Inspector, a local web UI that speaks to your server exactly like a client would:

~/expense-tracker $ uv run fastmcp dev main.py Starting MCP Inspector... 🔍 Inspector running at http://localhost:6274 Transport: STDIO · Command: fastmcp · Args: run main.py --no-banner

The Inspector lists your tools, lets you call them with hand-typed arguments, and shows the raw initialize handshake — invaluable for catching a broken tool before Claude ever sees it. When it's green, install into Claude Desktop with one command:

~/expense-tracker $ uv run fastmcp install claude-desktop main.py ✓ Successfully installed 'ExpenseTracker' in Claude Desktop

Restart Claude, and three toggles appear in the tool menu — Add expense, List expenses, Summarize — plus your categories resource under "Add from ExpenseTracker." You built a server. Claude can use it. That's option ② from the map, done.

shortcut worth knowingAlready have a FastAPI app? You don't rewrite it. mcp = FastMCP.from_fastapi(app=app) turns every existing route into an MCP tool — one line, and your web backend is also an AI backend.

Watching a single request cross the wire

Here's the part that turned MCP from "config I copy-paste" into something I actually understood. When I type a sentence at Claude, five things happen in order. Step through them — the glowing box is where your request is right now, and the panel shows the exact JSON-RPC on the wire at that moment:

you
plain English
host + client
Claude picks a tool
the wire
tools/call
your server
Python + SQLite
back to you
result
You type a sentence. No schema, no tool name — just intent.
"I spent ₹200 on lunch today, that's food."
The model reads its list of available tools (fetched earlier via tools/list), matches your intent to add_expense, and fills the arguments from your sentence.
// the model's decision, not yet on the wire
add_expense(date="2026-06-29", amount=200,
            category="food", note="lunch")
The client serialises that into one JSON-RPC 2.0 request — the exact grammar from Part 2 — and pushes it down the transport.
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
  "params": { "name": "add_expense",
    "arguments": { "date": "2026-06-29", "amount": 200,
                   "category": "food", "note": "lunch" } } }
Your server receives it, FastMCP routes it to the decorated function, and the function does the only real work in the whole chain: one SQL INSERT.
INSERT INTO expenses(date, amount, category, note)
VALUES ('2026-06-29', 200, 'food', 'lunch');
-- → new row, id = 7
The return value travels back as a JSON-RPC response with the same id, and Claude turns it back into a sentence for you.
{ "jsonrpc": "2.0", "id": 2,
  "result": { "status": "ok", "id": 7 } }
// Claude: "Added ₹200 for lunch under food. ✓"
Five hops, 1 → 5. The wire only ever carries JSON-RPC; everything else is just Python.

Once you've seen those five steps, every MCP server you'll ever meet is the same loop with different SQL in the middle.

Act III · The one line that makes it remote

So far the server runs on my laptop, launched by my Claude. That's a local server: fast, private, single-machine, one user. A remote server lives on the internet — reachable by many clients, a little slower over the network, but shareable and far more powerful. It's what every enterprise MCP server actually is.

Here is the part I find almost suspiciously elegant: the difference between the two is one line. Flip the switch:

# …all three tools, unchanged, above this line…if __name__ == "__main__":    mcp.run()
STDIO. Claude launches the script and talks over a pipe. Nothing leaves the machine. This is what fastmcp install set up for you.
# …all three tools, unchanged, above this line…if __name__ == "__main__":    mcp.run(transport="http", host="0.0.0.0", port=8000)
HTTP. Same three tools — literally not one character of them changed. Only the run call did. This is Part 2's "JSON-RPC is transport-agnostic" made completely literal: swap the pipe, keep the messages.
Flip Local ⇄ Remote. Watch what changes (the run line) and what doesn't (everything else).

Run the HTTP version and FastMCP tells you exactly what it did:

Server name: Simple Calculator Server Transport: Streamable-HTTP Server URL: http://0.0.0.0:8000/mcp FastMCP version: 2.12.4 · MCP SDK version: 1.15.0

Shipping it: deploy to FastMCP Cloud

A server bound to localhost:8000 is remote in spirit only — nobody else can reach it. To put it on the real internet, the fastest path is FastMCP Cloud, which deploys straight from a GitHub repo, free. The flow that took me from laptop to live URL:

  1. Push to GitHub. git init → commit → git push. FastMCP Cloud deploys from the repo, so the repo is the deploy artifact.
  2. Create a server at fastmcp.cloud → "Deploy from your code" → pick the repository and branch.
  3. Configure it: a server name (becomes your URL, and can't be changed later) and the entrypoint (main.py). Toggle Authentication on if you want it locked down.
  4. Deploy. It builds, goes Live, and hands you a permanent address: https://your-name.fastmcp.app/mcp — always pointing at the latest commit on your branch.
  5. Connect it to Claude Desktop → Settings → Connectors → Add custom connector → paste the name and the /mcp URL. Claude warns it's unverified (it's yours — that's expected), you click Add, and your cloud server is now a tool in the client.

That's the entire arc: a sentence I type reaches a Python function running in the cloud, and the answer comes back as a sentence. ① borrow, ② build, ③ ship — done. Except real deploys don't end on step 5. Mine didn't.

The afternoon it broke (and what fixed it)

I deployed the expense tracker, opened Claude, and tried the most obvious thing:

add an expense — navratri dinner last night, ₹1000
I'll add that for you.
add_expense → error: readonly database
I apologize — I can't add the expense right now. The database is in read-only mode. This looks like a permissions or configuration issue with the expense tracker.

It worked perfectly on my laptop and refused to write a single row in the cloud. The cause: my tools used synchronous sqlite3, opening the database on the request thread. On Cloud's runtime that path was read-only, and synchronous blocking I/O is the wrong shape for an async HTTP server anyway. The fix was to make the database layer async with aiosqlite, and switch on SQLite's write-ahead log. Same tools, same SQL — different plumbing underneath:

import sqlite3 @mcp.tool()def add_expense(date, amount, category, …):    with sqlite3.connect(DB_PATH) as c:        cur = c.execute("INSERT INTO expenses …", …)        return {"status": "ok", "id": cur.lastrowid}
Blocking sqlite3 on the request thread. Fine for a local STDIO server; read-only and wrong-shaped on a hosted async one.
import aiosqlite @mcp.tool()async def add_expense(date, amount, category, …):    async with aiosqlite.connect(DB_PATH) as c:        cur = await c.execute("INSERT INTO expenses …", …)        await c.commit()        return {"status": "ok", "id": cur.lastrowid}
async/await + aiosqlite, plus PRAGMA journal_mode=WAL at init. Push, Cloud rebuilds, and "navratri dinner" finally lands in the table.
Toggle before/after — the tool's signature gains three words, nothing else moves.

There's a second way to attach a remote server that's worth knowing, because it's how the most polished setups do it: a proxy. Instead of pasting a URL into Claude, you run a tiny local server whose only job is to forward to the remote one over HTTP. Claude talks to the local proxy over STDIO; the proxy talks to the cloud:

Claude DesktopSTDIO
⇄
local proxyFastMCP.as_proxy
⇄ https ⇄
remote serverfastmcp.app/mcp
from fastmcp import FastMCP
# a local STDIO server that forwards to the deployed one
mcp = FastMCP.as_proxy(
    "https://your-name.fastmcp.app/mcp",
    name="My Server Proxy")
if __name__ == "__main__":
    mcp.run()   # STDIO — installable with fastmcp install claude-desktop

Ten lines, and you've bridged a STDIO client to an HTTP server without either side knowing. That's the transport-agnostic promise paying off a third time.

The dish: the server that wrote this series

I've been showing you the recipe. Time for the dish.

Every post in this series — the why, the what, and this one — started as messy notes in OneNote. To write them, I don't copy-paste screenshots into a chat window. I built a small OneNote MCP server: tools to list my notebooks, pull a page, convert its ink and screenshots to clean markdown, download every image. It's the exact pattern from Act II — @mcp.tool() over an API instead of SQLite — running over the exact STDIO pipe from Act III's left toggle.

Which means the loop is closed in a way I still find slightly absurd: the server you just learned to build is the one that fed me the notes to teach you how to build it. When I started Part 1, that was a promise — "I built a small MCP server to kill my copy-paste problem." Three posts later it's the byline. None of it is clever. It's just consistent — and consistency, as I keep saying, is the entire point.

Where this goes next

If you opened MCP Inspector back in Act II, you saw tabs we never touched: Sampling, Elicitation, Auth. Those are the next rung — the server asking the model to generate something mid-call (sampling), the server pausing to ask the user a question (elicitation), and locking the whole thing down with real authentication (the toggle you flipped at deploy). They turn a server from a set of functions into a genuine collaborator.

That's where my own notes trail off, with a single line I wrote to future-me: "Bring Authentication, Sampling and Elicitation." Consider that the trailer for whatever I build next. For now, you have the whole spine — borrow a server, build a server, ship a server. Go make Claude touch something only you have.

References and Resources

  • Build an MCP server — FastMCP docs — the library this whole post is written in: tools, resources, transports, from_fastapi, and as_proxy.
  • FastMCP Cloud — free deploy-from-GitHub hosting for remote servers.
  • Model Context Protocol — official docs — the spec, the primitives, and the reference SDKs.
  • MCP Inspector — the local UI for poking at a server before a client ever sees it.
  • awesome-mcp-servers — the catalogue of community servers to borrow from in Act I.
  • CampusX code: expense-tracker-mcp-server (local) · test-remote-mcp-server (remote).

Massive Shoutout to CampusX

This finale stitches together the three "How" videos from the CampusX MCP trilogy: connecting servers to Claude Desktop, building local servers, and building & deploying remote servers — the same series I credited in Part 1 and Part 2. Captions were off on all three, so this is my write-up from the code and my own notes rather than a transcript — but the clarity is all theirs. Subscribe to the CampusX YouTube channel if any of this landed.

Tags

MCP
Model Context Protocol
FastMCP
Python
Claude Desktop

Related articles

AI & ML

Agent Harnesses — The Model Was Never the Bottleneck

Tejas Kumar took one of the worst models you can still rent — gpt-3.5-turbo — gave it one fixed prompt and one task (upvote a story on Hacker News), and made it reliable without ever touching the prompt or swapping the model. He did it across five git branches by engineering everything around the model: tools, context, guardrails, a verify step, a login handler. This is a hands-on, build-it-with-me walk through that repo and the idea underneath it — an AI harness is everything except the model weights.

AI & ML

Agent BattleGround — Claude Code vs Codex

I gave Claude Code (Fable 5) and Codex (gpt-5.5) the exact same six creative-coding tasks — an arcade game, a generative-art CLI, a bug hunt, an ASCII aquarium, a messy-data dashboard, and a 120-line demoscene — one shot each, zero interventions, blind judges, instrumented re-tests. This is the full fight card: every prompt, the actual screen recordings, the parameter-by-parameter scorecards, and what the judges said. Final tally: 6–0, but the story is closer than the headline.

Read More Articles
  • The three ways in
  • Act I · Borrowing servers you didn't build
  • FastMCP, or: you won't write the boilerplate
  • Act II · From an empty file to a working tool
  • Watching a single request cross the wire
  • Act III · The one line that makes it remote
  • Shipping it: deploy to FastMCP Cloud
  • The afternoon it broke (and what fixed it)
  • The dish: the server that wrote this series
  • Where this goes next
  • References and Resources
7
  • 8The afternoon it broke (and what fixed it)
  • 9The dish: the server that wrote this series
  • 10Where this goes next
  • 11References and Resources
  • MCP Inspector
    FastMCP Cloud
    Anthropic
    Claude
    AI Tools
    3-Part Series