Search “MCP server” and you get a thousand guides that all stop at the same place: paste this JSON into your Claude config, restart, done. That is installation. Nobody writes the other half - how to actually author a server, wire it to a real backend, protect it with auth, and ship it so other people can install it. This post is that half.

I have shipped a couple of internal MCP servers over the last few months. The protocol is simple, but the gap between “works in Claude Desktop on my laptop” and “a thing other engineers can trust” is wide, and it is mostly made of the parts the tutorials skip. So we will build a real server end to end: a task tracker that reads projects, runs tools, authenticates callers, and publishes to the registry.

The Naive Server, and Why It Is Not Enough

Here is the version every tutorial shows you. Python, one tool, stdio transport.

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("tasks")

@mcp.tool()
def create_task(title: str) -> str:
    """Create a task and return its id."""
    return f"created task: {title}"

if __name__ == "__main__":
    mcp.run()  # stdio transport by default

This works. Point Claude Desktop at it and the model can call create_task. But look at what is missing the moment you want it to be useful to anyone but yourself:

  • It has no state. The “created” task vanishes. There is no backend.
  • It exposes only tools, so the model can act but cannot read. It cannot list existing tasks without you writing a tool for every query.
  • It runs over stdio, which means it lives on one machine, launched as a subprocess. There is no way to host it for a team.
  • It has zero auth. Anything that can spawn the process can do anything the process can do.
  • There is no way to publish it. It exists only in your config file.

Each of those is a section below. The naive server is not wrong, it is just the first ten minutes of a real one.

Tools vs Resources - Get This Distinction Right

The single most common design mistake is making everything a tool. MCP servers expose three capability types, and mixing them up produces a server the model uses badly.

CapabilityAnalogySide effectsWho initiates
ResourceHTTP GETNone (read-only)Client picks what to read
ToolHTTP POSTYes (mutates state)Model decides to call
PromptSaved workflowNoneUser invokes explicitly

Resources are addressable, cacheable, read-only data. tasks://project/42 returns the state of project 42. The client can list resources, read them cheaply, and the model does not have to “spend” a tool call to look something up. Tools are for actions with consequences: create, update, close, notify. If you model a read as a tool, you force the model to reason about whether to call it, and you pay a round trip and tokens for data that could have been handed over as context.

Rule of thumb: if the operation would be a GET in a REST API, it is a resource. If it would be POST, PUT, or DELETE, it is a tool.

Building the Real Server (Python)

Let us give the task tracker an actual backend and both capability types. I am using an in-memory store to keep the example focused, but swap STORE for your database and the shape does not change.

from dataclasses import dataclass, field, asdict
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("tasks")

@dataclass
class Task:
    id: int
    project: str
    title: str
    done: bool = False

@dataclass
class Store:
    tasks: dict[int, Task] = field(default_factory=dict)
    _next_id: int = 1

    def add(self, project: str, title: str) -> Task:
        task = Task(self._next_id, project, title)
        self.tasks[self._next_id] = task
        self._next_id += 1
        return task

STORE = Store()

Resource endpoints

Resources use URI templates. A parameter in the URI becomes a function argument. The docstring and name are what the model sees, so write them like API docs, not like code comments.

@mcp.resource("tasks://projects")
def list_projects() -> str:
    """All project names that currently have tasks."""
    names = sorted({t.project for t in STORE.tasks.values()})
    return "\n".join(names) or "(no projects yet)"

@mcp.resource("tasks://project/{name}")
def project_tasks(name: str) -> str:
    """Every task in a given project, with status."""
    rows = [
        f"[{'x' if t.done else ' '}] #{t.id} {t.title}"
        for t in STORE.tasks.values()
        if t.project == name
    ]
    return "\n".join(rows) or f"No tasks in project '{name}'."

The client can now enumerate tasks://projects, then read tasks://project/backend without the model calling a single tool. That is cheaper in tokens and cleaner in intent.

Tool definitions

Tools are functions the model invokes to change state. Type hints become the input schema automatically, and the docstring becomes the description the model reads when deciding whether to call it. Vague descriptions are the number one cause of a model calling the wrong tool.

@mcp.tool()
def create_task(project: str, title: str) -> dict:
    """Create a new task in a project. Returns the created task including its id."""
    task = STORE.add(project, title)
    return asdict(task)

@mcp.tool()
def complete_task(task_id: int) -> dict:
    """Mark an existing task as done. Fails if the task id does not exist."""
    task = STORE.tasks.get(task_id)
    if task is None:
        raise ValueError(f"No task with id {task_id}")
    task.done = True
    return asdict(task)

Two design points that matter more than they look. First, complete_task raises on a bad id instead of silently returning “ok” - the model needs a real error to correct itself, and swallowing it produces confident lies. Second, both tools return the full task object, not just a string. Structured returns let the model verify what actually happened rather than trusting a “done” message.

Going Remote - Streamable HTTP

Stdio is fine for a local tool. To host a server for a team, you need HTTP. The modern transport is streamable HTTP (the old HTTP+SSE transport is deprecated). FastMCP switches with one argument.

if __name__ == "__main__":
    mcp.run(transport="streamable-http")  # serves on /mcp

Now the server is a normal web service you can put behind a load balancer, and the URL goes in the client config instead of a launch command:

{
  "mcpServers": {
    "tasks": { "url": "https://tasks.internal.example.com/mcp" }
  }
}

The moment your server is reachable over the network, the missing auth stops being a style problem and becomes a security hole. That is the next section.

Authentication - The Part Everyone Skips

A local stdio server inherits the trust of the user who launched it. A remote server does not. MCP standardized on OAuth 2.1, and the key architectural decision is this: your MCP server is a resource server, not an authorization server. It does not issue tokens or manage passwords. It validates a bearer token issued by an identity provider you already run (Auth0, Okta, Entra, or your own).

The flow, end to end:

Client                MCP Server              Auth Server (IdP)
  |                       |                          |
  |-- request w/o token ->|                          |
  |<- 401 + resource      |                          |
  |   metadata URL -------|                          |
  |                                                  |
  |------ discover + OAuth authorization code ------>|
  |<----------------- access token ------------------|
  |                       |                          |
  |-- request + Bearer -->|                          |
  |         (validate signature, audience, scope)    |
  |<------ result --------|                          |

The server’s job is small but non-negotiable: on every request, verify the token signature against the IdP’s public keys, check the aud claim points at this server (a token minted for another service must not work here), check expiry, and enforce scopes per operation. FastMCP wires this through a TokenVerifier and an AuthSettings block.

from mcp.server.fastmcp import FastMCP
from mcp.server.auth.provider import TokenVerifier, AccessToken
from mcp.server.auth.settings import AuthSettings

class MyVerifier(TokenVerifier):
    async def verify_token(self, token: str) -> AccessToken | None:
        claims = await validate_jwt_with_idp(token)  # signature, aud, exp
        if claims is None:
            return None
        return AccessToken(
            token=token,
            client_id=claims["sub"],
            scopes=claims.get("scope", "").split(),
        )

mcp = FastMCP(
    "tasks",
    token_verifier=MyVerifier(),
    auth=AuthSettings(
        issuer_url="https://auth.example.com",
        resource_server_url="https://tasks.internal.example.com",
        required_scopes=["tasks.read"],
    ),
)

Then enforce finer-grained scopes inside the tools that mutate state. Reading tasks needs tasks.read; creating them needs tasks.write. Do not let a read-only token close tickets.

from mcp.server.fastmcp import Context

@mcp.tool()
def create_task(project: str, title: str, ctx: Context) -> dict:
    """Create a new task. Requires the tasks.write scope."""
    if "tasks.write" not in ctx.auth.scopes:
        raise PermissionError("tasks.write scope required")
    task = STORE.add(project, title)
    return asdict(task)

Two failure modes to internalize. If you skip the audience check, any valid token from your IdP - including one your server was never meant to accept - unlocks your data. If you validate the token but never check scopes, every authenticated caller gets full write access, which is usually not what you want for an agent acting on a user’s behalf.

The Same Server in TypeScript

The TypeScript SDK mirrors the Python one closely. Same concepts, different syntax. Here is the core with a tool and a resource, using Zod for input schemas.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const server = new McpServer({ name: "tasks", version: "1.0.0" });

server.registerResource(
  "project-tasks",
  new ResourceTemplate("tasks://project/{name}", { list: undefined }),
  { title: "Project tasks", description: "Every task in a project" },
  async (uri, { name }) => ({
    contents: [{ uri: uri.href, text: renderProject(String(name)) }],
  })
);

server.registerTool(
  "create_task",
  {
    title: "Create task",
    description: "Create a new task in a project. Returns the created task.",
    inputSchema: { project: z.string(), title: z.string() },
  },
  async ({ project, title }) => {
    const task = store.add(project, title);
    return { content: [{ type: "text", text: JSON.stringify(task) }] };
  }
);

Serving it over streamable HTTP with Express is a few more lines, and the same OAuth resource-server rules apply. Pick the language your backend already lives in; there is no capability gap between the two SDKs for building servers.

Testing Before You Ship

Do not debug an MCP server by staring at Claude Desktop. Use the MCP Inspector, a standalone client that connects to your server and lets you list and call everything by hand.

npx @modelcontextprotocol/inspector python server.py

It shows you the exact tool schemas the model will see, lets you invoke tools with arbitrary arguments, and reads resources directly. If a description is ambiguous in the Inspector, it will be ambiguous to the model. Fix it there first. A quick checklist before publishing:

  • Every tool description says what it does and what it returns.
  • Errors raise with a real message, never a silent success string.
  • Read operations are resources, not tools.
  • Remote servers reject requests with a missing, expired, or wrong-audience token.
  • Scopes are enforced on writes, not just checked at the door.

Publishing to the Registry

The MCP Registry is the shared index clients use to discover servers, the PyPI/npm equivalent for MCP. Publishing has two moving parts: a manifest that describes your server, and the mcp-publisher CLI that signs and submits it.

The manifest is server.json, and the name is namespaced to a domain or package you control so nobody can squat on it:

{
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
  "name": "io.github.chirag/tasks",
  "description": "A task tracker exposed over MCP",
  "version": "1.0.0",
  "packages": [
    {
      "registryType": "pypi",
      "identifier": "mcp-server-tasks",
      "version": "1.0.0",
      "transport": { "type": "stdio" }
    }
  ]
}

You publish the actual code to PyPI or npm as usual, then register the metadata. Ownership of the namespace is proved by authenticating - GitHub OAuth for an io.github.* name, or a DNS record for your own domain.

mcp-publisher login github
mcp-publisher publish

Once it lands, MCP-aware clients can find io.github.chirag/tasks by name and install it without anyone hand-editing a config file. That is the difference between a script on your laptop and a server other people can actually use.

The Honest Assessment

What works: the authoring path is genuinely short. Tools and resources are a few decorated functions, the SDKs handle the JSON-RPC plumbing, and a useful internal server is an afternoon of work. The Inspector makes testing tight.

What does not: auth is where real time goes. The SDK gives you the hooks, but wiring OAuth correctly - audience checks, scope enforcement, token validation against your IdP - is the same careful work as securing any API, and the tutorials that skip it are setting people up to expose data. Streamable HTTP and remote hosting are still maturing, so expect rough edges around session handling at scale.

What to actually do: build local stdio servers freely, they are cheap and safe. The moment a server goes remote or touches data that is not yours to give away, treat it like the production web service it is - real auth, least-privilege scopes, a token audience you verify on every call. Model reads as resources and actions as tools, keep descriptions sharp enough that the Inspector never confuses you, and only publish to the registry once you would be comfortable with a stranger calling every tool you exposed.