Cookie preferences

We use cookies to improve your experience. See our Privacy Policy.

Manufact

How to Deploy an MCP Python Server to Production

Enrico Toniato
Enrico ToniatoCTO
How to Deploy an MCP Python Server to Production

This guide is the the official Python SDK entry from our seven-framework deploy comparison. We shipped the same example on Manufact Cloud: an echo tool and a greet_widget that returns a MCP Apps view. Below is the deploy path we used; the reference server code is in the second half if you want to reproduce the example.

If you want to run the same deploy pipeline on your repo, connect it in the dashboard. Open the dashboard.

Deploy to Manufact

This repo has no uv.lock, so Manufact installs via pip install .. For your own app, add and commit uv.lock after uv lock if you use uv — the cloud will run uv sync --frozen instead. Either way, the runtime launches uvicorn against the ASGI app you exported as app.

Push the repo to GitHub

git init && git add . && git commit -m "Initial commit" gh repo create my-org/mcp-python-greet --private --source=. --push

Open the new-server flow

Go to manufact.com/cloud/<your-org>/servers/new and pick Deploy from GitHub. The probe labels the repo mcp-python.

Set port and start command

  • Port: 8000
  • Build command: (leave empty: this repo has no uv.lock, so the cloud auto-runs pip install .; with a committed uv.lock it uses uv sync --frozen instead)
  • Start command: uvicorn 'my_server:app' --host 0.0.0.0 --port 8000

The single quotes around the module name are important if your package has dashes: Python module names use underscores even when the package name has dashes.

Click Deploy

First install pulls mcp[cli], uvicorn, starlette, and transitives (~30–60s). Subsequent deploys reuse layer cache.

Smoke-test the URL

curl -s -X POST -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'Host: my-server.fly.dev' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \ https://<your-slug>.run.mcp-use.com/mcp

The non-localhost Host header is what tripped earlier deployments. With enable_dns_rebinding_protection=False, you should now see both tools and the resource URI.

Example repo: manufacts/mcp-detect-mcp-python

Live /mcp: bold-forge-w0fwk.run.mcp-use.com/mcpserverInfo.name is mcp-detect-mcp-python (Manufact server status: running).

Note

The say-server pattern

The same meta={"ui": {"resourceUri": URI}} + @server.resource(mime_type="text/html;profile=mcp-app") pattern shows up in the official ext-apps say-server: a streaming TTS demo with a richer view. Worth reading once you're past the "Hello, World" stage.

The steps above are what we used for the live demo. Your repo can use the same GitHub deploy flow. Open the dashboard.

Reference server

Use this section if you are following along with the same example. If you already have an MCP app to deploy, the GitHub steps above are enough.

What we deployed

  • A FastMCP server from the official mcp[cli] package
  • An echo tool (text-only)
  • A greet_widget tool with meta={"ui": {"resourceUri": ...}} linking to a text/html;profile=mcp-app resource

Project setup

mkdir my-server && cd my-server python3 -m venv .venv source .venv/bin/activate pip install "mcp[cli]" uvicorn

pyproject.toml:

[project] name = "my-server" version = "0.1.0" requires-python = ">=3.11" dependencies = [ "mcp[cli]>=1.0.0", "uvicorn>=0.30.0", "starlette>=0.40.0", ] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/my_server"]

Project layout:

my-server/ ├── pyproject.toml └── src/ └── my_server/ └── __init__.py

The server

src/my_server/__init__.py:

"""Greet widget example using the official mcp Python SDK.""" from typing import TypedDict from mcp.server.fastmcp import FastMCP from mcp.server.transport_security import TransportSecuritySettings class GreetProps(TypedDict): """Structured payload sent to the View as ``structuredContent``.""" name: str VIEW_URI = "ui://my-server/greet.html" RESOURCE_MIME_TYPE = "text/html;profile=mcp-app" VIEW_HTML = """<!doctype html> <html><body style="font:16px/1.4 system-ui;padding:24px"> <h1 id="greeting">Greeting view loaded.</h1> <script type="module"> import { App } from "https://esm.sh/@modelcontextprotocol/ext-apps@1"; const app = new App({ name: "greet", version: "0.1.0" }); app.ontoolresult = (r) => { const text = (r?.content ?? []).find(c => c.type === "text")?.text; const struct = r?.structuredContent; if (text) document.getElementById("greeting").textContent = text; if (struct?.name) document.title = "Hello, " + struct.name; }; app.connect(); </script> </body></html>""" # Disable DNS-rebinding protection so the server is reachable behind any proxy # host (Fly, Cloudflare, etc.). FastMCP's default Host check only allows # localhost, returning 421 Misdirected Request from any other origin. server = FastMCP( "my-server", transport_security=TransportSecuritySettings( enable_dns_rebinding_protection=False ), stateless_http=True, ) @server.tool(name="echo", description="Echo the input back as text.") def echo(text: str) -> str: return text @server.tool( name="greet_widget", description="Greet someone and render an MCP App view.", meta={ "ui": {"resourceUri": VIEW_URI}, "ui/resourceUri": VIEW_URI, }, ) def greet_widget(name: str) -> GreetProps: """Return structuredContent the MCP Apps view renders.""" return {"name": name} @server.resource( VIEW_URI, name="Greet view", description="MCP Apps view for the greet_widget tool.", mime_type=RESOURCE_MIME_TYPE, ) def greet_view() -> str: return VIEW_HTML # Streamable-HTTP ASGI app (mounted at /mcp). app = server.streamable_http_app()

Two protocol-level patterns to notice:

  • meta= on @server.tool is how you set MCP _meta on the tool definition. Both meta={"ui": {"resourceUri": …}} and meta={"ui/resourceUri": …} are common: modern hosts read the first, older hosts read the second, so set both.
  • @server.resource(URI, mime_type="text/html;profile=mcp-app") is the universal way to register a UI resource in the Python SDK. Returning a string makes FastMCP serve it as the resource's text content.

The TypedDict return on greet_widget matters: FastMCP introspects the type to validate structuredContent against an output schema. A plain dict return raises InvalidSignature: return type <class 'dict'> is not serializable for structured output.

Why disable DNS-rebinding protection

FastMCP's default TransportSecurityMiddleware only allows localhost in the Host header. Behind a Fly proxy, every request lands with Host: <appname>.fly.dev and the server returns 421 Misdirected Request. Setting allowed_hosts=["*"] does not help: the matcher does exact-string compare, no wildcards. Either disable the check entirely (fine for a public smoke-test server) or enumerate every host you'll be served from. For production, allow-list the canonical domain explicitly.

Run it

pip install -e . uvicorn 'my_server:app' --host 0.0.0.0 --port 8000

Hit it:

curl -s -X POST -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \ http://localhost:8000/mcp

greet_widget._meta.ui.resourceUri should be ui://my-server/greet.html, and resources/list returns the view at that URI with mimeType: "text/html;profile=mcp-app".

When to reach for it

Pick this when you have a Python-only constraint and want protocol-level control with no framework opinions in the way. The trade-off is verbosity: every widget is a meta= argument plus a @server.resource(...) plus an HTML string you write by hand. For complex views you'll bring your own bundler (Vite emitting into a Python file server works fine).

If you're not strictly Python, mcp-use (TypeScript) covers the same protocol with a widget() helper that auto-registers the resource for you. The full comparison is in Deploying Seven MCP Frameworks.

Share