Cookie preferences

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

Manufact

Deploying Seven MCP Frameworks: A Field Report

Enrico Toniato
Enrico ToniatoCTO
Deploying Seven MCP Frameworks: A Field Report

I wanted ground truth on deploy shape, not README opinions. We took the same pair of tools: a plain echo and a greet_widget that returns an interactive view following the MCP Apps protocol, and deployed each implementation to Manufact Cloud (GitHub connect, build, live /mcp):

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

The seven stacks:

  • mcp-typescript: the official @modelcontextprotocol/sdk plus @modelcontextprotocol/ext-apps
  • mcp-python: the official mcp Python SDK with FastMCP
  • mcp-use: our own SDK with native widget() helper and auto-discovered React widgets
  • tmcp: Paolo Ricciuti's schema-agnostic TypeScript SDK
  • xmcp: basementstudio's file-based framework with Rspack and built-in widget tools
  • skybridge: Alpic's React-first framework with a Vite + tsc -b build
  • fastmcp: Jorimaos' Pythonic batteries-included server, with the Prefab UI app system

By the end I had seven /mcp endpoints, each registering both tools, each rendering a widget through a text/html;profile=mcp-app resource. Five of them deployed on the first try after I ironed out a generic build-system bug. Two needed framework-specific accommodations on the cloud side. None of them was the same shape as another.

What follows is the field report: the surprises, the time-sinks, and an honest read on where each framework earns its keep. Disclosure up front: we maintain mcp-use, so I have a horse in this race. I've tried to keep the comparison fair; the deploy URLs at the bottom of each guide let you verify any claim by hitting the live server.

The MCP Apps protocol, in one paragraph

A widget is a tool plus a resource. The resource serves HTML at a text/html;profile=mcp-app MIME type. The tool's _meta.ui.resourceUri points at that resource. The host fetches both, calls the tool, then renders the resource in a sandboxed iframe and pipes the tool result through to it via a small JS bridge (@modelcontextprotocol/ext-apps). The model only sees the tool's text content; the user sees the widget. That's the whole protocol. The seven frameworks differ entirely in how much of that boilerplate they hide.

What broke (and why)

A few problems showed up in more than one framework. They're worth naming because they shape every "should I pick X" question.

Build commands that name the binary directly

Several preset configurations defaulted to npm install && tsc or npm install && xmcp build for the build step. On Alpine-based runtime images these binaries aren't on PATH outside an npm script context: tsc is a node_modules bin, not a system command. Shell-form invocation finds nothing. The fix is uniform across frameworks: always run build steps as npm run build so the package's scripts.build shimming through node_modules' bin path applies. I rolled this into the cloud preset so future templates don't trip on it.

uv sync and the venv-vs-system PATH split

uv sync installs into .venv/, not site-packages. If your generated Dockerfile doesn't put /app/.venv/bin on PATH, the runtime CMD can't find uvicorn, python, or anything else uv installed. I now emit ENV PATH="/app/.venv/bin:${PATH}" after every uv sync step. This is invisible until you ship.

Lockfiles that pin an old version

The fastmcp starter ships with a uv.lock that pinned fastmcp==3.0.1. I needed >=3.2.4 for the [apps] extra. uv sync --frozen honours the lock no matter what pyproject.toml says: the lock is the contract. When uv.lock is in the repo, Manufact uses that frozen uv path; without it, the build falls back to pip install .. Removing the lock so the pip path regenerated dependencies from pyproject.toml was a one-off shortcut over a slow connection — not what I'd do in production. Lesson: regenerate the lock against the deploy target (uv lock on Python 3.12, commit both files), don't blindly trust whatever a starter committed.

DNS-rebinding protection in FastMCP

FastMCP from the official Python SDK ships transport security on by default. The Host header check only allows localhost unless you configure it. Behind a Fly proxy, every request lands with Host: <appname>.fly.dev and the server returns 421 Misdirected Request. The fix is one constructor argument:

from mcp.server.transport_security import TransportSecuritySettings server = FastMCP( "my-server", transport_security=TransportSecuritySettings( enable_dns_rebinding_protection=False ), )

Setting allowed_hosts=["*"] does not work: the matcher does exact string compare, not wildcards. You either disable the check or enumerate every host you'll be served from. For a public smoke-test server I disable; for a production server I'd allow-list the canonical domain.

Build-time Zod defaults baking the port

xmcp serializes its config object at build time. Its Zod schema has z.number().default(3001) for http.port. Even when you set http: true (no port), Zod fills in 3001 before serialization, so the runtime never falls through to process.env.PORT. The deploy target has to be told port=3001 to match. This is fine if you know. Annoying if you assume PORT env var is honoured everywhere.

Node version mismatches

The skybridge starter ships a Dockerfile pinned to node:24-slim and engines.node: ">=24.0.0". The fastmcp starter ships requires-python = ">=3.13". When I first hit this, the generated images were pinned to Node 22 / Python 3.12 and these starters failed the install. Manufact now reads engines.node and requires-python and selects a matching supported base image (Node 20/22/24, Python 3.11-3.13), so >=3.13 and >=24.0.0 deploy as declared — skybridge's own Dockerfile is still used when build/start aren't overridden. The remaining edge is versions beyond the supported set (e.g. Python >=3.14): commit a Dockerfile for those. This is the kind of incompatibility that doesn't show up on your laptop.

Stateful vs stateless transports

mcp-use ships in stateful mode by default: every request needs an mcp-session-id header obtained from initialize. fastmcp and the official Python SDK can be flipped to stateless with stateless_http=True. The MCP tools/list health probe in most cloud platforms is stateless, so unless your platform initializes first and reuses the session ID, stateful servers look broken to a probe. They aren't broken, but you have to know.

The seven frameworks at a glance

FrameworkRepoLive DemoLanguageLines of Code
mcp-typescriptLinkLinkTS~80
mcp-pythonLinkLinkPY~70
mcp-useLinkLinkTS~30
tmcpLinkLinkTS~80
xmcpLinkLinkTS~45
skybridgeLinkLinkTS~70
fastmcpLinkLinkPY~30

How the same widget looks in each

The same greet_widget(name) tool, in seven shapes:

mcp-typescript: explicit two-call registration. You write the HTML.

registerAppTool( server, "greet_widget", { description: "...", inputSchema: { name: z.string() }, _meta: { ui: { resourceUri: VIEW_URI } } }, async ({ name }) => ({ content: [{ type: "text", text: `Hello, ${name}!` }], structuredContent: { name } }), ); registerAppResource(server, "Greet view", VIEW_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => ({ contents: [{ uri: VIEW_URI, mimeType: RESOURCE_MIME_TYPE, text: VIEW_HTML }], }));

mcp-use: point a tool at a widget name, the framework wires the rest from your React component on disk.

server.tool({ name: "greet_widget", schema: z.object({ name: z.string() }), widget: { name: "greet-widget", invoking: "Preparing greeting...", invoked: "Greeting ready" }, }, async ({ name }) => widget({ props: { name }, output: text(`Hello, ${name}!`) }));
// resources/greet-widget/widget.tsx const GreetWidget = () => { const { props } = useWidget<{ name: string }>(); return <h1>Hello, {props.name}!</h1>; }; export default GreetWidget;

xmcp: every file in src/tools/ is both the tool definition and the React handler.

// src/tools/greet_widget.tsx export const metadata = { name: "greet_widget", description: "...", _meta: { ui: { csp: { connectDomains: [] }, prefersBorder: true } }, }; export const schema = { name: z.string() }; export default function GreetWidget({ name }: InferSchema<typeof schema>) { return <div>Hello, {name}!</div>; }

skybridge: a tool registers a view name; the matching src/views/<name>.tsx reads the tool result with useToolInfo.

server.registerTool( { name: "greet_widget", inputSchema: { name: z.string() }, view: { component: "greet" } }, async ({ name }) => ({ structuredContent: { name }, content: [{ type: "text", text: `Hello, ${name}!` }] }), );
// src/views/greet.tsx export default function Greet() { const { input, output } = useToolInfo<"greet_widget">(); return <h1>Hello, {output?.name ?? input?.name}!</h1>; }

tmcp: no native widget abstraction. You write the protocol calls yourself, and that's the whole point.

server.resource({ name: "Greet view", uri: VIEW_URI, mimeType: "text/html;profile=mcp-app" }, async (uri) => ({ contents: [{ uri, mimeType: "text/html;profile=mcp-app", text: VIEW_HTML }], })); server.tool({ name: "greet_widget", schema: z.object({ name: z.string() }), outputSchema: z.object({ name: z.string() }), _meta: { ui: { resourceUri: VIEW_URI }, "ui/resourceUri": VIEW_URI }, }, async ({ name }) => ({ content: [{ type: "text", text: `Hello, ${name}!` }], structuredContent: { name } }));

mcp-python: the say-server pattern from the ext-apps repo, applied verbatim.

@server.tool( name="greet_widget", description="...", meta={"ui": {"resourceUri": VIEW_URI}, "ui/resourceUri": VIEW_URI}, ) def greet_widget(name: str) -> GreetProps: return {"name": name} @server.resource(VIEW_URI, mime_type="text/html;profile=mcp-app") def greet_view() -> str: return VIEW_HTML

fastmcp: the most opinionated. Add app=True, return Prefab components, no HTML in sight.

@mcp.tool(app=True) def greet_widget(name: str) -> PrefabApp: with Column(gap=4) as view: with Card(): with CardContent(): Heading(f"Hello, {name}!") Muted("Greeting widget served by FastMCP.") return PrefabApp(view=view)

Same protocol on the wire. Wildly different code on the page.

Pros and cons, with receipts

mcp-typescript / mcp-python. The reference implementations. Nothing magic. You write every line, you understand every line, and the protocol fidelity is exact because it's literally the spec authors' code. The cost is verbosity: registering a widget is two function calls plus an HTML string, every time. If your team is going to ship one app, this is fine. If they're going to ship twenty, the boilerplate compounds.

mcp-use. Disclosure: this is ours. The widget config + resources/<name>/widget.tsx convention means a widget tool fits in roughly thirty lines including the React component, and tools render in the bundled inspector without a separate process. The trade-off is the framework runs Vite as a dev server beside your MCP server, which means more processes, more startup time, and occasional port pressure. The default stateful transport surprises probes. Both are configurable, neither is a deal-breaker.

tmcp. Schema-agnostic is the headline feature, and it actually delivers: Zod, Valibot, ArkType, and Effect Schema all work via adapters, with no SDK changes. The HTTP transport (HttpTransport.respond) hands you a Request and expects a Response back, which is portable across Node, Workers, and the edge. There's no widget DSL, no scaffolding, and no inspector; you bring your own. For a service that lives behind another framework (a Fastify plugin, a Hono adapter), tmcp is the right shape. For "I want to ship a widget by Friday" it's underbuilt.

xmcp. The file-based router is the headline feature. Every .tsx in src/tools/ is a tool with metadata at the top and a React component at the bottom. Tailwind comes wired up. The catch is Rspack: the build is fast but every tool ends up bundled separately, and the runtime port is baked at build time (Zod default of 3001). If your deploy target injects PORT at runtime, you either match the platform port to 3001 or fork the config schema. The opinions go deep: stepping outside the convention sometimes means stepping outside the framework.

skybridge. A complete React + Vite scaffold with HMR, dual-runtime widget emission (ChatGPT Apps SDK + MCP Apps), and a CLI that does build, dev, and start. The cost is weight: Node 24, React 19, Vite 7, peer-dep-strict installs, a generated Vite manifest the runtime imports. If your team already lives in that stack the ergonomics are good. If you don't, you're carrying a lot of frontend-shaped concerns into your runtime image for a /mcp endpoint.

fastmcp. Prefab UI is the most opinionated approach in the seven. You write Column, Heading, Card in Python; FastMCP serializes the component tree to JSON and sends it through the standard MCP App resource as a renderer that interprets the tree at the client. No HTML. No frontend build. The model never sees your data shape, only the textual [Rendered Prefab UI] placeholder, unless you wrap the result in ToolResult(content=...) to give the model a summary. This makes Python-only widgets very fast to ship, and pixel-perfect / brand-driven UIs hard to express because you're constrained to Prefab's component palette.

Each linked guide below is the deploy walkthrough for that framework's example, with the reference server in the second half of the post.

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

Share