Cookie preferences

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

Manufact

How to Deploy Skybridge to Production

Enrico Toniato
Enrico ToniatoCTO
How to Deploy Skybridge to Production

This guide is the Skybridge 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 detects skybridge by the skybridge dependency. The deploy uses the repo's own Dockerfile (which pins node:24-slim) instead of the cloud's generated one: leave the build/start commands empty in the form so the cloud knows to use your Dockerfile.

Push the repo to GitHub

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

Make sure Dockerfile, vite.config.ts, tsconfig.json, and the full src/ (including views/) are committed.

Open the new-server flow

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

Clear build and start commands

The skybridge preset auto-fills npm run build and npm start, but leave both fields empty. With nothing in the override, Manufact uses the repo's own Dockerfile (which already does npm run build && skybridge start).

Why? Skybridge's dist/server.js does import("./vite-manifest.js") at runtime: that file is produced by skybridge build and lives next to the server entry. The repo's Dockerfile keeps build artifacts where the start command expects them. The cloud's generated Dockerfile assumes a flatter layout and would break the manifest import.

Set the port to 3000

Skybridge's server.run() reads process.env.__PORT (with double underscore) but defaults to 3000. The cloud sets PORT=3000 and routes Fly's internal_port to 3000: which matches Skybridge's default fallback even though the env var name doesn't match. So port: 3000 in the form, and you're good.

Click Deploy

First build is 90–180 seconds: npm install pulls down React 19, Vite 7, the Skybridge devtools, plus type packages. Subsequent rebuilds are faster (Depot caches layers).

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 greet_widget with both ui/resourceUri (MCP Apps) and openai/outputTemplate (Apps SDK) keys.

Example repo: manufacts/mcp-detect-skybridge

Live /mcp: calm-pulse-tt2fj.run.mcp-use.com/mcpserverInfo.name is mcp-detect-skybridge (Manufact server status: running).

Note

Same widget, two runtimes

Skybridge emits two resources per view: one at ui://views/ext-apps/<name>.html (MCP Apps) and one at ui://views/apps-sdk/<name>.html (OpenAI Apps SDK). When you submit to ChatGPT, OpenAI reads the apps-sdk one. When Claude or the MCP Inspector connects, they read ext-apps. You write the React component once.

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 skybridge project scaffolded from npm create skybridge
  • An echo tool (text-only) and a greet_widget tool (returns structuredContent for the view)
  • A React view at src/views/greet.tsx rendered by the host

Project setup

npm create skybridge@latest my-server cd my-server npm install

This produces:

my-server/ ├── src/ │ ├── server.ts # registers tools │ ├── views/ │ │ └── magic-8-ball.tsx │ ├── components/ │ │ └── ball.tsx │ ├── helpers.ts # type-safe useToolInfo hook │ └── index.css ├── vite.config.ts # skybridge() + react() plugins ├── tsconfig.json # extends "skybridge/tsconfig" ├── Dockerfile # node:24-slim └── package.json

Replace the demo magic-8-ball with echo + greet_widget.

The server

src/server.ts:

import { McpServer } from "skybridge/server"; import { z } from "zod"; const server = new McpServer( { name: "my-server", version: "0.1.0", }, { capabilities: {} }, ) .registerTool( { name: "echo", description: "Echo the input back as text.", inputSchema: { text: z.string() }, }, async ({ text }) => ({ content: [{ type: "text", text }], isError: false, }), ) .registerTool( { name: "greet_widget", description: "Greet someone and render a Skybridge view.", inputSchema: { name: z.string() }, view: { component: "greet", // matches src/views/greet.tsx description: "Greeting card View", }, }, async ({ name }) => ({ structuredContent: { name }, content: [{ type: "text", text: `Hello, ${name}!` }], isError: false, }), ); if (process.env.NODE_ENV === "production") { const { default: manifest } = await import("./vite-manifest.js"); server.setViteManifest(manifest); } export default await server.run(); export type AppType = typeof server;

The view is identified by name (greet). At build time skybridge build runs vite build which scans src/views/, bundles each into a self-contained HTML resource, and emits a manifest. At runtime server.setViteManifest(manifest) lets Skybridge look up the resource URI for each view.

The view

src/views/greet.tsx:

import "@/index.css"; import { useToolInfo } from "../helpers.js"; export default function Greet() { const { input, output } = useToolInfo<"greet_widget">(); const name = (output as { name?: string } | undefined)?.name ?? input?.name; return ( <div className="container"> <div className="card"> <h1>{name ? `Hello, ${name}!` : "Greeting view loaded."}</h1> <p>Greeting widget served by my-server.</p> </div> </div> ); }

useToolInfo<"greet_widget">() is type-safe: the helpers.ts from the starter introspects AppType (your server's exported type) and gives the view typed access to that tool's input and output shapes. Missing tools, mismatched names, wrong field types: all caught at compile.

src/index.css: write whatever CSS you want, scoped however you want. Vite handles bundling it into the view's HTML resource.

What the host sees

tools/list:

{ "name": "greet_widget", "_meta": { "ui": { "resourceUri": "ui://views/ext-apps/greet.html?v=c6f47c45" }, "ui/resourceUri": "ui://views/ext-apps/greet.html?v=c6f47c45", "openai/outputTemplate": "ui://views/apps-sdk/greet.html?v=c6f47c45" } }

resources/list returns two resources for the same view: one for the MCP Apps profile (Claude/Inspector) and one for the OpenAI Apps SDK profile (ChatGPT). The ?v=… suffix is the Vite content hash, so cached widgets bust on rebuild.

Run it locally

npm run dev # vite dev + tsx watch with HMR

Visit http://localhost:3000 for Skybridge's built-in DevTools: call your tool from a UI panel and the view renders inline.

For a production-shaped run:

npm run build npm start # actually skybridge start, which runs node dist/server.js

When to reach for it

Pick this when your team already lives on Node 24, React 19, and Vite 7 (the runtime image weight is then a non-issue) and you specifically need both the OpenAI Apps SDK metadata and MCP Apps metadata emitted from the same view code with no extra work.

If your team isn't already on that stack, mcp-use gives you the same React-component-as-widget ergonomics with a much lighter dependency tree (no Vite manifest at runtime, no Node 24 lower bound). The full comparison is in Deploying Seven MCP Frameworks.

Share