Skip to content
N
intermediateAI

MCP in Practice (Level III)

The Model Context Protocol, from the handshake to a server your team actually uses.

27 lessons5 sections
Need to Know HQPractical engineering courses, written by people who ship
$24.99$34.99
  • Buy once, yours for good
  • 27 lessons across 5 sections
  • Progress tracking across devices

What you'll learn

  • Explain what MCP standardizes and what it deliberately leaves alone
  • Choose correctly between tools, resources and prompts
  • Build and ship a server your team connects to in one line
  • Pick the right transport and get the auth model right
  • Recognize the security failure modes before they become an incident
  • Build a read-only database server with enforcement that actually holds
  • Read a JSON-RPC wire trace and debug a server from the symptom
  • Run a security review of a server with a nine-question checklist

Course content

5 sections · 27 lessons

What the protocol isThree roles, one handshake, and a clear line between what the specification covers and what it deliberately leaves to you. Get this section right and the rest of the protocol reads as obvious.4 lessons
  • The problem it solves, stated honestlyFree preview2m
    Read this lesson

    You have M AI applications and N systems worth connecting them to. Without a standard that is M × N integrations, each written against a proprietary plugin interface, each rewritten when either side changes. With one, it is M + N.

    That is the entire pitch, and it is the same pitch as the Language Server Protocol — which is the right analogy to hold in your head. LSP didn't make editors smarter; it made "add Rust support" a thing you do once instead of once per editor. MCP does that for context and actions.

    What it deliberately does not do: it does not tell the model what to do, does not run the model, does not decide permissions, and does not make a badly-designed API pleasant. It standardizes the wire, so the interesting problems are the ones left over.

    It is worth being precise about that list, because most disappointment with MCP comes from expecting one of these:

    MCP specifiesMCP leaves entirely to you
    The message format (JSON-RPC 2.0)Whether the model is any good at using your tools
    The handshake and capability negotiationWhat your tool descriptions say
    Two transports (stdio, Streamable HTTP)Authorization: who may do what
    The shape of tools, resources and promptsRate limiting, quotas, audit logging
    How errors are reportedWhether an action is safe to take
    The auth framework for HTTP servers (OAuth 2.1)Which identity provider, and what a token means

    Read the right-hand column again. Everything that decides whether your integration is good or dangerous is in it. The protocol is the easy part, and it is solved; this course spends most of its time on the column the specification does not cover.

  • Hosts, clients, serversFree preview2m
    Read this lesson

    Three roles, and confusing them makes every other document unreadable.

    The host is the application the user is actually using — an IDE, a desktop assistant, an agent framework. It talks to the model, owns the UI, and enforces permission.

    A client lives inside the host and maintains one connection to one server. Ten servers means ten clients inside the host. The one-to-one pairing is deliberate: it keeps each server's capabilities and session state isolated from every other's.

    A server exposes capabilities — a wrapper around Postgres, GitHub, your internal deploy system, the filesystem. It is usually small, usually stateless, and it can run as a local subprocess or a remote HTTP service.

    The chain to keep straight: the model decides it wants a tool, the host approves it, the client sends the request, the server does the work. The model never touches the server, which is exactly why the design is safe enough to be useful.

    That chain has a consequence people miss until it bites them: the server has no idea what the model was asked. It receives tools/call with a name and arguments, and nothing else. It does not see the conversation, the system prompt, or the user's intent. Every decision your server makes — is this allowed, is this sensible, should this be rate limited — has to be made from the arguments and the authenticated identity alone.

    That is a feature, not a limitation. It is what makes a server auditable: the complete record of what an integration did is a list of tool calls with their arguments, and it does not require reasoning about a conversation to interpret. But it means "the model wouldn't ask for something unreasonable" is not a security argument your server can rely on, because your server cannot tell reasonable requests from unreasonable ones and neither can the protocol.

  • The handshake, and why capabilities are negotiated7m
  • Practice: read the wire12m
The primitivesThree things a server can expose, distinguished by who decides to use them. Choosing wrongly here is the most common design error in MCP servers, and it is the one that makes a technically working server feel bad to use.5 lessons
  • Tools, resources, prompts2m
  • When something should be a resource, not a tool2m
  • What the client can offer back2m
  • Designing tools models actually call well4m
  • Practice: pick the primitive11m
Connecting it upTransports, authorization, annotations and deployment — everything between a server that works on your machine and one your team can actually reach. This is the section where the specification stops helping and infrastructure decisions start.6 lessons
  • stdio and Streamable HTTP2m
  • Auth, and the one rule you must not break3m
  • Tool annotations, and how hosts use them3m
  • Wiring servers into a host2m
  • Deploying a Streamable HTTP server3m
  • Practice: wire it up12m
Building oneA complete server in TypeScript and Python, then the resource and prompt registrations, then a guided build of the read-only database server — including the SELECT-only enforcement written out rather than waved at. Finishes with how to test it and what breaks in production.7 lessons
  • Your first serverFree preview12m
    Read this lesson

    Here is a complete server. Not a fragment — the whole file, the package manifest, the build command and the exact JSON a colleague pastes to connect it.

    src/index.ts

    #!/usr/bin/env node
    import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
    import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
    import { z } from "zod";
    
    const API = process.env.DEPLOY_API_URL ?? "https://deploys.internal";
    
    const server = new McpServer({ name: "deploys", version: "1.0.0" });
    
    server.registerTool(
      "get_deploy_status",
      {
        title: "Deploy status",
        description:
          "Current deploy status for a service: version live, when it shipped, who " +
          "shipped it, and health. Call this whenever someone asks whether something " +
          "shipped, what version is live, why a deploy failed, or what changed recently.",
        inputSchema: {
          service: z
            .enum(["checkout", "payments", "search", "auth"])
            .describe("Which service to report on."),
        },
        annotations: { readOnlyHint: true, openWorldHint: false },
      },
      async ({ service }) => {
        const res = await fetch(`${API}/v1/deploys/${service}/current`);
    
        if (res.status === 404) {
          return {
            isError: true,
            content: [{ type: "text", text: `No service named '${service}'.` }],
          };
        }
        if (!res.ok) {
          return {
            isError: true,
            content: [{
              type: "text",
              text: `Deploy API returned ${res.status}. It may be degraded; ` +
                    `report the gap rather than guessing at the deploy state.`,
            }],
          };
        }
    
        const d = await res.json();
        return {
          content: [{
            type: "text",
            text:
              `${service} ${d.version}\n` +
              `live since ${d.deployed_at} (${d.deployed_by})\n` +
              `health: ${d.health}\n` +
              `commits: ${d.commit_range}`,
          }],
        };
      },
    );
    
    // stdout is the protocol. Every log line goes to stderr.
    const transport = new StdioServerTransport();
    await server.connect(transport);
    console.error("deploys mcp server ready on stdio");

    package.json

    {
      "name": "deploys-mcp",
      "version": "1.0.0",
      "type": "module",
      "bin": { "deploys-mcp": "./build/index.js" },
      "files": ["build"],
      "scripts": {
        "build": "tsc && chmod +x build/index.js",
        "dev": "npm run build && node build/index.js"
      },
      "dependencies": {
        "@modelcontextprotocol/sdk": "^1.0.0",
        "zod": "^3.23.8"
      },
      "devDependencies": {
        "@types/node": "^22.0.0",
        "typescript": "^5.5.0"
      }
    }

    tsconfig.json

    {
      "compilerOptions": {
        "target": "ES2022",
        "module": "Node16",
        "moduleResolution": "Node16",
        "outDir": "build",
        "rootDir": "src",
        "strict": true,
        "esModuleInterop": true,
        "skipLibCheck": true
      },
      "include": ["src/**/*"]
    }

    Then:

    npm install
    npm run build

    And the config a colleague pastes into their client's mcpServers block — this is the artifact that matters, because it is what "ship it" means:

    {
      "mcpServers": {
        "deploys": {
          "command": "node",
          "args": ["/absolute/path/to/deploys-mcp/build/index.js"],
          "env": { "DEPLOY_API_URL": "https://deploys.internal" }
        }
      }
    }

    In Claude Code the same thing is one line, and it can be committed to the repository so everyone who clones gets it:

    claude mcp add deploys --scope project -- node /absolute/path/to/deploys-mcp/build/index.js

    The Python equivalent, because both SDKs are mature and a course that names both should show both. pip install "mcp[cli]", then server.py:

    import os
    import httpx
    from typing import Literal
    from mcp.server.fastmcp import FastMCP
    from mcp.server.fastmcp.exceptions import ToolError
    
    API = os.environ.get("DEPLOY_API_URL", "https://deploys.internal")
    
    mcp = FastMCP("deploys")
    
    
    @mcp.tool()
    async def get_deploy_status(
        service: Literal["checkout", "payments", "search", "auth"],
    ) -> str:
        """Current deploy status for a service: version live, when it shipped, who
        shipped it, and health. Call this whenever someone asks whether something
        shipped, what version is live, why a deploy failed, or what changed recently.
    
        Args:
            service: Which service to report on.
        """
        async with httpx.AsyncClient() as client:
            res = await client.get(f"{API}/v1/deploys/{service}/current")
    
        # Raising marks the result isError:true, exactly like the TypeScript branch
        # above. Returning the message as a plain string would report a failure to the
        # model as a success — the single easiest way to get an agent to build on a
        # result that does not exist.
        if res.status_code == 404:
            raise ToolError(f"No service named '{service}'.")
        if res.status_code >= 400:
            raise ToolError(
                f"Deploy API returned {res.status_code}. It may be degraded; "
                "report the gap rather than guessing at the deploy state."
            )
    
        d = res.json()
        return (
            f"{service} {d['version']}\n"
            f"live since {d['deployed_at']} ({d['deployed_by']})\n"
            f"health: {d['health']}\n"
            f"commits: {d['commit_range']}"
        )
    
    
    if __name__ == "__main__":
        mcp.run()          # stdio by default

    Note what the Python SDK infers for you: the tool name from the function name, the input schema from the type annotations (Literal becomes an enum), and the description from the docstring. That is convenient and it is a trap — the docstring is now your tool description, which means it has to be written for a model rather than for a developer reading the source. Write the trigger condition into it, as above.

    The second trap is the one the error branches above avoid. In TypeScript you return isError: true explicitly; in Python the natural-looking thing is to return a string saying what went wrong, and a returned string is a success. The model receives "No service named 'checkuot'" as a normal result and may well treat it as the answer. Raise instead, and the SDK marks the result as an error. Two implementations of the same server have to fail the same way, or the one that does not will be the one in production.

    Its config entry:

    {
      "mcpServers": {
        "deploys": {
          "command": "/absolute/path/to/.venv/bin/python",
          "args": ["/absolute/path/to/server.py"],
          "env": { "DEPLOY_API_URL": "https://deploys.internal" }
        }
      }
    }

    The protocol machinery — initialization, capability advertisement, tools/list, schema validation, framing — is handled by the SDK in both languages. What deserves your attention is everything in that description string and everything in the formatting of the result. The plumbing is solved. The interface is not.

  • Resources and prompts, registered6m
  • Four servers worth building on Monday8m
  • Testing without a chat window8m
  • Servers that behave in production3m
  • Troubleshooting: symptom, cause, fix3m
  • Practice: debug the server12m
SecurityThe protocol standardizes the wire and leaves every security property to you. Two lessons on the failure modes — prompt injection through tool results, and the confused-deputy family that includes over-broad credentials, tool shadowing, aggregation across servers and silent scope creep — then a checklist to paste into the pull request that adds your next tool.5 lessons
  • Prompt injection through tool results2m
  • The confused deputy, and other trust problems2m
  • A checklist before you connect it to anything real3m
  • Practice: find the hole12m
  • Where to go next

Requirements

  • Comfortable with HTTP, JSON and a language that can run a server
  • Some exposure to LLM tool use — AI and LLM Foundations (Level II) is enough

Description

Every AI assistant needs the same things: your files, your database, your tickets, your docs. Before MCP, every one of those integrations was written once per assistant, in that assistant's plugin format, and thrown away when you switched.

The Model Context Protocol — open-sourced by Anthropic in late 2024 and now implemented across most serious AI tooling — is the boring, correct fix: one protocol, one server, every client. This course covers what it actually specifies, how to design a server that models use well rather than one that merely exposes an API, and the security properties you have to supply yourself. It is practical throughout: by the end you should be able to ship something your team plugs in on Monday.

The code is real. You get a complete, runnable server in TypeScript and Python, the exact JSON your client pastes to connect it, the wire trace of a full session, and a guided build of a read-only database server with the SELECT-only enforcement written out. Every section ends with a practice lesson.

Student reviews