Claude Academy
Sign in

Vault / course/projects/p12-build-an-mcp-server.md

updated 2026-06-25

Project 12 — Build an MCP Server

Enforces: the three MCP primitives (tool, resource, prompt), stdio transport, and .mcp.json wiring (from introduction-to-mcp) Surface: code (Python, FastMCP) · Time: ~75 min · Difficulty: 🟡 intermediate

Why this project

MCP is how you give Claude new capabilities that aren't built in. The three primitives — tools (model-invoked actions), resources (app-controlled data), prompts (user-invoked templates) — are the entire vocabulary, and the exam tests whether you know which is which. The fastest way to internalize them is to ship one of each and call it from Claude Code.

What you'll build

A small FastMCP server exposing exactly one of each primitive — one tool, one resource, one prompt — running over stdio, wired into Claude Code via .mcp.json, with the tool successfully called from a session.

Steps

  1. Set upuv init mcp-notes && cd mcp-notes && uv add "mcp[cli]". (Or pip install mcp.)

  2. Write the serverserver.py, one of each primitive. The function name + docstring become the tool name + description; type hints become the JSON schema:

    from mcp.server.fastmcp import FastMCP
    
    mcp = FastMCP("notes")
    
    NOTES: dict[str, str] = {"welcome": "This is your first MCP note."}
    
    @mcp.tool()
    def add_note(title: str, body: str) -> str:
        """Save a note by title. Use when the user wants to persist a short note."""
        NOTES[title] = body
        return f"Saved note '{title}'."
    
    @mcp.resource("note://{title}")
    def read_note(title: str) -> str:
        """Return the body of a saved note. App-controlled context, not an action."""
        return NOTES.get(title, f"(no note titled '{title}')")
    
    @mcp.prompt()
    def summarize_notes() -> str:
        """A user-invoked prompt template to summarize all saved notes."""
        joined = "\n\n".join(f"# {t}\n{b}" for t, b in NOTES.items())
        return f"Summarize these notes into 3 bullets:\n\n{joined}"
    
    if __name__ == "__main__":
        mcp.run()  # stdio transport by default
    

    Remember: log to stderr, never stdout — stdout is the wire on stdio transport.

  3. Inspect it standalone — Before wiring to Claude, sanity-check with the inspector: npx @modelcontextprotocol/inspector uv run server.py. List tools, call add_note, read note://welcome, render the prompt.

  4. Wire into Claude Code — Create .mcp.json at the repo root so the server loads on session start:

    {
      "mcpServers": {
        "notes": {
          "command": "uv",
          "args": ["run", "server.py"]
        }
      }
    }
    

    Launch claude, then run /mcp to confirm the notes server connected and lists three primitives.

  5. Call the tool — Ask Claude: "Add a note titled 'standup' with body 'shipped MCP server'." Confirm it invokes add_note (you'll see the tool_use / tool_result exchange), then ask it to read the note back via the resource.

  6. Map primitives to roles — Write one sentence each: who invokes the tool (the model), who controls the resource (the app/host), who triggers the prompt (the user).

Acceptance criteria — you're done when

  • server.py defines exactly one tool, one resource (with a URI template), and one prompt.
  • The server passes a manual check in @modelcontextprotocol/inspector.
  • .mcp.json is committed and /mcp shows notes connected with all three primitives.
  • Claude successfully calls add_note and reads it back via the note:// resource.
  • You can correctly assign each primitive to its invoker (model / app / user).
  • You journaled the difference between a tool and a resource in your own words in learning-journal-template.

Stretch goals

  • Add input validation to add_note and return a clear error string the model can recover from.
  • Add a second tool and write descriptions specific enough that Claude never confuses the two.
  • Run the same server over an HTTP transport instead of stdio and note what changes in .mcp.json.

Self-assessment rubric

LevelSignal
🟢 Got itYou can scaffold an MCP server from memory and explain tool vs resource vs prompt without hesitation.
🟡 AlmostThe server runs, but you had to look up which decorator maps to which primitive.
🔴 RevisitServer wouldn't connect, or you logged to stdout and broke the wire. Re-watch introduction-to-mcp and re-read mcp-server-python.

See also