Skip to content

How to Build a Remote MCP Server in Python

Published July 6, 2026

A remote MCP server is an HTTP service an agent can call. It is not a library you import into the model, and it is not a stdio process that only exists on one laptop. This post is the shape we use for the Calorie API nutrition server: FastMCP, Streamable HTTP, one process, no session pinned to a dyno.

The primary job of the server is small. initialize names the server. tools/list returns the tools. tools/call runs one tool and returns JSON. Everything else (accounts, rate limits, photos) hangs off that third call.

Why stateless HTTP

Agents open a lot of short calls. A lunch log might be a search, a portion scale, and a recipe total, each a separate HTTP request, often seconds apart, sometimes from a different connection. If the server keeps an in-memory session, the next request has to land on the same process or the client has to replay a session id. On a host that can restart or run more than one process, that session becomes a failure mode.

We run FastMCP with stateless_http=True and json_response=True. Each request carries what it needs. The response is a normal JSON body, which is easier to log and to bill than a long-lived stream. The tradeoff is real: you do not get a sticky session to hide authentication in. The credential has to be on the request. That is the subject of the authentication article in this series.

The path must not double

FastMCP's default streamable path is /mcp. If you then mount the app at /mcp, the public URL becomes /mcp/mcp. Clients that were given https://your-api.example/mcp get a 404 and the usual "could not reach the server" message, which looks like a network problem.

Set the streamable path to / and mount the ASGI app at /mcp. The URL you publish is the mount point. Ours is the API origin plus /mcp. The docs and the connect snippets on the MCP page use that URL. Do not invent a second prefix in the client config.

from mcp.server.fastmcp import FastMCP

mcp = FastMCP(
    name="calorie-api",
    stateless_http=True,
    json_response=True,
    streamable_http_path="/",
)

@mcp.tool()
def search_foods(query: str) -> dict:
    return {"query": query, "results": []}

That sample is the skeleton. It does not search a catalog. It shows the constructor we actually use: a name, stateless HTTP, JSON responses, and a streamable path of / so a later mount at /mcp is the public URL.

Mount it on the existing API

The MCP app is an ASGI app. Mount it on the same FastAPI process that already serves REST, billing, and account routes. Protocol chatter (initialize, tools/list) is not a food lookup. Tool calls are. We record tool calls as /api/v1/mcp/<tool name> so the usage chart can name the tool, and we skip the /mcp prefix in the REST rate-limit middleware so a handshake is not billed as a search.

The MCP server's own lifespan has to run. If you mount the app and forget to nest its lifespan inside the parent app's lifespan, the first request fails even though the route exists. Start the inner lifespan when the outer app starts, and only when the server is enabled.

We keep the server off unless MCP_ENABLED is true. A deploy of the code does not expose tools until that flag is on. That is deliberate. The catalog, the plan row, and the flag are separate switches.

What the tool list is

The nutrition server exposes eight tools:

  • search_foods: name to foods, with calories and macros per 100 g and a food_id
  • get_food_nutrition: one food_id to the nutrient list
  • suggest_foods: a short prefix, for autocomplete
  • lookup_barcode: the argument is upc, 8 to 14 digits
  • calculate_portion: food_id and grams
  • calculate_recipe: ingredients of {food_id, grams} and servings
  • calculate_macro_targets: age, gender, weight, height, activity, goal
  • analyze_food_photo: image_base64 and a content type

Two prompts (log_a_meal, plan_my_macros) and one resource (nutrition://limits) sit beside the tools. The prompts are instructions. They do not skip the tools. A model that invents a food_id instead of calling search_foods will fail the portion call. Say that in the tool description, not only in a later article in this series.

Host header and DNS rebinding

The process listens in the normal way behind a proxy. Proxy Host headers are not the internal bind address. The MCP Python SDK can reject those requests as DNS rebinding and return 421. We turn that protection off for this deployment so a normal proxy host is accepted.

That is a tradeoff, not a free pass. A call still needs an API key or an OAuth access token. The rebinding check is not the authorization check. If you copy this pattern, keep the credential check. Do not treat "the host header was allowed" as authentication.

A request, end to end

  1. The client POSTs to https://<api-origin>/mcp.
  2. initialize returns the server name calorie-api.
  3. tools/list returns the eight tools and their argument names.
  4. tools/call with search_foods runs only after the credential resolves to an account on the MCP plan.
  5. The handler returns JSON. The call is metered under /api/v1/mcp/search_foods.

initialize without a credential succeeds only while the OAuth challenge is off. When OAuth is enabled, a request with neither an API key nor a bearer token is HTTP 401, with a WWW-Authenticate header that points at the protected-resource metadata. API-key clients keep sending X-API-Key and do not go through that screen. Both behaviors are in the MCP docs.

What this post does not claim

There is no latency table here. We have not published a p95 for this server under a 20-session load, and a made-up millisecond figure would be worse than silence. The design choices above are the ones in the code: stateless JSON, path / mounted at /mcp, tool calls metered separately from the handshake, and the server gated by a flag.

A tools/call, not a session

The body the client posts is JSON-RPC. A tool call looks like this:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "search_foods",
    "arguments": {"query": "greek yogurt", "limit": 5}
  }
}

id is the client's correlation id. arguments has to match the schema. There is no session id in this body, because the server is stateless. The credential is a header on the HTTP request, which the next article covers. If you put the credential in arguments, the model can see it. Don't.

Two prompts ship with the tools: log_a_meal and plan_my_macros. A prompt is a recipe for the model. It is not a tool and it does not skip search_foods. One resource, nutrition://limits, is there so a client can read the caps without calling a food tool. Reading it is not a search.

Errors the tool returns are one of four kinds: the caller is missing or unknown, the plan does not include MCP, a limit was hit, or the service could not finish. The message for the last one does not include a stack trace. The message for a limit includes the cap. That split is useless if you collapse them into one string that says "error."

The process flag MCP_ENABLED defaults to false. OAuth has its own flag, MCP_OAUTH_ENABLED, and it defaults to false as well. Turning the code on in a repository is not the same as turning the server on in an environment. Keep those separate so a deploy cannot surprise an existing REST customer with a new public surface before you mean to.

A parent FastAPI app already has its own lifespan: the database pool, and then a yield. The mounted MCP app has a session-manager lifespan of its own. If you mount the ASGI app and never enter that inner lifespan, the route exists and the first request still fails. Enter the inner lifespan only when the flag is on, from inside the outer lifespan, and exit it on shutdown. That is the difference between "the path is mounted" and "the path answers."

Bind the process the way the platform expects. Behind a proxy, the Host header is the public name, not the container name. The SDK's DNS-rebinding check can answer 421 for a host you did not list. This server turns that check off so a normal proxy host is accepted. Authorization is the API key or the bearer token, not the host header. Copy the flag only if you keep the credential check.

initialize returns the server name calorie-api. tools/list returns names, descriptions, and the argument schema. tools/call is the only method that should touch the catalog. We skip /mcp in the REST limiter and record the tool under /api/v1/mcp/<tool>. If those two paths are the same string, you will either bill handshakes or lose the tool name in the usage chart.

Limits, which are configured rather than measured in a scrape test, are the subject of the rate-limit article in this series. The plan those limits belong to is on MCP pricing.

Frequently Asked Questions

Where should a remote MCP server listen?

Publish one HTTPS URL that ends in /mcp. Mount the app at /mcp and set the streamable HTTP path to / so the public path is not /mcp/mcp.

Should the server keep a session?

For a multi-process host, no. A stateless JSON request carries its own credential. A sticky session fails when the next call lands on another process.

Is initialize a billable food lookup?

No. Handshake methods are not metered as food calls. tools/call is, one row per tool, under a path that includes the tool name.

Does this sample search a food database?

No. The sample is the constructor and one tool stub. A production tool still has to resolve the caller, enforce the plan, and query the catalog.

← Back to all articles

Start building with the Calorie API

Get a free API key and access 4M+ foods with search, barcode lookup, and full macro data.