Discuss online

XXBlueskyBlueskyRedditRedditFacebookFacebookEmail

How to Build an MCP Server: A Python Tutorial

12 minute read

Summarize with:

Discuss online

XXBlueskyBlueskyRedditRedditFacebookFacebookEmail

How to Build an MCP Server: A Python Tutorial

To build an MCP server, define a tool, register it with an MCP SDK, test it with an MCP client, and connect it to the application that will use it. Start with a local, read-only tool; add real data, credentials, and state-changing operations only after you have defined how access and failures will be handled.

This tutorial builds a small Python server that looks up example order statuses, tests it with MCP Inspector, and connects it to Claude Desktop. It also shows what must change before the same design can safely serve real users.

Key Takeaways

  • Build one narrow tool first. This example exposes a read-only order lookup using the official Python MCP SDK.
  • Use stdio for the local walkthrough. The client starts the server as a subprocess and communicates through standard input and output.
  • Test before connecting a model. MCP Inspector lets you inspect the tool schema and call it with valid and invalid inputs.
  • Treat the model’s arguments as untrusted. Validate inputs and enforce user and resource permissions in application code.
  • Secure remote access separately. An HTTP endpoint, valid schema, or tool description does not provide authentication or approval by itself.

What You Will Build

The example server exposes one tool, get_order_status, backed by an in-memory dictionary. An MCP-compatible client can discover the tool, provide an order ID, and receive either a status or an error. The data is fictional: the example does not connect to a real order system or establish a user’s identity.

The components have separate responsibilities:

  1. Host: The application the user interacts with, such as an AI assistant or development environment.
  2. MCP client: The host component that connects to the server and exchanges protocol messages.
  3. MCP server: The program you write, which exposes tools or other supported capabilities.
  4. Underlying system: Optional application code, a database, or an external API called by the server.

MCP tools are callable operations; resources provide context, and prompts provide reusable message templates. A server does not need all three for a useful first project. The Python SDK’s first-steps guide explains these primitives and how a host, client, and server relate.

An MCP server can wrap an existing service API, but it is a separate interface. The API vs. MCP guide explains where each fits.

Step 1: Set Up the Project

You will need Python, the uv Python package manager, and Node.js with npx for the Inspector. The Python SDK installation guide specifies Python 3.11 or later; the Inspector documentation specifies Node.js 22.19.0 or later.

Create a project and install the SDK with its command-line tools:

uv init mcp-order-server
cd mcp-order-server
uv add "mcp[cli]>=2,<3"

The version range keeps this example on the SDK’s 2.x line. The SDK’s v2 migration notes explain that its server class is MCPServer; older examples using FastMCP target the v1 interface. If you use a different SDK version, check its documentation rather than mixing the two examples.

Keep the generated lockfile with your project so that the environment can be reproduced. The example below does not need API keys, a database, or a paid service.

Step 2: Build the MCP Server in Python

Create a file named server.py in the project directory:

import re

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError

mcp = MCPServer("Order Status Demo")

# Fictional data for local testing only.
ORDERS = {
    "4821": "shipped",
    "4822": "processing",
}


@mcp.tool()
def get_order_status(order_id: str) -> str:
    """Look up the status of an example order by its four-digit ID."""
    if re.fullmatch(r"[0-9]{4}", order_id) is None:
        raise ToolError("Order ID must contain exactly four digits.")

    status = ORDERS.get(order_id)
    if status is None:
        raise ToolError("Order not found.")

    return f"Order {order_id}: {status}"


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

The decorator registers the function as a tool. The SDK uses its name, docstring, and type hints to describe the tool and its input to clients; the server code applies the additional four-digit rule. The SDK’s host-connection example uses the same MCPServer, ToolError, and guarded mcp.run() pattern.

When started directly, mcp.run() uses standard input and output, or stdio. Do not write debugging messages to stdout in this mode: it carries protocol messages. Use logging directed to stderr instead.

The format check is deliberately small. It prevents malformed IDs from reaching this demo’s lookup, but it does not prove that a user owns an order. A real implementation needs an authenticated identity and a resource-level authorization check before returning any private data.

Step 3: Test the MCP Server with Inspector

From the project directory, launch the development tool:

uv run mcp dev server.py

Open the URL printed by the command. The SDK’s first-steps guide uses this command to launch the Inspector against a local server. Keep its access URL private and use it only in a trusted local environment.

In the Inspector, connect to the server if prompted, open Tools, and list the available tools. Select get_order_status, enter 4821 as order_id, and run the call. The result should contain:

Order 4821: shipped

Then test the boundaries:

  • 4822 should return Order 4822: processing.
  • 9999 should produce an order-not-found error.
  • abc or 12345 should produce a format error.
  • Omitting the required argument should be rejected rather than treated as a valid lookup.

The exact error presentation depends on the client. A tool error is different from an empty successful result: do not make your application interpret a failure as evidence that an order does not exist. Stop the development process with Ctrl-C when you are finished.

Step 4: Connect the MCP Server to Claude Desktop

After the Inspector test succeeds, you can register the same local server with Claude Desktop. With Claude Desktop installed and opened at least once, run this command from the project directory:

uv run mcp install server.py

The SDK’s host guide says this writes a launch configuration for the server, including an absolute script path. Fully quit Claude Desktop and reopen it so it reads the updated configuration.

Check that Order Status Demo and get_order_status are available in the application’s MCP or tool interface. Then try a request such as:

Check the status of example order 4821.

A successful tool call should return the demo’s shipped status. The application may show a tool-use or approval prompt, and the model may not choose a tool for every wording. Check the actual tool activity rather than assuming the model called it because its answer looks plausible.

To stop using the server, remove or disable its entry in the host configuration and restart the host as needed. For other clients, use their documented MCP configuration: a client may expect a different JSON structure or setup procedure.

Step 5: Replace Example Data with a Real API

To use real order data, replace the dictionary lookup with a call to your order service. Keep the MCP tool focused on one operation: accept an order ID, retrieve an authorized status, and return only the fields needed for the user’s task.

A production request path should look like this:

  1. Identify the caller through trusted infrastructure. Obtain the authenticated user or service identity from the host or server’s security context—not from an order_id argument or a user ID the model invents.
  2. Validate the request. Check the ID’s format and any business constraints before calling the service.
  3. Authorize the resource. Verify that this identity can access this particular order. Perform the check in trusted code or a trusted downstream service.
  4. Call the API with scoped credentials. Load credentials from an appropriate secret store or execution environment, not from tool descriptions or model-visible output.
  5. Return a bounded result. Include the status and relevant freshness information if the service provides it. Do not return unrelated customer details, tokens, or internal stack traces.

Decide how to distinguish a missing record, an access denial, an upstream outage, and an unknown outcome. Depending on your privacy requirements, the message shown to the caller may intentionally avoid revealing whether an inaccessible record exists. Keep more detailed diagnostics in appropriately protected logs.

When documenting the tool, specify its input format, output fields, error behavior, and side effects. An agent-friendly API design specification can help make those decisions explicit, but documentation is not enforcement.

For an operation that changes state, such as issuing a refund, add a separate approval flow. Show an authorized reviewer the exact action and parameters; invalidate approval if those details change. If a write times out, its outcome may be unknown. Check the destination or use a documented idempotency mechanism before retrying, and stop if you cannot resolve the outcome.

Choose a Transport and Secure the Server

Use stdio when the client and server run on the same machine and the client can start the server as a subprocess. For a server accessed over a network, Streamable HTTP provides a different connection path; the client connects to an endpoint rather than launching your file.

You can try the SDK’s HTTP transport locally without changing server.py:

uv run mcp run server.py --transport streamable-http

The SDK’s example connects a client to http://localhost:8000/mcp. Use the address and port reported by your own configuration, and stop the process with Ctrl-C. This local command is a development exercise, not a public deployment recipe.

Before exposing a server remotely, define and test these controls:

  • Transport and network: Use encrypted connections for remote access, restrict network exposure, and bind local-only services to localhost rather than every interface. Validate Origin as required by the Streamable HTTP specification.
  • Authentication and authorization: Verify caller credentials and authorize each operation and resource. If using OAuth, validate tokens intended for your MCP server; do not blindly forward client tokens to another service. The MCP security guidance describes token passthrough as an anti-pattern.
  • Secrets and privileges: Use narrowly scoped credentials, isolate tenants, and make rotation and revocation possible. A server that can read every customer record can expose more than the user is allowed to see unless access is checked per request.
  • Untrusted tool results: Treat API responses and documents as data, even when they contain instructions. They must not change permissions, recipients, approval requirements, or system policy.
  • Operations and monitoring: Set timeouts, concurrency and rate limits, a shutdown path, and protected logs. Record enough information to investigate a failure without storing secrets or unnecessary personal data.

Tool descriptions can help a model select an operation, but they are not security controls. The server and the infrastructure around it must enforce the actual rules.

Test and Troubleshoot Your MCP Server

Inspector is useful for interactive checks. Add automated tests so that later changes do not silently break the tool contract. The SDK testing guide shows how to connect an MCP client directly to a server in memory.

Install the test dependency:

uv add --dev pytest

Create test_server.py next to server.py:

import pytest
from mcp import Client

from server import mcp


@pytest.fixture
def anyio_backend():
    return "asyncio"


@pytest.mark.anyio
async def test_known_order():
    async with Client(mcp) as client:
        result = await client.call_tool(
            "get_order_status", {"order_id": "4821"}
        )
        assert not result.is_error
        assert "Order 4821: shipped" in result.content[0].text


@pytest.mark.anyio
async def test_invalid_order_id():
    async with Client(mcp) as client:
        result = await client.call_tool(
            "get_order_status", {"order_id": "abc"}
        )
        assert result.is_error

Run uv run pytest -q. These tests cover the tool through an in-memory client, not the desktop host or network transport. Add separate integration tests for those boundaries.

If something fails, check the layer before changing the tool:

  • Import error: Confirm the active environment contains the v2 SDK. FastMCP and MCPServer examples use different version lines.
  • Inspector does not start: Check that Node.js and npx are available and that the SDK CLI extra is installed.
  • Tool is missing: Save server.py, restart the server, and check that the @mcp.tool() decorator is present. Refresh the client’s tool list.
  • Desktop host cannot launch the server: Check the registered absolute paths, the host’s environment, and whether it was fully restarted. Run the launch command from a terminal to expose startup errors; a stdio process waiting silently for input can be normal.
  • Unexpected protocol errors: Remove application output from stdout and inspect stderr or the host’s logs instead.
  • Real API returns an error: Check the authenticated identity, resource permission, credential scope, and upstream response separately. Do not turn an access failure into a successful empty result.

Before rollout, also test another user’s record, expired credentials, malformed upstream responses, rate limits, and a lost response after a write. Verify that the application stops or escalates when it cannot establish a safe outcome.

Conclusion

A working MCP server is more than a function with a decorator. It is a contract between a client and code that must validate inputs, protect data, report errors honestly, and stop when an operation cannot be completed safely.

Begin with the local order-status example, confirm its behavior in Inspector and a real host, and add automated tests. When you replace the demo data with a service API, add authentication and resource-level authorization before giving the tool access to real records; introduce state-changing actions only after approval and recovery rules are in place.

FAQ

What is the easiest way to build an MCP server?

Start with an official SDK and one small, read-only tool. Run it locally over stdio, test its inputs and outputs with MCP Inspector, and connect it to a compatible host before introducing real data or write access.

Can an MCP server call an existing API?

Yes. The server can expose an MCP tool and translate a valid, authorized tool call into a request to an existing service API. The server still needs to handle credentials, resource permissions, errors, and the API’s own contract.

Does an MCP server have to run remotely?

No. A client can launch a local server as a subprocess and communicate with it over stdio. A remote server can use Streamable HTTP, but needs additional network, authentication, and authorization controls.

What is the difference between an MCP tool and a resource?

A tool is an operation a client can invoke, such as looking up an order or initiating an action. A resource provides content or context to the client; support and presentation can vary by host.

Is an MCP server automatically secure?

No. MCP defines a communication protocol; it does not automatically decide who can access a record or approve a consequential action. Implement validation, identity checks, authorization, secret handling, and appropriate deployment controls separately.

All posts