> ## Documentation Index
> Fetch the complete documentation index at: https://sunpeak.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Deployment

> Deploy your MCP App to production and submit it to ChatGPT as an MCP-backed plugin.

## Build for Production

Build your app for production using the sunpeak CLI:

<Tabs>
  <Tab title="pnpm">
    ```bash theme={null}
    pnpm build
    ```
  </Tab>

  <Tab title="npm">
    ```bash theme={null}
    npm run build
    ```
  </Tab>

  <Tab title="yarn">
    ```bash theme={null}
    yarn build
    ```
  </Tab>
</Tabs>

This generates a `dist/` directory with built resources and compiled tools:

```
dist/
├── albums/
│   ├── albums.html    # Self-contained HTML (JS + CSS inlined)
│   └── albums.json    # Resource metadata (_meta, name, uri)
├── tools/
│   ├── show-albums.js # Compiled tool handler + schema
│   └── ...
├── server.js          # Compiled server entry (if src/server.ts exists)
└── ...
```

Each `.html` file is a complete, self-contained app bundle — no external scripts or stylesheets. Tool files are compiled to standard ESM JavaScript with `node_modules` resolved at runtime.

## Start the Production Server

<Tabs>
  <Tab title="pnpm">
    ```bash theme={null}
    pnpm start
    ```
  </Tab>

  <Tab title="npm">
    ```bash theme={null}
    npm run start
    ```
  </Tab>

  <Tab title="yarn">
    ```bash theme={null}
    yarn start
    ```
  </Tab>
</Tabs>

This loads your compiled tool handlers, Zod schemas, and optional auth from `dist/`, registers them with the [MCP protocol](/mcp-apps/mcp/overview), and starts a [Streamable HTTP](/mcp-apps/mcp/overview#transports) server on port 8000.

<Tabs>
  <Tab title="pnpm">
    ```bash theme={null}
    pnpm start -- --port 3000          # Custom port
    pnpm start -- --host 127.0.0.1     # Bind to localhost only
    pnpm start -- --json-logs           # Structured JSON logging
    pnpm start -- --sse                 # Use SSE streaming instead of JSON responses
    pnpm start -- --stateless           # Stateless mode (no session tracking)
    PORT=3000 HOST=127.0.0.1 pnpm start  # Or via environment variables
    ```
  </Tab>

  <Tab title="npm">
    ```bash theme={null}
    npm run start -- --port 3000          # Custom port
    npm run start -- --host 127.0.0.1     # Bind to localhost only
    npm run start -- --json-logs           # Structured JSON logging
    npm run start -- --sse                 # Use SSE streaming instead of JSON responses
    npm run start -- --stateless           # Stateless mode (no session tracking)
    PORT=3000 HOST=127.0.0.1 npm run start  # Or via environment variables
    ```
  </Tab>

  <Tab title="yarn">
    ```bash theme={null}
    yarn start --port 3000          # Custom port
    yarn start --host 127.0.0.1     # Bind to localhost only
    yarn start --json-logs           # Structured JSON logging
    yarn start --sse                 # Use SSE streaming instead of JSON responses
    yarn start --stateless           # Stateless mode (no session tracking)
    PORT=3000 HOST=127.0.0.1 yarn start  # Or via environment variables
    ```
  </Tab>
</Tabs>

The production server:

* Registers each tool from `dist/tools/` with its real handler and Zod schema (input validation)
* Serves pre-built resource HTML from `dist/{name}/{name}.html`
* Calls your `auth()` function from `src/server.ts` on every request (if present)
* Uses [MCP Streamable HTTP](/mcp-apps/mcp/overview#transports) transport on a single `/mcp` endpoint
* Provides a `/health` endpoint for load balancer probes and uptime monitoring
* Handles graceful shutdown on SIGTERM/SIGINT

## Testing Locally

Use `sunpeak build` and `sunpeak start` to test production behavior locally:

<Tabs>
  <Tab title="pnpm">
    ```bash theme={null}
    pnpm build && pnpm start
    ```
  </Tab>

  <Tab title="npm">
    ```bash theme={null}
    npm run build && npm run start
    ```
  </Tab>

  <Tab title="yarn">
    ```bash theme={null}
    yarn build && yarn start
    ```
  </Tab>
</Tabs>

Then expose the MCP server with a tunnel (e.g., `ngrok http 8000`) and connect from ChatGPT.

## Custom Server Setup

For full control over the HTTP server (custom middleware, CORS, health checks, etc.), use `createMcpHandler` to mount the MCP protocol handler on your own server.

Unlike `sunpeak start` which auto-discovers from `dist/`, these handlers accept pre-loaded config objects — you load tools, resources, and auth yourself. See [Production Server API](/app-framework/tools/production-server) for config types.

```typescript theme={null}
import express from 'express';
import cors from 'cors';
import { createMcpHandler } from 'sunpeak/mcp';

const app = express();

// Your middleware
app.use(cors({ origin: 'https://chatgpt.com' }));
app.get('/health', (req, res) => res.json({ ok: true }));

// tools: ProductionTool[], resources: ProductionResource[], auth: AuthFunction
const mcpHandler = createMcpHandler({ tools, resources, auth });
app.use((req, res, next) => {
  mcpHandler(req, res).then(() => {
    if (!res.headersSent) next();
  });
});

app.listen(3000);
```

The handler manages Streamable HTTP sessions, auth propagation via `req.auth`, and CORS preflights for the `/mcp` path. For unmatched routes it does nothing, so your server handles them.

## Serverless / Edge Deployment

For serverless and edge runtimes (Cloudflare Workers, Deno, Bun, Vercel Edge), use `createHandler` with `stateless: true`:

```typescript theme={null}
// Cloudflare Worker
import { createHandler } from 'sunpeak/mcp';

const handler = createHandler({
  tools,
  resources,
  stateless: true,
});
export default { fetch: handler };
```

```typescript theme={null}
// Hono (any runtime)
import { Hono } from 'hono';
import { createHandler } from 'sunpeak/mcp';

const app = new Hono();
const handler = createHandler({
  tools,
  resources,
  stateless: true,
});
app.all('/mcp', (c) => handler(c.req.raw));
export default app;
```

Unlike `createMcpHandler`, the web handler does **not** do path matching — it handles every request it receives. Mount it behind your own router.

### Stateless vs Stateful Mode

By default, both `createHandler` and `createMcpHandler` use **stateful mode**: they track MCP sessions in an in-memory map and route requests by the `mcp-session-id` header. This works for single-instance servers and multi-instance deployments with sticky sessions (see [Horizontal Scaling](#horizontal-scaling)).

**Stateless mode** (`stateless: true`) creates a fresh MCP server and transport for every request. No session map, no session ID validation. Use this when:

* **Serverless** (Lambda, Workers, Vercel Edge) where each request may hit a different instance with no shared memory
* **Horizontally scaled** deployments where configuring sticky sessions isn't practical

In stateless mode, only `POST` is supported (no SSE streaming via `GET`). JSON responses are used by default, which is the right choice for serverless anyway.

### Domain Resolution in Stateless Mode

sunpeak auto-computes resource domains from `serverUrl` based on which host is connecting (ChatGPT and Claude use different sandbox domain schemes). This requires knowing the host's identity.

ChatGPT sends `user-agent: openai-mcp/1.0.0` and Claude sends `user-agent: Claude-User` on every MCP HTTP request. sunpeak detects these automatically, so domain resolution works in stateless mode without any extra configuration:

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

For custom MCP clients that don't send identifiable headers, `detectClientFromHeaders` is exported from `sunpeak/mcp` so you can build your own detection logic in a custom handler.

Both `createMcpHandler` and `createHandler` use JSON responses by default (`enableJsonResponse: true`), which works reliably across all environments including serverless. If you need SSE streaming (e.g., for progress updates on long-running tool calls), set `enableJsonResponse: false` in your config. See [Production Server API](/app-framework/tools/production-server) for details.

<Info>
  For fully custom MCP server setups, `sunpeak/mcp` also re-exports `registerAppTool`, `registerAppResource`, and `RESOURCE_MIME_TYPE` from the [MCP Apps SDK](https://github.com/anthropics/ext-apps). See [Server Helpers](/app-framework/run-mcp-server#server-helpers) for the full list.
</Info>

## Production Operations

### Health Checks

The built-in server exposes a `GET /health` endpoint that returns:

```json theme={null}
{ "status": "ok", "uptime": 3600 }
```

Use this for load balancer probes, Kubernetes liveness/readiness checks, and uptime monitoring. The endpoint is always unauthenticated.

### Structured Logging

Pass `--json-logs` to output structured JSON lines instead of human-readable logs:

<Tabs>
  <Tab title="pnpm">
    ```bash theme={null}
    pnpm start -- --json-logs
    ```
  </Tab>

  <Tab title="npm">
    ```bash theme={null}
    npm run start -- --json-logs
    ```
  </Tab>

  <Tab title="yarn">
    ```bash theme={null}
    yarn start --json-logs
    ```
  </Tab>
</Tabs>

Each line is a JSON object with `ts`, `level`, and `msg` fields, plus contextual data:

```json theme={null}
{"ts":"2025-01-15T10:30:00.000Z","level":"info","msg":"Session started: a1b2c3d4...","sessionId":"a1b2c3d4-...","active":1}
{"ts":"2025-01-15T10:30:01.000Z","level":"info","msg":"CallTool: show-albums{artist}"}
```

This format is compatible with log aggregation tools like Datadog, CloudWatch, Loki, and Elasticsearch. Errors go to stderr, everything else to stdout.

### Binding Interface

By default, the server binds to `0.0.0.0` (all interfaces). To restrict it to localhost only:

<Tabs>
  <Tab title="pnpm">
    ```bash theme={null}
    pnpm start -- --host 127.0.0.1
    HOST=127.0.0.1 pnpm start
    ```
  </Tab>

  <Tab title="npm">
    ```bash theme={null}
    npm run start -- --host 127.0.0.1
    HOST=127.0.0.1 npm run start
    ```
  </Tab>

  <Tab title="yarn">
    ```bash theme={null}
    yarn start --host 127.0.0.1
    HOST=127.0.0.1 yarn start
    ```
  </Tab>
</Tabs>

### Process Management

`sunpeak start` handles SIGTERM and SIGINT for graceful shutdown (5-second drain timeout). It works with any process manager:

```bash theme={null}
# Docker
CMD ["sunpeak", "start", "--json-logs"]

# PM2
pm2 start "sunpeak start --json-logs" --name sunpeak-app

# systemd
ExecStart=/usr/bin/sunpeak start --json-logs
```

### Reverse Proxy

For TLS, rate limiting, and request size limits, put `sunpeak start` behind a reverse proxy. The server's CORS headers allow all origins by default, which is correct for MCP servers called by AI hosts from various origins.

```nginx theme={null}
# nginx
location /mcp {
    proxy_pass http://127.0.0.1:8000;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    proxy_buffering off;       # Required if enableJsonResponse is false (SSE mode)
}

location /health {
    proxy_pass http://127.0.0.1:8000;
}
```

### Horizontal Scaling

MCP uses session-based communication by default. Each `initialize` handshake creates a session, and all subsequent requests include an `mcp-session-id` header that must route back to the same server instance.

For multi-instance deployments behind a load balancer, you have two options:

**Sticky sessions (recommended for long-lived servers)** — Configure your load balancer to route requests with the same `mcp-session-id` header to the same backend instance:

* **AWS ALB**: Enable target group stickiness
* **nginx**: Use `ip_hash` or `sticky cookie`
* **Cloudflare**: Enable session affinity
* **Kubernetes**: Set session affinity annotations on your Ingress

**Stateless mode (recommended for serverless)** — Disable session tracking entirely. Each request creates a fresh server instance, so any backend can handle any request:

<Tabs>
  <Tab title="pnpm">
    ```bash theme={null}
    pnpm start -- --stateless
    ```
  </Tab>

  <Tab title="npm">
    ```bash theme={null}
    npm run start -- --stateless
    ```
  </Tab>

  <Tab title="yarn">
    ```bash theme={null}
    yarn start --stateless
    ```
  </Tab>
</Tabs>

Or with a custom server:

```typescript theme={null}
const handler = createMcpHandler({
  tools,
  resources,
  stateless: true,
});
```

See [Serverless / Edge Deployment](#serverless--edge-deployment) for details on domain resolution in stateless mode.

### What Not to Worry About

These are handled at the infrastructure layer, not by `sunpeak start`:

* **TLS/HTTPS** — Use a reverse proxy (nginx, Caddy, Cloudflare)
* **Rate limiting** — Configure at the proxy or CDN level
* **Request body limits** — Handled by the proxy or platform

For full control over the HTTP server (custom middleware, metrics, etc.), use [`createMcpHandler`](/app-framework/tools/production-server#createmcphandler) to mount the MCP handler on your own Express, Fastify, or http server.

## Publish to ChatGPT

ChatGPT apps are now submitted and published as plugins. The app itself remains an MCP-backed app, so your sunpeak server, tools, and UI resources do not change. The plugin is the package used for review and distribution.

Use the [OpenAI Platform plugin submission portal](https://platform.openai.com/plugins) when your production server is ready. OpenAI's [plugin submission guide](https://learn.chatgpt.com/docs/submit-plugins) is the source of truth for the review flow.

### Prerequisites

* Your MCP server is deployed at a public production HTTPS URL.
* Your resources are built with `sunpeak build`.
* The submitter has **Apps Management: Write** access for the OpenAI Platform organization. Organization owners already have this access.
* The publisher has completed individual or business identity verification in the same organization and project used for submission.
* You can serve the domain verification token at `/.well-known/openai-apps-challenge` on the MCP hostname or an allowed parent hostname.
* If sign-in is required, reviewer credentials work without MFA, SMS, email confirmation, or private-network access.

<Note>
  For local development, first enable Developer mode from the bottom-left user menu under `Settings > Security and login > Developer mode`. Then create the app from the ChatGPT homepage under `Plugins > +`. Public submission is a separate flow in the OpenAI Platform. If the app already exists in ChatGPT, submit its MCP server from scratch. The portal does not accept an existing ChatGPT app ID.
</Note>

### Prepare the submission

Collect these materials before opening the portal:

* Plugin name, short and long descriptions, logo, category, website, support URL, privacy policy URL, and terms URL.
* Production MCP server URL, authentication details, and reviewer credentials when needed.
* A content security policy listing the exact domains the app fetches from.
* Clear tool names, descriptions, input schemas, output shapes, and accurate [`readOnlyHint`, `openWorldHint`, and `destructiveHint` annotations](/mcp-apps/server/tool-annotations) for every tool.
* Starter prompts that show realistic workflows.
* Exactly five positive test cases and three negative test cases, including expected behavior and any fixture data.
* Country or region availability and release notes.

Review tool responses before submission. Remove auth secrets, debug payloads, unneeded personal data, internal identifiers, and user-related fields that your privacy policy does not disclose.

### Submit the MCP-backed plugin

1. Open the [plugin submission portal](https://platform.openai.com/plugins) and select **Create plugin**.
2. Choose **With MCP** for an app-only plugin or an app-plus-skills plugin.
3. Complete the public listing and publisher identity fields.
4. Enter the production MCP server URL, configure authentication, and provide demo credentials when required.
5. Add the app's content security policy.
6. Complete domain verification if the portal asks for it. The challenge endpoint must return only the exact token as plain text.
7. Select **Scan Tools**, fix any server or metadata errors, deploy the fixes, and scan again.
8. Add starter prompts, five positive tests, three negative tests, availability, release notes, and policy attestations.
9. Select **Submit for Review**.

Submission starts the review. After OpenAI approves the plugin, publish it from the portal when you are ready. Published apps appear with other plugins in the shared Plugins Directory in ChatGPT and Codex. There is no separate Apps Directory.

### Production Checklist

* Set an exact [CSP (Content Security Policy)](/mcp-apps/server/resource-meta#csp--content-security-policy) for your resources.
* Configure CORS if serving from a different domain.
* Set up HTTPS for secure communication.
* Verify that tool annotations match real behavior.
* Confirm that the domain challenge and reviewer credentials work from the public internet.
* Check that listing URLs and publisher identity match.

See OpenAI's [build plugins guide](https://learn.chatgpt.com/docs/build-plugins) if you also want to package skills with the app or test the app through a local plugin manifest.
