To build an MCP server, wrap the capabilities you already have in a few well-described functions, using the official SDK, then serve them over HTTP with a token check in front. The protocol work is done for you. What decides whether the server is any good is which tools you expose and how you describe them.
This guide builds a small remote server in Python: one tool, the HTTP transport, authentication, a Cloud Run deploy and the monthly cost, then the lessons that only show up once a real agent uses it. The example is generic, the patterns are the ones we use.
Start with the decision, not the code
An MCP server is worth building when several AI clients need the same capability, or a person's permissions must follow them into the agent. If one script calls your API on a schedule, just call the API, MCP vs API covers that decision.
A minimal Python MCP server, with the official SDK
Install the official Python SDK. Version 2 calls the server class MCPServer; on version 1 it is FastMCP, in mcp.server.fastmcp.
uv add "mcp[cli]" httpx uvicorn
Here is one tool over an existing orders API:
import os
import httpx
from mcp.server import MCPServer
from mcp.types import ToolAnnotations
API_BASE = os.environ.get("API_BASE", "https://api.example.com/v1")
mcp = MCPServer("orders")
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
async def order_status(order_id: str) -> dict:
"""Return the status, items and expected delivery date of one order.
Use this when someone asks where an order is or when it will arrive.
"""
async with httpx.AsyncClient(base_url=API_BASE, timeout=10) as client:
resp = await client.get(f"/orders/{order_id}")
resp.raise_for_status()
return resp.json()
The type hints become the input schema the model fills in. The docstring is what the model reads to decide when to call the tool, so write it for someone who has never seen your system. The read-only annotation tells the client the tool changes nothing.
Run it locally with mcp.run() and connect it from Claude Code over stdio before you think about hosting. If you want to know how to add an MCP server to your client, be it Claude Code, Claude Desktop or claude.ai, What is Claude MCP walks through the commands and config files.
Building a remote MCP server: streamable HTTP
A local server talks over standard input and output (stdio). A remote server answers HTTP on one path, so any client that can reach the URL can use it, including claude.ai in a browser. To put the same server on a port, name the transport in run():
if __name__ == "__main__":
mcp.run(transport="streamable-http", port=3001)
That one line builds a Starlette app and serves it with uvicorn, and clients connect to http://127.0.0.1:3001/mcp. Transport options such as host, port and streamable_http_path go to run(), not to MCPServer(...).
run() starts one process on localhost. To deploy, build the ASGI app yourself and allowlist the hostname you serve:
from mcp.server.transport_security import TransportSecuritySettings
security = TransportSecuritySettings(
allowed_hosts=["mcp.example.com", "mcp.example.com:*"],
allowed_origins=["https://app.example.com"],
)
app = mcp.streamable_http_app(transport_security=security)
Then serve it with uvicorn:
uvicorn server:app --workers 4
allowed_hosts is the go-live gate. By default the app only accepts requests addressed to localhost, a guard against DNS rebinding, so a deployed server answers every request with 421 until you list its real hostname; list both the bare host and the :* form. allowed_origins only matters for browser clients.
If TLS ends at a proxy in front of uvicorn, add --proxy-headers and set --forwarded-allow-ips to the proxy's address, or to '*' when nothing but the proxy can reach uvicorn; otherwise the app's redirects point at http:// and clients refuse them. Clients on the current protocol revision (2026-07-28) send self-contained requests, so any worker can answer any of them. Current Claude Code speaks that revision. Older clients (2025-11-25 and earlier) keep a session in one worker's memory, so they need sticky sessions, or stateless_http=True on streamable_http_app(), which drops sessions and server-to-client features such as sampling.
Authentication: check a token before any tool runs
A remote server without auth is a public API to your data. The SDK's integration point is a token verifier:
import secrets
from pydantic import AnyHttpUrl
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings
class EnvTokenVerifier(TokenVerifier):
async def verify_token(self, token: str) -> AccessToken | None:
expected = os.environ.get("MCP_TOKEN", "")
if expected and secrets.compare_digest(token, expected):
return AccessToken(token=token, client_id="internal", scopes=["orders:read"])
return None
mcp = MCPServer(
"orders",
token_verifier=EnvTokenVerifier(),
auth=AuthSettings(
issuer_url=AnyHttpUrl("https://auth.example.com"),
resource_server_url=AnyHttpUrl("https://mcp.example.com/mcp"),
required_scopes=["orders:read"],
),
)
With that in place, a request without a valid token gets a 401 whose WWW-Authenticate header points to the server's protected-resource metadata, which the SDK serves for you at /.well-known/oauth-protected-resource/mcp. Clients that support OAuth follow it to your identity provider.
A shared token is a sensible first step for an internal tool, not the last. The next step is verifying tokens your identity provider issues to each user, so that every tool call runs with that person's permissions. The specification also forbids passing the client's token straight through to your backend; the server should hold its own credentials for the API, scoped to what the tools need.
I checked this exact code with version 2.3.0 of the SDK: without a token the server answered 401 with the metadata link, a wrong token was refused, and with the right token a client listed the tool and called it.
Deploying to Cloud Run
Any platform that runs a Python web app works. Cloud Run suits a small team server because it scales to zero when idle. A Dockerfile:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py .
CMD exec uvicorn server:app --host 0.0.0.0 --port ${PORT:-8080} --proxy-headers --forwarded-allow-ips='*'
Store the token in Secret Manager, then deploy from the source directory:
gcloud run deploy orders-mcp --source . --region us-central1 \
--allow-unauthenticated \
--set-secrets MCP_TOKEN=orders-mcp-token:latest \
--set-env-vars API_BASE=https://api.example.com/v1
Only Cloud Run's front end can reach the container, so the CMD trusts its forwarded headers. --allow-unauthenticated looks alarming, but it only switches off Cloud Run's own Google-identity check; your token verifier is the gate. Put the service URL that Cloud Run prints into allowed_hosts and resource_server_url, or map your own domain first and use that.
What it costs
For a low-traffic internal server, usually nothing. Cloud Run's free tier with request-based billing includes 2 million requests, 180,000 vCPU-seconds and 360,000 GiB-seconds a month, per billing account. A team tool serving a few thousand calls a day stays well inside it.
The surprise is cold starts: a server scaled to zero takes a few seconds to wake. Keeping one instance warm fixes that, but idle time is then billed. The model's tokens for tool definitions and results usually cost more than the server, one more reason to keep the tool list short.
PostgreSQL MCP servers: start read-only
The tempting design is one run_sql tool. Start instead with a few named, read-only tools over queries you wrote, a database role that can only read, and a row limit. The read-only role is the one guard that doesn't rely on the model behaving.
What we learnt building MCP servers
We put one in front of our own APIs; MCP vs API has the longer story:
- Fewer, task-shaped tools. Our first version mapped endpoints to tools one to one and exposed around 150 functions. The agent was poor at choosing among them. A handful of tools shaped like the questions people actually ask worked far better.
- Read before write. Start with read-only tools, and add writes one at a time, each narrowly scoped and confirmed by the user.
- The user's permissions, not the server's. A tool call made for a person should see only what that person can see in your app. Design for this from the start; retrofitting it is painful.
- Descriptions are the interface. The model only knows a tool through its name, schema and docstring. When an agent picks the wrong tool, improve the description before you add code.
How to build an MCP server: common questions
How do I build an MCP server? Install the official SDK, create an MCPServer, and turn each capability into a decorated Python function with type hints and a clear docstring. Run it over stdio for local use, or as a streamable HTTP app for remote use, then connect it from Claude Code with claude mcp add and call one tool to check it works.
How can I host my own MCP server? Any platform that runs a Python web app will do, because a remote MCP server is an ASGI app on one URL path. A container on Cloud Run, Fly or a small VM all work. Set the allowed hostnames, put a token check in front of the tools, and keep secrets in the platform's secret store.
How do I build a remote MCP server? Use the streamable HTTP transport instead of stdio. With the Python SDK, call streamable_http_app() and serve the result with uvicorn. The server then answers at one path, /mcp by default, so any MCP client that can reach the URL can connect. Add a token verifier before you expose it.
How much does an MCP server cost to run? For a low-traffic internal server, often nothing. Google Cloud Run's free tier includes 2 million requests and 180,000 vCPU-seconds a month with request-based billing, which covers most team tools. Cost appears when you keep instances warm to avoid cold starts, because idle time is then billed too.
Should a PostgreSQL MCP server run raw SQL? Not at first. Give the model a few named, read-only tools, such as find_customer or recent_orders, that run queries you wrote, and connect with a database role that can only read. Raw SQL is tempting for analysis, but it hands the model your whole schema and every mistake it can make.
Where to start
Build one read-only tool for the question your team asks most, run it locally for a week, and only then make it remote. Once it is connected, a Skill can teach Claude how your team uses it; Claude Skills vs MCP servers explains how the two fit together.
What is the first tool you would expose?
Originally published on nulltensor.com.
Top comments (0)