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

> ## Agent Instructions
> These are the docs for Sevalla, a platform for deploying applications, databases, static sites, object storage and load balancers. The dashboard is at https://app.sevalla.com.
> Bold text in steps is an exact dashboard label.
> To act on a Sevalla account, prefer the Sevalla MCP server (https://mcp.sevalla.com/mcp, see https://docs.sevalla.com/ai-agents/overview.md), then the Sevalla CLI (https://docs.sevalla.com/developer-tools/cli.md), then the REST API (https://api-docs.sevalla.com, OpenAPI spec at https://api.sevalla.com/v3/openapi.json).
> Every page index is at https://docs.sevalla.com/llms.txt.

# Deploy an MCP server with TypeScript

> Deploy a remote MCP server built with the official TypeScript SDK: Streamable HTTP on /mcp, bearer token auth, PostgreSQL tools and stateless instances, built with Ingot or a Dockerfile.

An MCP server built with the official [MCP TypeScript SDK](https://ts.sdk.modelcontextprotocol.io/v2/) runs on Sevalla as an application: its web process serves the Model Context Protocol over Streamable HTTP at `/mcp`, and AI clients such as Claude, Claude Code and Cursor call its tools over HTTPS. This guide builds a server with two read-only tools that query a Sevalla PostgreSQL database over an internal connection, requires a bearer token on every request, and keeps no state between requests, so it runs on as many instances as you need.

To let an AI agent manage your Sevalla services instead, connect it to the [Sevalla MCP server](https://docs.sevalla.com/ai-agents/overview.md).

![MCP clients such as Claude Code, Cursor, the Claude API and the OpenAI API connect over HTTPS to https://\<app>.sevalla.app/mcp with Streamable HTTP, sending a bearer token in the Authorization header. The MCP server runs as the application's web process, and its tools reach a PostgreSQL database over an internal connection.](https://assets.sevalla.com/brand/diagrams/docs/remote-mcp-light.svg)

## At a glance

| | |
| - | - |
| Source | A Git repository |
| Build | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) (Node.js 24, npm), or the [Dockerfile](#deploy-with-a-dockerfile) |
| Start command | `node server.ts`, from `scripts.start` in `package.json` |
| Port | The `PORT` environment variable, `8080` by default |
| Processes | Web process |
| Services | PostgreSQL 18 over an internal connection |
| Disk | None |
| Health check | `GET /health` returns `200` and `ok` (readiness probe) |
| Size | **H1** for the web process |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `MCP_TOKEN` | Yes | Runtime | Yes | Generate with `openssl rand -hex 32`. Clients send it as `Authorization: Bearer <token>` |
| `DB_URL` | Yes | Build and runtime | Yes | Added by the internal connection to `orders-db`, in the form `postgres://<user>:<password>@<host>:<port>/<database>` |
| `PORT` | No | Runtime | No | Set by Sevalla to the web process port |

The internal connection also adds `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME` and `DB_PASSWORD`, which the server doesn't read.

## Before you begin

* A Sevalla company with a payment method, and a GitHub, GitLab or Bitbucket account for the repository. See [Git providers](https://docs.sevalla.com/git/overview.md).
* Node.js 24 or later on your computer. Node.js 24 runs TypeScript files directly, so the project has no build step.

## Create the project

Create a folder, set the package's module type, start script and Node.js version, and install the SDK, Express, the PostgreSQL client and Zod:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
mkdir storefront-mcp && cd storefront-mcp
npm init -y
npm pkg set type=module scripts.start="node server.ts" engines.node="24.x"
npm install @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node express pg zod
npm install --save-dev typescript @types/node@24 @types/express @types/pg
```

| Package | What it does |
| - | - |
| `@modelcontextprotocol/server` | The MCP server SDK, version 2: `McpServer` and `createMcpHandler`, which serves Streamable HTTP |
| `@modelcontextprotocol/express` and `@modelcontextprotocol/node` | The SDK's Express app and the adapter that mounts its handler on Express |
| `pg` | The PostgreSQL client |
| `zod` | The input schemas of the tools, which the SDK turns into the JSON Schema clients see |
| `typescript` and the `@types` packages | Type checking only, with `npx tsc`. Node.js strips the types when it runs the file |

Create the server:

```ts server.ts theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { createHash, timingSafeEqual } from "node:crypto";
import { createMcpExpressApp } from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import type { NextFunction, Request, Response } from "express";
import pg from "pg";
import * as z from "zod";

const token = process.env.MCP_TOKEN;
if (!token) throw new Error("MCP_TOKEN is not set");

// Every connection is read-only, so no tool can change data.
const pool = new pg.Pool({
  connectionString: process.env.DB_URL,
  options: "-c default_transaction_read_only=on",
});

function buildServer() {
  const server = new McpServer({ name: "storefront", version: "1.0.0" });

  server.registerTool(
    "get_order_status",
    {
      title: "Get order status",
      description: "Get the status, total and last update of one order by its number, such as 1001.",
      inputSchema: z.object({ order_id: z.number().int().positive() }),
      annotations: { readOnlyHint: true },
    },
    async ({ order_id }) => {
      const { rows } = await pool.query(
        "SELECT id, status, total, updated_at FROM orders WHERE id = $1",
        [order_id],
      );
      if (rows.length === 0) {
        return { content: [{ type: "text", text: `Order ${order_id} not found` }], isError: true };
      }
      return { content: [{ type: "text", text: JSON.stringify(rows[0]) }] };
    },
  );

  server.registerTool(
    "search_products",
    {
      title: "Search products",
      description: "Find products whose name contains the query, with their price and stock.",
      inputSchema: z.object({ query: z.string().min(1).max(100) }),
      annotations: { readOnlyHint: true },
    },
    async ({ query }) => {
      const { rows } = await pool.query(
        "SELECT name, price, stock FROM products WHERE name ILIKE $1 ORDER BY name LIMIT 10",
        [`%${query}%`],
      );
      return { content: [{ type: "text", text: JSON.stringify(rows) }] };
    },
  );

  return server;
}

// A new server answers each request, so any instance can serve any request.
const handler = createMcpHandler(buildServer);
const mcp = toNodeHandler(handler);

// Hashing both sides gives equal lengths, which timingSafeEqual needs.
const expected = createHash("sha256").update(`Bearer ${token}`).digest();
function requireToken(req: Request, res: Response, next: NextFunction) {
  const given = createHash("sha256").update(req.headers.authorization ?? "").digest();
  if (timingSafeEqual(given, expected)) return next();
  res.status(401).set("WWW-Authenticate", "Bearer").json({ error: "invalid_token" });
}

// Listening on all interfaces turns off the SDK's localhost-only Host check; the token protects the server.
const app = createMcpExpressApp({ host: "0.0.0.0" });
app.get("/health", (req, res) => {
  res.send("ok");
});
app.all("/mcp", requireToken, (req, res) => void mcp(req, res, req.body));

const port = Number(process.env.PORT ?? 3000);
const server = app.listen(port, () => {
  console.log(`MCP server listening on port ${port}`);
});

process.on("SIGTERM", () => {
  console.log("SIGTERM received, closing the server");
  server.close(async () => {
    await handler.close();
    await pool.end();
  });
});
```

What the server does:

* **Streamable HTTP on `/mcp`.** `createMcpHandler` answers every MCP request at `/mcp`: `POST` for requests and notifications. It answers clients on the `2026-07-28` protocol revision and on the older revisions that open with an `initialize` request.
* **No sessions.** The handler builds a new `McpServer` from `buildServer` for each request and keeps nothing in memory between requests, so it never issues an `Mcp-Session-Id`. See [Run more than one instance](#run-more-than-one-instance).
* **A bearer token.** `requireToken` compares the `Authorization` header with `Bearer ` and the `MCP_TOKEN` environment variable in constant time, and answers `401` to anything else. The server refuses to start without `MCP_TOKEN`, so it never runs open by mistake. `/health` stays public for the readiness probe.
* **Read-only database access.** `DB_URL` is the variable Sevalla adds when you connect the database. Every connection starts with `default_transaction_read_only=on`, and the queries pass the model's arguments as parameters (`$1`), never as SQL text. The `readOnlyHint` annotation tells clients the tools change nothing; clients use it to decide whether to ask you before a call.
* **Shutdown.** Sevalla sends `SIGTERM` to each instance it replaces during a deployment. The server stops accepting connections, finishes the requests in progress, then closes the handler and the database pool.

Add a `tsconfig.json` so `npx tsc` and your editor check the types the way Node.js runs the file:

```json tsconfig.json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "types": ["node"],
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true
  }
}
```

Keep installed packages and local secrets out of the repository:

```text .gitignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
node_modules/
.env
```

Save the tables and sample rows the tools read. You run this file in the database once it exists:

```sql schema.sql theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
CREATE TABLE products (
  id serial PRIMARY KEY,
  name text NOT NULL,
  price numeric(10, 2) NOT NULL,
  stock integer NOT NULL
);

CREATE TABLE orders (
  id integer PRIMARY KEY,
  status text NOT NULL,
  total numeric(10, 2) NOT NULL,
  updated_at timestamptz NOT NULL DEFAULT now()
);

INSERT INTO products (name, price, stock) VALUES
  ('Espresso beans', 14.50, 120),
  ('Pour-over kettle', 39.00, 8),
  ('Ceramic mug', 12.00, 0);

INSERT INTO orders (id, status, total) VALUES
  (1001, 'shipped', 53.50),
  (1002, 'processing', 12.00);
```

Run `npx tsc` to check the types; it prints nothing when they're correct. To run the server on your computer, point `DB_URL` at a local PostgreSQL database that has these tables, for example `MCP_TOKEN=local-token DB_URL=postgres://localhost:5432/storefront npm start`, then open `http://localhost:3000/health`: the response is `ok`.

## Prepare the project for Sevalla

The project already has what [Ingot](https://docs.sevalla.com/applications/builds/ingot.md), Sevalla's builder, needs:

| File or field | Why Sevalla needs it |
| - | - |
| `scripts.start` in `package.json` | Ingot starts the web process with the `start` script, `node server.ts`. |
| `engines.node` in `package.json` | Ingot builds on the Node.js 24 line, which runs `.ts` files without a build step. Without it, Ingot uses its default line, which a new Ingot release can move. See [Pin the Node.js version](https://docs.sevalla.com/guides/javascript/overview.md#pin-the-node-js-version). |
| `package-lock.json` | Ingot installs the exact versions in it with `npm ci`, then removes the devDependencies from the image. Commit it. |

The server reads [`PORT`](https://docs.sevalla.com/guides/javascript/overview#listen-on-the-port-environment-variable), which Sevalla sets to the web process's port (`8080` by default), and listens on all interfaces.

Commit everything and push it to your Git provider:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
git init
git add .
git commit -m "Prepare for Sevalla"
git branch -M main
git remote add origin https://github.com/acme/storefront-mcp.git
git push -u origin main
```

## Deploy to Sevalla

These steps build with [Ingot](https://docs.sevalla.com/applications/builds/ingot.md). To build from your own Dockerfile instead, see [Deploy with a Dockerfile](#deploy-with-a-dockerfile).

Generate the token that clients send, and keep it in your password manager:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
openssl rand -hex 32
```

**1. Create the database**

In the sidebar, click **Databases**, then click **Create database**. Under **Database**, choose **PostgreSQL** and the newest version. Enter `orders-db` as the **Display name**, choose a **Data center** and a **Size**, then click **Create database**. See [Create a database](https://docs.sevalla.com/databases/create.md) for every field.

**2. Create the tables**

When the database is ready, click its **SQL studio** tab, then click **SQL console** at the top of the sidebar. Paste the contents of `schema.sql` and click **Run all**.

**3. Create the application**

In the sidebar, click **Applications**, then click **Create** > **Application**. Under **Source**, click **Private repository**, then choose the **Git provider**, the **Repository** and the **Branch**. Under **Location**, choose the same **Data center** as the database. Keep the **Size** at **H1** or choose a larger one, then click **Create application**. The application opens on its **Overview** tab with the status **Not deployed yet**: creating an application doesn't deploy it.

**4. Connect the database**

Click the **Networking** tab. Under **Connected services**, click **Add internal connection**. Choose `orders-db` as the **Service**, turn on **Add environment variables to the application**, and click **Add internal connection**. Sevalla adds `DB_URL` and the other connection variables to the application. See [Connect an application](https://docs.sevalla.com/databases/connect.md#connect-an-application).

**5. Add the token**

Click the **Environment variables** tab, then click **Add environment variables**. Enter `MCP_TOKEN` as the key and the token you generated as the value. Under **Availability**, clear **Available during buildtime**, so the token reaches only the running server, then click **Add environment variables**.

**6. Deploy**

Click the **Overview** tab, then click **Deploy now** under **Recent deployments**. Check the **Branch** and click **Deploy**.

**7. Turn on auto-deploy**

To deploy every push to the branch automatically, click the **Settings** tab, then click **Update auto-deploy** in the **Auto-deploy** section. Turn on **Auto-deploy** and click **Update auto-deploy**. See [Deployments](https://docs.sevalla.com/applications/deployments.md#deploy-automatically-on-push).

## Verify the deployment

1. On the **Deployments** tab, click the deployment. Its **Build log** shows Ingot's [build plan](https://docs.sevalla.com/applications/builds/ingot.md#read-the-build-plan): Node.js 24 from `engines.node`, npm from `package-lock.json`, and the start command from the `start` script.

   ```text Build log theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ╭─ ■ Node.js 24 application ───────────────────────────────────╮
   │ Node.js          24              package.json engines "24.x" │
   │ Package manager  npm             package-lock.json           │
   │ Start            node server.ts  package.json scripts.start  │
   │ DevDependencies  removed after the build                     │
   ├─ Image ──────────────────────────────────────────────────────┤
   │ Build            node:24-trixie                              │
   │ Runtime          node:24-trixie-slim                         │
   │ User             1000:1000                                   │
   │ Init             tini                                        │
   │ Port             $PORT, default 8080                         │
   ╰─────────────────────────────────────────────── Ingot v0.4.1 ─╯
   ```

2. Wait for the deployment's status to change to **Deployed**. The application's status next to its name changes to **Running**. Its [runtime logs](https://docs.sevalla.com/applications/logs.md) show `MCP server listening on port 8080`, after the SDK's warning `Server is binding to 0.0.0.0 without DNS rebinding protection`: the warning is about servers on `localhost`, and this server requires a token instead.

   With the API, [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) returns the status, [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`) searches the build log, and [Get application runtime logs](https://api-docs.sevalla.com/v3/applications/get-application-runtime-logs) (`getApplicationRuntimeLogsV3`) searches the runtime logs. With `APP_ID` and `DEP_ID` from [Deploy with the API](#deploy-with-the-api):

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s "https://api.sevalla.com/v3/applications/$APP_ID/deployments/$DEP_ID" \
     -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r .status
   curl -s "https://api.sevalla.com/v3/applications/$APP_ID/deployments/$DEP_ID/logs?search=scripts.start&limit=1" \
     -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[0].message'
   curl -s "https://api.sevalla.com/v3/applications/$APP_ID/runtime-logs?search=MCP%20server%20listening&limit=1" \
     -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[0].message'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   success
   │ Start            node server.ts  package.json scripts.start  │
   MCP server listening on port 8080
   ```

3. Copy the domain under the application's name, for example `storefront-mcp-x7k2m.sevalla.app`. The MCP endpoint is that domain with `/mcp`. In a terminal, put the token in a variable and call the `get_order_status` tool with a JSON-RPC request:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   MCP_TOKEN=<your token>
   curl -s https://storefront-mcp-x7k2m.sevalla.app/mcp \
     -H "Authorization: Bearer $MCP_TOKEN" \
     -H "Content-Type: application/json" \
     -H "Accept: application/json, text/event-stream" \
     -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_order_status","arguments":{"order_id":1001}}}'
   ```

   The response is one Server-Sent Event with the tool's result, read from the database:

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   event: message
   data: {"result":{"content":[{"type":"text","text":"{\"id\":1001,\"status\":\"shipped\",\"total\":\"53.50\",\"updated_at\":\"2026-10-30T03:42:17.994Z\"}"}]},"jsonrpc":"2.0","id":1}
   ```

   Check the health endpoint, and that the same server refuses a request without the token:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s https://storefront-mcp-x7k2m.sevalla.app/health; echo
   curl -s -w "\n%{http_code}\n" https://storefront-mcp-x7k2m.sevalla.app/mcp \
     -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ok
   {"error":"invalid_token"}
   401
   ```

4. List the tools with the [MCP Inspector](https://github.com/modelcontextprotocol/inspector), the protocol's own test client:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   npx @modelcontextprotocol/inspector --cli https://storefront-mcp-x7k2m.sevalla.app/mcp \
     --header "Authorization: Bearer $MCP_TOKEN" --method tools/list \
     | jq -r '.tools[] | "\(.name) \(.annotations.readOnlyHint)"'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   get_order_status true
   search_products true
   ```

   Without `jq`, the output is the full list with each tool's input schema. To call a tool, use `--method tools/call --tool-name search_products --tool-arg query=mug`.

![The application header with its name, Running status, repository, branch and the domain that opens the application](https://assets.sevalla.com/screenshots/light/app-overview-address.png)

If the deployment fails, read its build log, then see [Troubleshoot Ingot builds](https://docs.sevalla.com/applications/builds/ingot.md#troubleshoot-ingot-builds). If the server crashes at start, read the runtime logs and see [Troubleshoot](#troubleshoot).

## Run more than one instance

Sevalla spreads the requests to a web process across its instances, request by request, with no session affinity: two requests from the same client can reach different instances. This server works with any number of instances because `createMcpHandler` keeps no session, so any instance answers any request.

A server that keeps MCP sessions in memory doesn't. With the SDK's sessionful transport (`NodeStreamableHTTPServerTransport` with a `sessionIdGenerator`), the instance that answers `initialize` creates the session, and every request that reaches the other instance fails with `404` and `{"jsonrpc":"2.0","error":{"code":-32001,"message":"Session not found"},"id":null}`. In a test with 2 instances, 25 of 50 requests failed this way and the MCP Inspector couldn't complete a single tool call, while this guide's server answered 200 of 200 tool calls. Keep MCP state out of the server: anything a tool must remember belongs in the database.

A readiness probe makes each new instance wait until it answers before it gets requests, so a deployment switches over only to instances that serve MCP:

**1. Open the web process**

On the application's **Processes** tab, click **⋯** on the **Web** process, then click **Update process**.

**2. Set the instances**

In **Scaling**, drag **Instance count** to `2` or more. More than 1 instance needs a valid payment method.

**3. Turn on the readiness probe**

Under **Health checks**, turn on **Readiness probe** and set the **Path** to `/health`. Keep the **Port** at the web process's port, `8080` unless you changed it.

**4. Save and deploy**

Click **Update process**, then click **Deploy** in the undeployed changes bar at the bottom of the page.

Every instance costs the same as the first. See [Scaling](https://docs.sevalla.com/applications/scaling.md) and [Health checks](https://docs.sevalla.com/applications/processes.md#health-checks). With the API, send `{"scaling_strategy":{"type":"manual","config":{"instanceCount":2}}}` to [Update process](https://api-docs.sevalla.com/v3/applications/update-process) (`updateProcessV3`), then deploy; the readiness probe is part of [Deploy with the API](#deploy-with-the-api). The Sevalla MCP server's `scale_process` tool sets the instance count too.

## Connect a client

Each client needs the endpoint URL, such as `https://storefront-mcp-x7k2m.sevalla.app/mcp`, and the token, sent as `Authorization: Bearer <token>`. Clients connect over the internet to the application's domain, so keep HTTP traffic on.

### Claude Code

Add the server, then check that Claude Code connects:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
claude mcp add --transport http storefront https://storefront-mcp-x7k2m.sevalla.app/mcp \
  --header "Authorization: Bearer $MCP_TOKEN"
claude mcp list
```

`claude mcp list` shows `storefront: https://storefront-mcp-x7k2m.sevalla.app/mcp (HTTP) - ✔ Connected`. Ask Claude Code "What is the status of order 1001?": it calls `get_order_status` and answers that the order has shipped. `claude mcp add` stores the server for the current project only; add `--scope user` to use it in every project.

### Claude

In Claude (claude.ai and the desktop app), a custom connector sends a fixed token only through **Request headers**, which Anthropic offers to a limited set of organizations. If your **Add custom connector** dialog has a **Request headers** section:

**1. Add the connector**

Go to **Customize** > **Connectors** and click **Add custom connector**. On a Team or Enterprise plan, an owner adds it in **Organization settings** > **Connectors** instead.

**2. Enter the server and the token**

Enter the endpoint URL as the **MCP server URL**. Under **Request headers**, add the `authorization` header with the value `Bearer ` followed by your token.

Without **Request headers**, Claude connects only to servers that use OAuth or no authentication, and this server uses neither. See Anthropic's [custom connector documentation](https://www.claude.com/docs/connectors/custom/remote-mcp).

### Cursor

Add the server to `.cursor/mcp.json` in your project, or to `~/.cursor/mcp.json` for every project. Cursor reads `${env:STOREFRONT_MCP_TOKEN}` from the environment it runs in, so the token stays out of the file:

```json .cursor/mcp.json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
  "mcpServers": {
    "storefront": {
      "url": "https://storefront-mcp-x7k2m.sevalla.app/mcp",
      "headers": { "Authorization": "Bearer ${env:STOREFRONT_MCP_TOKEN}" }
    }
  }
}
```

See Cursor's [MCP documentation](https://cursor.com/docs/context/mcp).

### ChatGPT

ChatGPT adds MCP servers as plugins, with **OAuth**, **No authentication**, or **OAuth or no authentication**. It can't send a fixed bearer token, so it can't connect to this server as built. Don't remove the token to connect it: anyone who finds the URL could then call the tools. See OpenAI's [custom MCP server documentation](https://developers.openai.com/api/docs/guides/custom-mcp-server).

### Other clients

Any MCP client that supports Streamable HTTP and custom headers connects the same way. The MCP Inspector in [Verify the deployment](#verify-the-deployment) is the quickest check that the URL and token work.

To change the token, update `MCP_TOKEN` on the **Environment variables** tab, deploy, and update every client.

## Deploy with a Dockerfile

Ingot needs no Dockerfile. Write your own when you want full control of the image: the base image, the system packages and every layer. Add a `Dockerfile` and a `.dockerignore` to the root of the repository:

```dockerfile Dockerfile theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
FROM node:24-trixie-slim

WORKDIR /app
ENV NODE_ENV=production PORT=8080

COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force

COPY . .

USER 1000:1000
EXPOSE 8080
CMD ["node", "server.ts"]
```

| Line or choice | Why |
| - | - |
| `node:24-trixie-slim` | The official Node.js 24 image on Debian 13, the line that `engines.node` pins for Ingot. Node.js 24 runs `server.ts` directly. The tag follows Node.js 24's patch releases, so each build gets its security fixes; a digest would stay on one release. |
| One stage | Nothing is compiled, so the image needs no build stage. `npm ci --omit=dev` installs the exact versions in the lock file without TypeScript and the type packages. |
| `package.json` and `package-lock.json` first | Docker reuses the installed dependencies when only the code changes. |
| `USER 1000:1000` | The server runs as the image's unprivileged `node` user. The code and dependencies stay owned by root, so the server can read them but not change them. |
| No init process | `server.ts` handles `SIGTERM` itself, so Node.js shuts down cleanly as the container's first process. |
| `PORT=8080` and `EXPOSE 8080` | The server listens on `PORT`, which Sevalla sets at runtime. The image's default matches Sevalla's, for running it on your computer. |

The image holds no secrets: `MCP_TOKEN` and `DB_URL` reach the container at runtime.

```text .dockerignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
.git
node_modules
npm-debug.log*
.env*
Dockerfile
.dockerignore
```

Commit and push both files. Ingot doesn't use the `Dockerfile`, so you can keep it in a repository that builds with Ingot, but it adds the `.dockerignore` patterns to its own: its build plan shows an **Ignored** row with `6 patterns from .dockerignore`. Then switch the application to the Dockerfile build strategy:

**1. Choose the Dockerfile build strategy**

On the application's **Settings** tab, in the **Build strategy** section, click **Update build strategy**. Click **Dockerfile**, keep the **Dockerfile path** `Dockerfile` and the **Docker context** `.`, then click **Update build strategy**.

**2. Deploy**

Click the **Overview** tab, then click **Deploy now** under **Recent deployments**. Check the **Branch** and click **Deploy**.

![The Update build strategy sheet with Dockerfile selected, the Dockerfile path, the Docker context and the Docker registry credential](https://assets.sevalla.com/screenshots/light/app-settings-build-strategy-dockerfile.png)

The deployment's **Build log** shows `📦 Building and pushing docker image using Dockerfile...` instead of Ingot's build plan, and its **Dockerfile** tab shows your `Dockerfile`. When the deployment is **Deployed**, the `curl` request in [Verify the deployment](#verify-the-deployment) returns the order again. See [Dockerfile](https://docs.sevalla.com/applications/builds/dockerfile.md) for build arguments and private base images.

## Deploy changes

With **Auto-deploy** on, every push to the branch starts a deployment; otherwise, click **Deploy now** for each change. Each deployment builds the image, then replaces the web process's instances. Clients see a tool you add the next time they list the tools, for example when they reconnect. See [Deployments](https://docs.sevalla.com/applications/deployments.md).

## Add a custom domain

To serve the MCP endpoint from your own domain, such as `mcp.example.com`, add the domain on the application's **Domains** tab and create the DNS records that Sevalla shows. The server accepts any host, so no code change is needed; update the URL in each client. Custom domains need a web process larger than **H1**, so [scale](https://docs.sevalla.com/applications/scaling.md) the web process first. See [Custom domains](https://docs.sevalla.com/domains/overview.md).

## Troubleshoot

| Symptom | Cause | Fix |
| - | - | - |
| Requests get `401` and `{"error":"invalid_token"}` | The client sends no `Authorization` header, or a different token. | Send `Authorization: Bearer <token>` with the value of `MCP_TOKEN`. After you change the token, deploy and update every client. |
| The web process crashes with `Error: MCP_TOKEN is not set` | The application has no `MCP_TOKEN` variable, or it's available only during the build. | Add `MCP_TOKEN` with **Available during runtime** selected, then deploy. |
| Requests fail with `404` and `Session not found` some of the time | The server keeps MCP sessions in memory and runs on 2 or more instances. | Serve MCP with `createMcpHandler`, which keeps no session, as in [Run more than one instance](#run-more-than-one-instance), or run 1 instance. |
| A tool fails with `cannot execute DELETE in a read-only transaction`, or the same for `INSERT` or `UPDATE` | The tool writes to the database through the read-only pool. | Give tools that write a second `pg.Pool` without `options`, and mark them with `readOnlyHint: false` so clients ask before they run. |
| A tool returns `relation "orders" does not exist` | The tables from `schema.sql` aren't in the database the application connects to. | Run `schema.sql` in the database's **SQL studio**, or with `psql` as in [Deploy with the API](#deploy-with-the-api). |
| `get_order_status` returns `Order 1003 not found` | No order has that number. The result has `isError: true`, so the model reads the message and can try another number. | No fix needed. |

For build and runtime failures every application shares, see [Troubleshoot builds and deployments](https://docs.sevalla.com/applications/troubleshooting/builds-and-deployments.md). To write the same server in Python, see [Deploy an MCP server with Python](https://docs.sevalla.com/guides/ai/mcp-python.md).

## Deploy with the API

These steps make the same deployment as [Deploy to Sevalla](#deploy-to-sevalla) through the [Sevalla API](https://docs.sevalla.com/developer-tools/api.md), as commands an agent or a script runs in order. Each step names the API operation, the [CLI](https://docs.sevalla.com/developer-tools/cli.md) command and the [Sevalla MCP server](https://docs.sevalla.com/ai-agents/overview.md) tool that do the same.

What the sequence needs:

* An [API key](https://docs.sevalla.com/developer-tools/api-keys.md) with the **Company developer** role, or custom capabilities that create, read and update applications and databases, in the `SEVALLA_API_KEY` environment variable. A call the key isn't allowed to make fails with `403` and names the missing permission.
* The project pushed to GitHub, from a Git account connected to Sevalla. If Sevalla can't read the repository, Create application fails with `400` and `Cannot access repository <url>: Repository not found or you don't have access.`, followed by where a person grants access. [List Git providers](https://api-docs.sevalla.com/v3/git/list-git-providers) (`listGitProvidersV3`) returns the `connect_url` that connects an account.
* `curl`, `jq`, `openssl` and `psql`, run from the project folder, which holds `schema.sql`.

**1. Set the variables**

Generate the token clients send and the database password. Clients need `MCP_TOKEN`; [List environment variables](https://api-docs.sevalla.com/v3/applications/list-environment-variables) (`listEnvironmentVariablesV3`) returns it again later, while the Sevalla MCP server's tools never show variable values.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
export SEVALLA_API_KEY=<your API key>
API=https://api.sevalla.com/v3
MCP_TOKEN=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 16)
```

**2. Pick the location**

[List clusters](https://api-docs.sevalla.com/v3/resources/list-clusters) (`listClustersV3`) returns each location with its region in `location`. Pick one by its region from [Locations](https://docs.sevalla.com/platform/locations.md), for example `eu-frankfurt-1` for Frankfurt, Germany:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
CLUSTER_ID=$(curl -s "$API/resources/clusters" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.[] | select(.location == "eu-frankfurt-1") | .id')
```

CLI: `sevalla resources clusters`. MCP: `get_platform_options`.

**3. Find the database size**

[List database resource types](https://api-docs.sevalla.com/v3/resources/list-database-resource-types) (`listDatabaseResourceTypesV3`) returns the database sizes; `db1` is the smallest.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB_SIZE_ID=$(curl -s "$API/resources/database-resource-types" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.[] | select(.name == "db1") | .id')
```

CLI: `sevalla resources db-types`. MCP: `get_platform_options`.

**4. Find the process size**

[List process resource types](https://api-docs.sevalla.com/v3/resources/list-process-resource-types) (`listProcessResourceTypesV3`) returns the process sizes; `h1` is **H1**.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
H1_ID=$(curl -s "$API/resources/process-resource-types" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.[] | select(.name == "h1") | .id')
```

CLI: `sevalla resources process-types`. MCP: `get_platform_options`.

**5. Create the database**

[Create database](https://api-docs.sevalla.com/v3/databases/create-database) (`createDatabaseV3`) starts creating `orders-db`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB_ID=$(jq -n --arg cluster "$CLUSTER_ID" --arg size "$DB_SIZE_ID" --arg password "$DB_PASSWORD" \
  '{display_name: "orders-db", type: "postgresql", version: "18", cluster_id: $cluster, resource_type_id: $size,
    db_name: "storefront", db_user: "storefront", db_password: $password}' \
  | curl -s -X POST "$API/databases" -H "Authorization: Bearer $SEVALLA_API_KEY" \
      -H "Content-Type: application/json" -d @- \
  | jq -r .id)
```

CLI: `sevalla databases create --name orders-db --type postgresql --db-version 18 --cluster $CLUSTER_ID --resource-type $DB_SIZE_ID --db-name storefront --db-user storefront --db-password $DB_PASSWORD`. MCP: `create_database`, which generates the password itself and never returns it; once the application exists, its `connect_to_application_id` and `env_var_name: "DB_URL"` also make the connection and write `DB_URL` as `postgresql://app_user:...`, which `pg` accepts like `postgres://`.

**6. Open the database to your computer**

When [Get database](https://api-docs.sevalla.com/v3/databases/get-database) (`getDatabaseV3`) reports `ready`, [Toggle external connection](https://api-docs.sevalla.com/v3/databases/toggle-external-connection) (`toggleDatabaseExternalConnectionV3`) gives the database a public hostname, so you can create the tables from your computer:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 60); do [ "$(curl -s "$API/databases/$DB_ID" -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r .status)" = ready ] && break; sleep 10; done
curl -s -X POST "$API/databases/$DB_ID/external-connection/toggle" -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r .message
```

The output is `External connection toggle started`. CLI: `sevalla databases connections external-toggle $DB_ID`. MCP: `sevalla_api_create` with `toggleDatabaseExternalConnectionV3`.

**7. Create the tables**

Wait until [Get database](https://api-docs.sevalla.com/v3/databases/get-database) (`getDatabaseV3`) is `ready` again with an `external_hostname`, then run `schema.sql`. The hostname can refuse connections for a minute after it appears, so `psql` retries; `-1` runs the file in one transaction, so a failed attempt leaves nothing behind:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 60); do DB=$(curl -s "$API/databases/$DB_ID" -H "Authorization: Bearer $SEVALLA_API_KEY") \
  && [ "$(echo "$DB" | jq -r .status)" = ready ] && [ "$(echo "$DB" | jq -r .external_hostname)" != null ] && break; sleep 10; done
for attempt in 1 2 3 4 5 6; do
  PGPASSWORD=$DB_PASSWORD psql "host=$(echo "$DB" | jq -r .external_hostname) port=$(echo "$DB" | jq -r .external_port) user=storefront dbname=storefront" \
    -1 -v ON_ERROR_STOP=1 -f schema.sql && break
  sleep 20
done
```

`psql` prints `CREATE TABLE` twice, `INSERT 0 3` and `INSERT 0 2`, after `server closed the connection unexpectedly` for each attempt that came too early. CLI: `sevalla databases get $DB_ID`. MCP: `get_database`.

**8. Close the database again**

The application reaches the database over the internal connection, so turn the public hostname off with [Toggle external connection](https://api-docs.sevalla.com/v3/databases/toggle-external-connection) (`toggleDatabaseExternalConnectionV3`):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X POST "$API/databases/$DB_ID/external-connection/toggle" -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r .message
```

CLI: `sevalla databases connections external-toggle $DB_ID`. MCP: `sevalla_api_create` with `toggleDatabaseExternalConnectionV3`.

**9. Create the application**

[Create application](https://api-docs.sevalla.com/v3/applications/create-application) (`createApplicationV3`) creates the application with a web process, without deploying it:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
APP_ID=$(jq -n --arg cluster "$CLUSTER_ID" \
  '{display_name: "storefront-mcp", cluster_id: $cluster, source: "privateGit", git_type: "github",
    repo_url: "https://github.com/acme/storefront-mcp", default_branch: "main"}' \
  | curl -s -X POST "$API/applications" -H "Authorization: Bearer $SEVALLA_API_KEY" \
      -H "Content-Type: application/json" -d @- \
  | jq -r .id)
```

The web process starts at the **S1** size, not the dashboard's **H1**, and `auto_deploy` is `false`. CLI: `sevalla apps create --name storefront-mcp --cluster $CLUSTER_ID --source privateGit --git-type github --repository https://github.com/acme/storefront-mcp --branch main`. MCP: `create_application` with `deploy: false` (it deploys by default).

**10. Find the web process**

[List processes](https://api-docs.sevalla.com/v3/applications/list-processes) (`listProcessesV3`) returns the web process:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
WEB_ID=$(curl -s "$API/applications/$APP_ID/processes" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.data[] | select(.type == "web") | .id')
```

CLI: `sevalla apps processes list --app-id $APP_ID`. MCP: `get_application`.

**11. Set the size and the readiness probe**

[Update process](https://api-docs.sevalla.com/v3/applications/update-process) (`updateProcessV3`) sets the web process to **H1** and adds the readiness probe on `/health`. Send `timeoutSeconds`: without it, the API sets the probe's timeout to `120` seconds, where the dashboard uses `1`.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
jq -n --arg size "$H1_ID" \
  '{resource_type_id: $size, readiness_probe: {httpGet: {path: "/health", port: 8080}, timeoutSeconds: 1}}' \
  | curl -s -X PATCH "$API/applications/$APP_ID/processes/$WEB_ID" -H "Authorization: Bearer $SEVALLA_API_KEY" \
      -H "Content-Type: application/json" -d @- \
  | jq -c '{resource_type_name, readiness_probe}'
```

The response shows `"resource_type_name":"h1"` and the probe. CLI: `sevalla apps processes update $WEB_ID --app-id $APP_ID --resource-type $H1_ID` sets the size; the CLI has no option for probes. MCP: `sevalla_api_update` with `updateProcessV3` and the same body.

**12. Connect the database**

[Create internal connection](https://api-docs.sevalla.com/v3/applications/create-internal-connection) (`createApplicationInternalConnectionV3`) lets the application reach `orders-db`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
jq -n --arg db "$DB_ID" '{target_id: $db, target_type: "database"}' \
  | curl -s -X POST "$API/applications/$APP_ID/internal-connections" -H "Authorization: Bearer $SEVALLA_API_KEY" \
      -H "Content-Type: application/json" -d @- \
  | jq -r .id
```

The output is the connection's ID. Unlike the dashboard, the API adds no environment variables with the connection; the next step adds them. CLI: `sevalla apps internal-connections create $APP_ID --target-id $DB_ID --target-type database`. MCP: `sevalla_api_create` with `createApplicationInternalConnectionV3`.

**13. Add the environment variables**

[Create environment variable](https://api-docs.sevalla.com/v3/applications/create-environment-variable) (`createEnvironmentVariableV3`), once per variable, adds the six connection variables the dashboard adds, with the address from [Get database](https://api-docs.sevalla.com/v3/databases/get-database), and `MCP_TOKEN` for runtime only:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB=$(curl -s "$API/databases/$DB_ID" -H "Authorization: Bearer $SEVALLA_API_KEY")
DB_HOST=$(echo "$DB" | jq -r .internal_hostname)
DB_PORT=$(echo "$DB" | jq -r .internal_port)
add_var() {
  jq -n --arg key "$1" --arg value "$2" --argjson buildtime "$3" \
    '{key: $key, value: $value, is_runtime: true, is_buildtime: $buildtime}' \
    | curl -s -X POST "$API/applications/$APP_ID/env-vars" -H "Authorization: Bearer $SEVALLA_API_KEY" \
        -H "Content-Type: application/json" -d @- \
    | jq -r .key
}
add_var DB_HOST "$DB_HOST" true
add_var DB_PORT "$DB_PORT" true
add_var DB_DATABASE storefront true
add_var DB_USERNAME storefront true
add_var DB_PASSWORD "$DB_PASSWORD" true
add_var DB_URL "postgres://storefront:$DB_PASSWORD@$DB_HOST:$DB_PORT/storefront" true
add_var MCP_TOKEN "$MCP_TOKEN" false
```

Each call prints the key it created. CLI: `sevalla apps env-vars create $APP_ID --key DB_URL --value <url>`, one variable per command; CLI 2.1.0 makes every variable available during build and runtime and has no option to change that, so add `MCP_TOKEN` with the API or MCP. MCP: `set_environment_variables`, with `runtime` and `buildtime` per variable.

**14. Turn on auto-deploy**

[Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`) deploys every push to `main` from now on:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X PATCH "$API/applications/$APP_ID" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" -d '{"auto_deploy":true}' | jq -r .auto_deploy
```

The response is `true`. CLI: `sevalla apps update $APP_ID --data '{"auto_deploy":true}'`. MCP: `sevalla_api_update` with `updateApplicationV3`.

**15. Deploy**

[Trigger deployment](https://api-docs.sevalla.com/v3/applications/trigger-deployment) (`triggerDeploymentV3`) builds the latest commit on `main` with Ingot:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DEP_ID=$(curl -s -X POST "$API/applications/$APP_ID/deployments" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" -d '{}' | jq -r .id)
```

CLI: `sevalla apps deployments trigger $APP_ID`. MCP: `trigger_deployment`.

**16. Wait for the deployment**

Poll [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) until `status` is `success`, `failed`, `cancelled` or `skipped`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
while :; do
  STATUS=$(curl -s "$API/applications/$APP_ID/deployments/$DEP_ID" -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r .status)
  echo "$STATUS"
  case $STATUS in success|failed|cancelled|skipped) break ;; esac
  sleep 10
done
```

The last line is `success`. On `failed`, read the build log with [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`): `curl -s "$API/applications/$APP_ID/deployments/$DEP_ID/logs?order=desc&limit=50" -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[].message'`. CLI: `sevalla apps deployments get $DEP_ID --app-id $APP_ID` and `sevalla apps deployments logs $DEP_ID --app-id $APP_ID`. MCP: `get_deployment` and `get_deployment_logs`.

**17. Run the checks**

[List application domains](https://api-docs.sevalla.com/v3/applications/list-application-domains) (`listApplicationDomainsV3`) returns the domain; then call the server:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
HOST=$(curl -s "$API/applications/$APP_ID/domains" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.data[] | select(.is_primary) | .name')
curl -s "https://$HOST/health"; echo
curl -s "https://$HOST/mcp" -H "Authorization: Bearer $MCP_TOKEN" -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_order_status","arguments":{"order_id":1001}}}'
```

The output is `ok`, then the order, as in [Verify the deployment](#verify-the-deployment):

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
ok
event: message
data: {"result":{"content":[{"type":"text","text":"{\"id\":1001,\"status\":\"shipped\",\"total\":\"53.50\",\"updated_at\":\"2026-10-30T05:04:57.418Z\"}"}]},"jsonrpc":"2.0","id":1}
```

CLI: `sevalla apps domains list --app-id $APP_ID`. MCP: `get_application`.

To build with the [Dockerfile](#deploy-with-a-dockerfile) instead, send `{"build_type":"dockerfile","dockerfile_path":"Dockerfile","docker_context":"."}` to [Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`), then run the deploy and wait steps again. CLI: `sevalla apps update $APP_ID --build-type dockerfile --dockerfile-path Dockerfile`. MCP: `sevalla_api_update` with `updateApplicationV3`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.