AI & ML / MCP / 04_building_a_server_worked.md

Building an MCP server, end to end

Updated 5 interview angles 5 min read source
On this page10
  1. The whole server
  2. Choose the transport deliberately
  3. Structure the surface, not just the tools
  4. Errors are output, not exceptions
  5. Auth, for the HTTP case
  6. Testing it
  7. Packaging and distribution
  8. The build checklist
  9. Related
  10. Interview angle

Building an MCP server, end to end

Building and securing MCP servers covers how to design the tool surface and how to secure it. This is the build: from an empty file to something a client can discover, call and be trusted with.

Employers increasingly ask for servers you have built, not consumed, so being able to walk this without hand-waving is the differentiator.

The whole server

python
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("orders")

@mcp.tool()
async def refund_order(
    order_id: str, reason: str
) -> str:
    """Refund an order.

    Args:
        order_id: The order to refund.
        reason: Why, recorded in the audit log.
    """
    return await process_refund(order_id, reason)

if __name__ == "__main__":
    mcp.run()

That is a working server. Everything after this is about making it correct, discoverable and safe.

The docstring is the prompt — it is what the model reads to decide whether to call this tool and with what. Type annotations become the JSON Schema. So the function signature and docstring are the entire contract, and vague ones are the single largest cause of wrong tool calls.

Choose the transport deliberately

Transport Runs as Trust model
stdio subprocess of the client the user’s own machine
Streamable HTTP network service authenticated multi-user
python
mcp.run()                          # stdio, the default
mcp.run(transport="streamable-http")

stdio is right for a developer tool on a laptop: the client spawns it, and it inherits the user’s filesystem and environment. Streamable HTTP is right for anything shared, and it turns the server into an ordinary web service with all the obligations that brings.

Since the 2026-07-28 revision the protocol core is stateless, so a remote server scales horizontally behind a load balancer like any other HTTP service. See The 2026-07-28 MCP revision.

Structure the surface, not just the tools

Three primitives, and using the right one matters — see Model Context Protocol — fundamentals:

python
@mcp.resource("order://{order_id}")
async def order(order_id: str) -> str:
    """Read-only order detail."""
    return json.dumps(await load_order(order_id))

@mcp.prompt()
def investigate(order_id: str) -> str:
    """Investigation checklist for an order."""
    return (
        f"Check status, payments and shipping "
        f"for order {order_id}."
    )

The common mistake is exposing everything as a tool. Read-only context belongs as a resource, because tools are what the model chooses between, and forty tools makes it choose badly.

Errors are output, not exceptions

python
@mcp.tool()
async def get_invoice(invoice_id: str) -> str:
    """Fetch an invoice by id."""
    inv = await find(invoice_id)
    if inv is None:
        return (
            f"No invoice {invoice_id}. "
            "Check the id format."
        )
    return inv.to_json()

An error the model can read is an error it can recover from. Raising gives it a stack trace or nothing; returning a sentence tells it what to try next. Include what would help — the expected format, the nearest match, the permitted range.

Gotcha: never put an internal exception message in the return value. It goes into the model’s context, and from there potentially into a user’s chat window. Log the real error, return a useful sentence.

Auth, for the HTTP case

stdio inherits the user’s credentials. A remote server has to establish identity itself, and the model is not the identity — the user is.

The rule that survives contact with a security review: the server authorises against the calling user’s permissions, never against what the model claims. A tool call arriving with a request to refund order 123 is authorised by checking that this user may refund that order.

Pass credentials by environment variable for stdio and by header for HTTP, and never accept them as tool arguments — arguments come from the model, which means they come from whatever the model read.

Testing it

bash
npx @modelcontextprotocol/inspector python server.py

The inspector is the fastest loop: it lists your tools, shows the generated schemas, and lets you call them by hand. Read the generated schema rather than assuming — a missing annotation silently becomes an untyped argument.

Then test the functions as ordinary Python, because they are:

python
async def test_refund_unknown_order():
    out = await refund_order("nope", "test")
    assert "No order" in out

The MCP layer needs no mocking. That is a benefit of keeping tool bodies thin and pushing logic into the services they call.

Packaging and distribution

Audience Ship as
Your team, local a repo plus a client config snippet
Public, local a published package (uvx, npx)
Organisation, remote a container behind HTTPS

For stdio the client config is the interface, so document it:

json
{
  "mcpServers": {
    "orders": {
      "command": "uvx",
      "args": ["my-orders-server"],
      "env": {"ORDERS_API_TOKEN": "..."}
    }
  }
}

For remote, it is a normal deployment — health check, logs, metrics, an identity story — plus the stateless core making horizontal scaling ordinary.

The build checklist

  1. One capability per tool, named as a verb.
  2. Docstrings written for a model, with argument semantics.
  3. Read-only data as resources, not tools.
  4. Validated arguments — never trust model-supplied input.
  5. Readable error strings, no internal detail.
  6. Authorised against the user, not the model.
  7. Schemas checked in the inspector.
  8. Tools thin, logic in the services beneath.

Interview angle 5

  • “Walk me through building an MCP server.” - decorate functions as tools, where the docstring is the prompt the model reads and the annotations become the JSON Schema; expose read-only data as resources rather than tools; pick stdio for local and Streamable HTTP for shared; test the generated schemas in the inspector.
  • “Tool or resource?” - tools are what the model chooses between, so read-only context belongs as a resource. Exposing everything as a tool gives you forty options and a model that picks badly.
  • “How do you handle errors?” - return a readable sentence rather than raising, because an error the model can read is one it can recover from. Never include the internal exception message: it lands in the model’s context and potentially in a user’s chat window.
  • “How do you authorise a remote server?” - against the calling user’s permissions, never against what the model asserts. Credentials arrive by env var for stdio or header for HTTP, never as tool arguments, since arguments originate from whatever the model just read.
  • “How do you test one?” - the MCP inspector for schemas and manual calls, then ordinary pytest against the functions, which need no MCP mocking if you keep tool bodies thin and the logic in the services beneath.