Cookie preferences

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

Manufact

How to Deploy an MCP TypeScript Server to Production

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

This guide is the the official TypeScript 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

Manufact Cloud builds your repo with Depot, runs it on Fly, and gives you a stable *.run.mcp-use.com URL with a verified MCP endpoint. The whole flow is GitHub → Cloud → working /mcp.

Push the repo to GitHub

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

Open the new-server flow

Go to manufact.com/cloud/<your-org>/servers/new and pick Deploy from GitHub. If you've never connected GitHub, the dialog walks you through installing the Manufact GitHub App on the org or user that owns the repo.

Pick the repo and branch

Search for mcp-typescript-greet, select it, leave the branch on main. Manufact runs framework detection in the background: for this repo it'll label it mcp-typescript.

Confirm build and start

The preset auto-fills:

  • Build command: npm run build
  • Start command: npm start
  • Port: 3000

No edits needed. The cloud's generated Dockerfile clones the repo, runs npm install, runs the build, and starts the server.

Click Deploy and watch the logs

The first deploy takes 30–90 seconds (Depot builds a fresh image, Fly cold-starts the machine, the cloud probes /mcp for an MCP initialize response). Once you see Server status: running in the build log, the URL is live.

Smoke-test the URL

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"}' \ https://<your-slug>.run.mcp-use.com/mcp

You should see both tools, with greet_widget._meta.ui.resourceUri pointing at ui://my-server/greet.html.

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

Live /mcp: keen-forge-rsxua.run.mcp-use.com/mcpserverInfo.name is mcp-detect-mcp-typescript (Manufact server status: running).

Tip

Auto-deploy on push

Once the server exists, every push to main triggers a new deployment. Add watchPaths: ["src/**"] in the server settings to limit auto-deploys to source changes only.

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 streamable-HTTP MCP server on Express
  • An echo tool that returns text
  • A greet_widget tool whose _meta.ui.resourceUri points at a text/html;profile=mcp-app resource the host renders in an iframe

Project setup

mkdir mcp-detect-mcp-typescript && cd mcp-detect-mcp-typescript npm init -y npm pkg set type=module npm pkg set scripts.build="tsc" npm pkg set scripts.start="node dist/index.js" npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps express zod npm install -D typescript @types/node @types/express

tsconfig.json:

{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "rootDir": "src", "outDir": "dist", "strict": true, "skipLibCheck": true, "esModuleInterop": true }, "include": ["src/**/*.ts"] }

The server

src/index.ts:

import express from "express"; import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { z } from "zod"; const VIEW_URI = "ui://my-server/greet.html"; const VIEW_HTML = `<!doctype html> <html><head><meta charset="utf-8"><title>Greet</title></head> <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 = (result) => { const text = (result?.content ?? []).find(c => c.type === "text")?.text; if (text) document.getElementById("greeting").textContent = text; }; app.connect(); </script> </body></html>`; function getServer() { const server = new McpServer( { name: "my-server", version: "0.1.0" }, { capabilities: { tools: {}, resources: {} } }, ); registerAppResource( server, "Greet view", VIEW_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => ({ contents: [{ uri: VIEW_URI, mimeType: RESOURCE_MIME_TYPE, text: VIEW_HTML }], }), ); registerAppTool( server, "echo", { description: "Echo the input back as text.", inputSchema: { text: z.string() }, _meta: {}, }, async ({ text }) => ({ content: [{ type: "text", text }] }), ); registerAppTool( server, "greet_widget", { description: "Greet someone and render an MCP App view.", inputSchema: { name: z.string() }, _meta: { ui: { resourceUri: VIEW_URI } }, }, async ({ name }) => ({ content: [{ type: "text", text: `Hello, ${name}!` }], structuredContent: { name }, }), ); return server; } const app = express(); app.use(express.json()); app.post("/mcp", async (req, res) => { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); const server = getServer(); res.on("close", () => { transport.close(); server.close(); }); await server.connect(transport); await transport.handleRequest(req, res, req.body); }); const port = Number(process.env.PORT ?? "3000"); app.listen(port, "0.0.0.0", () => console.log(`listening on :${port}/mcp`));

registerAppTool does two things server.registerTool doesn't: it accepts the _meta.ui.resourceUri shape and also writes the legacy _meta["ui/resourceUri"] key, so older hosts still resolve the view. registerAppResource defaults the MIME type to text/html;profile=mcp-app when you pass RESOURCE_MIME_TYPE.

What the host sees

tools/list returns:

{ "name": "greet_widget", "_meta": { "ui": { "resourceUri": "ui://my-server/greet.html" }, "ui/resourceUri": "ui://my-server/greet.html" } }

resources/list returns:

{ "uri": "ui://my-server/greet.html", "mimeType": "text/html;profile=mcp-app" }

The host fetches the resource, sees the MIME profile, renders it in a sandboxed iframe, and lets the inline App instance receive every tools/call result via the bridge.

Run it

npm run build npm start

Hit it with the standard MCP probe:

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:3000/mcp

You should see both tools and the greet_widget _meta.ui.resourceUri.

When to reach for it

You want maximum protocol fidelity, no framework opinions, and you don't mind writing your widget HTML by hand. For a single tool with a single view, the boilerplate is fine. Beyond that you'll start factoring out helpers: at which point you've built half of mcp-use and might as well use it.

The full comparison across all seven frameworks is in Deploying Seven MCP Frameworks.

Share