Skip to main content
All posts

How to Deploy an MCP App to Production (August 2026)

Abe Wheeler
MCP AppsMCP App FrameworkDeploymentChatGPT AppsChatGPT App FrameworkClaude AppsClaude ConnectorsTutorial
Deploy your sunpeak MCP App to production.

Deploy your sunpeak MCP App to production.

TL;DR: Run pnpm build, deploy the output behind a stable HTTPS /mcp endpoint, and use pnpm start for a long-lived Node.js server or sunpeak’s stateless handler for serverless and edge runtimes. Add MCP OAuth 2.1 for private data, monitor /health and tool errors, keep tool contracts backward compatible, then test the production endpoint in an inspector and a real target host.

Building an MCP App locally with pnpm dev is fast. Production adds network and compatibility concerns because ChatGPT, Claude, and other hosts connect to your server remotely, cache tool and UI metadata, and may retry requests. A good deployment needs a stable endpoint, predictable auth errors, safe scaling, observable tool calls, and a way to roll back.

The same MCP App server can support several hosts, but host setup and OAuth details differ. This guide separates the shared MCP deployment contract from the ChatGPT plugin and Claude Connector steps.

Step 1: Build for Production

Run pnpm build from your project root:

pnpm build

This does three things:

  1. Compiles each resource in src/resources/ into a self-contained HTML file at dist/{name}/{name}.html with a metadata sidecar at dist/{name}/{name}.json. The HTML has all JavaScript and CSS inlined. No external script tags, no CDN dependencies. This is what AI hosts load into iframes when your tool is called. The JSON contains the resource URI (with a cache-bust timestamp), title, description, and any _meta config (CSP, permissions).

  2. Compiles tool handlers from src/tools/ into Node.js ESM modules at dist/tools/{name}.js.

  3. Compiles src/server.ts (if it exists) into dist/server.js. This is where your auth() function and server config live.

If the build fails, fix the errors before proceeding. A failed build means either a TypeScript error in your resource components or a misconfigured tool file.

Check that dist/ was created with your resources, tools, and server entry:

dist/
├── contact/
│   ├── contact.html      ← self-contained resource bundle
│   └── contact.json      ← resource metadata (URI, title, _meta)
├── tools/
│   └── show-contact.js   ← compiled tool handler
└── server.js             ← compiled auth + server config

Step 2: Configure Authentication

An authless server is reasonable when every tool uses public data and performs no user-specific action. If a tool reads private data, changes state, or acts for a user, use the MCP OAuth 2.1 flow.

The connection starts before a host has a token. When an unauthenticated request reaches a protected MCP endpoint, return 401 Unauthorized with a WWW-Authenticate header that points to your Protected Resource Metadata:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://my-app.example.com/.well-known/oauth-protected-resource"

That metadata tells the host which authorization server and scopes protect the MCP resource. The authorization server then handles an authorization-code flow with PKCE. Validate the token issuer, audience, expiry, scopes, and user permissions on every protected request. Do not use an MCP session ID as proof of identity, and do not pass a host token through to another API unless that API issued the token for itself.

In a sunpeak project, create src/server.ts and export an auth() function that validates the incoming bearer token. Return an AuthInfo object when it is valid or null to reject the request:

import type { IncomingMessage } from 'node:http';
import type { AuthInfo } from 'sunpeak/mcp';

export async function auth(req: IncomingMessage): Promise<AuthInfo | null> {
  const token = req.headers.authorization?.replace('Bearer ', '');

  if (!token) {
    return null;
  }

  const user = await verifyToken(token);
  if (!user) return null;

  return {
    token,
    clientId: user.id,
    scopes: user.scopes,
  };
}

export const server = { name: 'My App', version: '1.0.0' };

The AuthInfo you return is passed to every tool handler as extra.authInfo:

export default async function (args: Args, extra: ToolHandlerExtra) {
  const userId = extra.authInfo?.clientId;
  const data = await db.getDataForUser(userId);
  return { structuredContent: data };
}

If the whole server is public, omit the auth() export. If you mix public and protected tools, return anonymous auth context for public calls and make each protected tool check extra.authInfo and required scopes:

export async function auth(req: IncomingMessage): Promise<AuthInfo | null> {
  return { token: '', clientId: 'anonymous', scopes: [] };
}

Claude’s current remote connector flow does not accept a pasted bearer token or a credential in the connector URL. ChatGPT’s public plugin flow also expects a reviewable production auth configuration. OAuth discovery is the portable production choice.

Step 3: Set Environment Variables

Your tool handlers read configuration from process.env. Set these on your server before starting the process. Never commit secrets to your repository.

For a local production test:

DATABASE_URL=postgres://... API_KEY=sk-... pnpm start

In production, use the secret manager supplied by your container platform, cloud service, or process manager. Keep separate credentials for development, staging, and production, rotate them without rebuilding the UI bundles, and never log authorization headers or sensitive tool results.

Read them in tool handlers:

export default async function (args: Args, extra: ToolHandlerExtra) {
  const db = new Client(process.env.DATABASE_URL);
  // ...
}

Step 4: Start the Production Server

pnpm start

The built-in production server listens on 0.0.0.0:8000 by default. It serves /mcp, an unauthenticated /health endpoint, and a small server information page. Pass flags or environment variables to change its behavior:

pnpm start -- --port 3000
pnpm start -- --json-logs
pnpm start -- --stateless
pnpm start -- --sse

Use --stateless for serverless or horizontally scaled deployments that cannot route a session to the same process. JSON responses are the default. Enable --sse only when your app needs streamed progress or server-initiated notifications.

Your local endpoint is http://localhost:8000/mcp. The production URL must use HTTPS. The server handles the MCP protocol over Streamable HTTP from this single endpoint:

  • Tool and resource discovery, read by the host when it connects or refreshes metadata
  • Tool calls, validated against your Zod schemas, routed to your handler, and returned as structured content
  • Resource HTML, the pre-built bundles from dist/, served to host iframes when a tool returns structured content

Verify the server is running:

curl http://localhost:8000/health

The response includes status and process uptime:

{ "status": "ok", "uptime": 42 }

Step 5: Reverse Proxy and TLS

AI hosts require your MCP endpoint to use HTTPS. In production, terminate TLS at a reverse proxy or managed load balancer and forward traffic to pnpm start.

sunpeak uses JSON responses by default, which works well on containers and serverless platforms. If you enable SSE, configure the proxy to keep the connection open and disable response buffering. In nginx:

location /mcp {
  proxy_pass http://localhost:8000;
  proxy_buffering off;
  proxy_cache off;
  proxy_set_header Connection '';
  proxy_http_version 1.1;
  chunked_transfer_encoding on;
}

Set request-size limits and timeouts at the proxy, but allow enough time for the longest supported tool call. Add rate limits by authenticated user and tool, not only by source IP, because host traffic may come from shared egress ranges.

For the 2026-07-28 transport, validate every Origin header and return 403 Forbidden for an untrusted origin. Also check that MCP-Protocol-Version, Mcp-Method, and Mcp-Name headers match the request body. Do this in a compatible MCP SDK or at an application-aware gateway, because a generic proxy cannot validate JSON-RPC fields safely.

Choose Stateful or Stateless Hosting

sunpeak’s default server keeps MCP sessions in process memory. This is simple on one long-lived server. With several replicas, route requests for the same mcp-session-id to the same replica or run in stateless mode.

The MCP 2026-07-28 protocol revision removed protocol-level sessions and made Streamable HTTP messages request scoped. Hosts and SDKs migrate at different times, and the specification includes backward compatibility for the earlier session-based transport. sunpeak 0.20.x supports those deployed hosts and also offers stateless mode, so choose the mode that matches the protocol revisions your target hosts negotiate.

Use stateless mode for functions, edge runtimes, and load balancers without session affinity:

pnpm start -- --stateless

You can also mount the web-standard handler in an edge router:

import { createHandler } from 'sunpeak/mcp';

const handler = createHandler({
  tools,
  resources,
  stateless: true,
  serverUrl: 'https://my-app.example.com/mcp',
});

export default { fetch: handler };

Stateless mode creates a fresh MCP server for each request and supports POST /mcp. Use stateful mode when you need SSE notifications or other connection-scoped behavior.

Step 6: Test Before You Deploy

Run your test suite before every deploy. This catches rendering issues, tool handler bugs, and cross-host differences without a paid ChatGPT or Claude account:

pnpm test

Then build and inspect the exact production bundles:

pnpm build
pnpm start

The sunpeak inspector replicates ChatGPT and Claude runtimes and can load resources from dist/, so Playwright tests can cover the files you will deploy. Test both host modes, light and dark themes, supported display modes, empty and error states, OAuth failures, and long data. Deterministic simulation files make those states repeatable in CI.

For automated deploys, add testing to your CI/CD pipeline:

- run: pnpm install
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm exec sunpeak test
- run: pnpm exec sunpeak build

The pre-submission testing checklist covers tool annotations, test credentials, privacy behavior, display mode rendering, and CSP configuration.

Step 7: Connect to ChatGPT

OpenAI now places development MCP connections under Plugins. Developer mode is required to add one. Open Settings > Security and login, enable Developer mode, then open Plugins and select the plus button. Add your full production endpoint:

https://my-app.example.com/mcp

ChatGPT fetches your tool manifest from /mcp and registers your tools. The next time a user asks something that triggers one of your tools, ChatGPT calls your tool handler and renders the resource HTML in an iframe inside the conversation.

If your server uses authentication, configure it in the development app form. ChatGPT supports the MCP OAuth 2.1 flow.

For public distribution, follow OpenAI’s plugin submission guide. Create a submission in the OpenAI Platform, choose With MCP, enter the production MCP server URL, complete domain verification, and select Scan Tools. OpenAI stores a reviewed snapshot of tool and UI metadata, so deploy metadata changes and scan again before publishing an update. Do not submit the development connection ID.

Step 8: Connect to Claude

In Claude, go to Settings > Connectors > Add custom connector. Enter the same server URL with the /mcp path:

https://my-app.example.com/mcp

Claude discovers your tools and resources automatically. For private data, use the supported remote connector OAuth flow. Claude does not accept user-pasted bearer tokens or tokens in URL query parameters. Return a proper 401 and WWW-Authenticate header so Claude can find your Protected Resource Metadata.

Claude connects to your MCP server from Anthropic’s cloud infrastructure, even when the user is in Claude Desktop. Your server needs to be reachable over the public internet. If you use firewall rules, allow the documented Anthropic network ranges to reach /mcp.

For Claude-specific deployment details (Cloudflare Workers, Vercel, callback URLs), see the deploying Claude Connectors guide.

Keeping the Server Running

Use a process manager to keep pnpm start running after deploys and restarts.

pm2

pm2 start "pnpm start -- --json-logs" --name my-mcp-app
pm2 save
pm2 startup

systemd

[Unit]
Description=My MCP App
After=network.target

[Service]
WorkingDirectory=/opt/my-mcp-app
ExecStart=/usr/local/bin/pnpm start -- --json-logs
Restart=always
Environment=PORT=8000
Environment=DATABASE_URL=postgres://...

[Install]
WantedBy=multi-user.target

Docker

FROM node:22-alpine
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm exec sunpeak build
EXPOSE 8000
CMD ["pnpm", "exec", "sunpeak", "start", "--json-logs"]

Deploying on Fly.io

Fly.io is a good fit for MCP Apps: global regions, automatic TLS, and straightforward Node.js deployments.

Create a fly.toml:

app = "my-mcp-app"
primary_region = "ord"

[build]

[http_service]
internal_port = 8000
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true

[[vm]]
size = "shared-cpu-1x"
memory = "256mb"

Add a Dockerfile using the example above, then deploy:

fly launch
fly secrets set DATABASE_URL=postgres://...
fly deploy

Your MCP endpoint will be at https://my-mcp-app.fly.dev/mcp. Point ChatGPT, Claude, or any MCP host to that URL.

Watch out for cold starts on serverless and auto-stop platforms. If your server takes too long to wake up, the host may time out the connection. On Fly.io, set min_machines_running = 1 in fly.toml to keep at least one instance warm. On other platforms, check whether you can configure minimum instances or keep-alive pings.

CI/CD: Build and Test Before Deploy

Add build and test steps to your CI/CD pipeline before deploying. Here’s a GitHub Actions workflow:

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: pnpm/action-setup@v4
        with:
          version: 10
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps chromium
      - run: pnpm exec sunpeak test
      - run: pnpm exec sunpeak build

      # Deploy dist/ and server files to your host
      - name: Deploy to Fly.io
        run: fly deploy
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

pnpm test runs unit and E2E tests in the inspector against the ChatGPT and Claude host modes before the build. See the CI/CD guide for the full workflow and the complete testing guide for how to write the tests.

Treat the Endpoint as a Versioned API

Hosts cache tool metadata and UI resources, so deployment compatibility matters even when your own frontend and server ship together.

  • Keep published tool names and required input fields stable. Add optional fields or new tools instead of silently changing an existing contract.
  • Keep model-readable content useful when a host does not render MCP Apps UI. UI support is an extension, so the tool still needs a sensible text or structured result.
  • Version a resource URI when its HTML, JavaScript, CSS, or bridge expectations change. sunpeak build generates cache-busting resource URIs for production bundles.
  • Deploy server code and its matching UI resources as one release. Keep the previous artifact available for rollback.
  • Refresh a ChatGPT developer connection after metadata changes. For a published plugin, scan the deployed server and submit a new reviewed metadata snapshot.

This is also why blue-green or rolling deploys need care. During a rollout, an old server replica may receive a request based on new metadata. Backward-compatible tool schemas and atomic artifacts keep that window from breaking users.

Smoke Testing After Deploy

Start with the health endpoint:

curl --fail --silent --show-error https://my-app.example.com/health

Then use MCP Inspector against the public /mcp URL. Confirm protocol discovery or backward-compatible initialization succeeds, review the advertised tools and annotations, call each tool with valid and invalid input, and check that protected calls return the expected OAuth challenge.

Finish with one narrow live-host check:

  1. Refresh the MCP connection so the host sees the deployed metadata.
  2. Trigger a read tool and confirm its model-readable result.
  3. Render the UI and check the browser console and resource requests.
  4. Trigger one error or empty state.
  5. For write tools, verify authorization and confirmation before the action runs.

Monitor initialization failures, tool latency, OAuth errors, resource fetch failures, and process restarts after release. pnpm start -- --json-logs emits structured logs for aggregation, while /health works with load balancer probes and uptime checks. Keep tokens, authorization headers, and private tool output out of logs.

For ongoing monitoring, the error handling guide covers how to handle loading, error, and cancelled states in your resources so users see useful feedback when something goes wrong in production.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

What does sunpeak build do?

"pnpm build" (which runs sunpeak build) compiles each resource in src/resources/ into a self-contained HTML bundle with all JavaScript and CSS inlined, plus a JSON metadata sidecar with the resource URI and config. It compiles tool handlers from src/tools/ into Node.js ESM modules and compiles src/server.ts (if present) into dist/server.js. The output goes into dist/. All steps must succeed before you can run pnpm start.

How do I authenticate users in a deployed MCP App?

Use MCP OAuth 2.1 when the app reads private data or acts for a user. The MCP endpoint should return 401 with a WWW-Authenticate header that points to Protected Resource Metadata, then validate each access token, audience, scope, and user authorization on the server. In sunpeak, an auth() export from src/server.ts can validate the bearer token and expose the resulting AuthInfo to tool handlers.

Do I need a special server to host an MCP App?

No. A remote MCP App can run on a container, VM, managed Node.js service, serverless function, or edge runtime. The production endpoint must be stable, reachable over HTTPS, and support Streamable HTTP. Long-lived servers can keep MCP sessions in memory; serverless and horizontally scaled deployments should use stateless mode unless they provide sticky routing.

How do I connect a deployed MCP App to ChatGPT?

Enable Developer mode under Settings > Security and login, open ChatGPT Plugins, select the plus button, and enter the full production HTTPS URL including /mcp. For public distribution, create a plugin submission in the OpenAI Platform, choose With MCP, enter the production MCP server URL, scan the tools, and submit the reviewed metadata snapshot.

How do I connect a deployed MCP App to Claude?

In Claude, go to Settings > Connectors > Add custom connector and enter the public HTTPS URL including /mcp. Claude connects from Anthropic cloud infrastructure, so the endpoint must be reachable from Anthropic IP ranges. Use supported MCP OAuth discovery for private data because Claude does not support user-pasted bearer tokens or credentials in the connector URL.

Should I test my MCP App before deploying?

Yes. Run unit and Playwright E2E tests against deterministic tool results, then build and test the production bundles in the sunpeak inspector. After deployment, inspect the live /mcp endpoint, exercise valid and invalid tool calls, verify OAuth errors, and render the UI in at least one real target host before release.

What transport protocol do MCP Apps use?

Remote MCP Apps use MCP Streamable HTTP, usually at one /mcp endpoint. sunpeak returns JSON responses by default and can enable SSE when tools need streamed progress or server notifications. If SSE is enabled, configure the reverse proxy to keep the stream open and disable response buffering.

How should I monitor an MCP App in production?

Monitor the unauthenticated /health endpoint, protocol discovery or backward-compatible initialization failures, tool call latency and errors, OAuth 401 responses, and UI resource fetch failures. Use structured logs without access tokens or sensitive tool results, alert on error rates and latency, and keep a rollback path for both server code and UI resource versions.