> ## 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 Python

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

An MCP server built with the official [MCP Python SDK](https://py.sdk.modelcontextprotocol.io/) runs on Sevalla as an application: Uvicorn serves the Model Context Protocol over Streamable HTTP at `/mcp` in the web process, 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 runs in stateless HTTP mode, so it works on as many instances as you need.

The SDK's `MCPServer` class is what version 1 of the SDK called `FastMCP`. 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) (Python 3.14, uv), or the [Dockerfile](#deploy-with-a-dockerfile) |
| Start command | `python main.py`, detected from `main.py` |
| 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).
* Python 3.14 and [uv](https://docs.astral.sh/uv/) 0.13 or later on your computer.

## Create the project

Create the project with uv and add the SDK, the PostgreSQL driver with its connection pool, and Uvicorn:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
uv init --app --no-package --python 3.14 storefront-mcp
cd storefront-mcp
uv add mcp "psycopg[binary,pool]" uvicorn
```

`uv init` writes `pyproject.toml`, a `.python-version` with `3.14`, a `.gitignore` that keeps out `.venv`, a `main.py` and a Git repository. `uv add` installs the packages into `.venv` and pins every version in `uv.lock`:

| Package | What it does |
| - | - |
| `mcp` | The MCP Python SDK, version 2: `MCPServer` and its Streamable HTTP app |
| `psycopg[binary,pool]` | The PostgreSQL driver, psycopg 3, with its prebuilt binary and connection pool |
| `uvicorn` | The ASGI server that serves the app |

Replace the contents of `main.py` with the server:

```python main.py theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import hmac
import os
from contextlib import asynccontextmanager
from typing import Annotated

import uvicorn
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.server.transport_security import TransportSecuritySettings
from mcp.types import ToolAnnotations
from psycopg.rows import dict_row
from psycopg_pool import AsyncConnectionPool
from pydantic import Field
from starlette.requests import Request
from starlette.responses import JSONResponse, PlainTextResponse

TOKEN = os.environ["MCP_TOKEN"]

# Every connection is read-only, so no tool can change data.
pool = AsyncConnectionPool(
    os.environ["DB_URL"],
    kwargs={"options": "-c default_transaction_read_only=on", "row_factory": dict_row},
    open=False,
)


@asynccontextmanager
async def lifespan(server: MCPServer):
    async with pool:
        yield {}


mcp = MCPServer("storefront", lifespan=lifespan)
read_only = ToolAnnotations(read_only_hint=True)


@mcp.tool(title="Get order status", annotations=read_only)
async def get_order_status(order_id: Annotated[int, Field(gt=0)]) -> dict:
    """Get the status, total and last update of one order by its number, such as 1001."""
    async with pool.connection() as conn:
        cur = await conn.execute(
            "SELECT id, status, total, updated_at FROM orders WHERE id = %s", (order_id,)
        )
        order = await cur.fetchone()
    if order is None:
        raise ToolError(f"Order {order_id} not found")
    return order


@mcp.tool(title="Search products", annotations=read_only)
async def search_products(query: Annotated[str, Field(min_length=1, max_length=100)]) -> list[dict]:
    """Find products whose name contains the query, with their price and stock."""
    async with pool.connection() as conn:
        cur = await conn.execute(
            "SELECT name, price, stock FROM products WHERE name ILIKE %s ORDER BY name LIMIT 10",
            (f"%{query}%",),
        )
        return await cur.fetchall()


@mcp.custom_route("/health", methods=["GET"])
async def health(request: Request) -> PlainTextResponse:
    return PlainTextResponse("ok")


class RequireToken:
    """Answers 401 to every request but /health without the right bearer token."""

    def __init__(self, app):
        self.app = app
        self.expected = f"Bearer {TOKEN}".encode()

    async def __call__(self, scope, receive, send):
        if scope["type"] == "http" and scope["path"] != "/health":
            given = dict(scope["headers"]).get(b"authorization", b"")
            if not hmac.compare_digest(given, self.expected):
                response = JSONResponse(
                    {"error": "invalid_token"}, status_code=401, headers={"WWW-Authenticate": "Bearer"}
                )
                await response(scope, receive, send)
                return
        await self.app(scope, receive, send)


app = mcp.streamable_http_app(
    # A new session answers each request, so any instance can serve any request.
    stateless_http=True,
    # The SDK accepts only localhost Host headers by default; the token protects the server instead.
    transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
)
app.add_middleware(RequireToken)

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8000")))
```

What the server does:

* **Streamable HTTP on `/mcp`.** `mcp.streamable_http_app()` returns a Starlette app that answers MCP at `/mcp`, and Uvicorn serves it on the port in the `PORT` environment variable, on all interfaces. The app answers clients on the `2026-07-28` protocol revision and on the older revisions that open with an `initialize` request.
* **Stateless HTTP.** For clients on the older revisions, the SDK keeps a session in the memory of one process by default. `stateless_http=True` answers each request on its own instead, so any instance can serve any request. See [Run more than one instance](#run-more-than-one-instance).
* **No localhost-only check.** By default the app accepts only requests addressed to `localhost` and answers every other `Host` with `421 Misdirected Request`. Turning that check off lets it answer on its Sevalla domain and on custom domains; the token protects it instead.
* **A bearer token.** `RequireToken` compares the `Authorization` header with `Bearer ` and the `MCP_TOKEN` environment variable with `hmac.compare_digest`, 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. The lifespan opens the pool when the server starts and closes it when it stops. Every connection starts with `default_transaction_read_only=on`, and the queries pass the model's arguments as parameters (`%s`), never as SQL text. The type hints and `Field` constraints become the tools' input schemas, and the `read_only_hint` annotation tells clients the tools change nothing; clients use it to decide whether to ask you before a call.
* **Errors the model reads.** `ToolError` returns its message to the model as a failed tool result, `Error executing tool get_order_status: Order 1003 not found`, so the model can try again with another number. Any other exception returns only `Error executing tool get_order_status`.
* **Shutdown.** Sevalla sends `SIGTERM` to each instance it replaces during a deployment. Uvicorn stops accepting connections, finishes the requests in progress, and the lifespan closes the pool.

Keep local secrets out of the repository too:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
echo ".env" >> .gitignore
```

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);
```

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 uv run main.py`, then open `http://localhost:8000/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 | Why Sevalla needs it |
| - | - |
| `.python-version` | Ingot builds on the Python line it names, 3.14. Without it, Ingot uses its default line, which a new Ingot release can move. See [Pin the Python version](https://docs.sevalla.com/guides/python/overview.md#pin-the-python-version). |
| `uv.lock` | Ingot installs the exact versions in it with `uv sync --locked`, into a virtual environment at `/app/.venv` that's on the `PATH`. Commit it. |
| `main.py` | Ingot finds no web framework among the dependencies, so it starts the web process with `python main.py`, and `uvicorn.run()` listens on `PORT`, which Sevalla sets to the web process's port (`8080` by default). |

Uvicorn runs one process in each instance; add instances to serve more clients. To start the server another way, set the web process's start command; see [Set the start command](https://docs.sevalla.com/applications/builds/ingot.md#set-the-start-command).

Commit everything and push it to your Git provider. `uv init` already created the repository:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
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): Python 3.14 from `.python-version`, uv from `uv.lock`, and `python main.py` as the start command.

   ```text Build log theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ╭─ ■ Python 3.14 application ─────────────────────────────╮
   │ Python           3.14            .python-version "3.14" │
   │ Package manager  uv              uv.lock                │
   │ uv               0.13            default                │
   │ Start            python main.py  main.py                │
   ├─ Image ─────────────────────────────────────────────────┤
   │ Build            python:3.14-slim-trixie                │
   │ Runtime          python:3.14-slim-trixie                │
   │ 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 `Uvicorn running on http://0.0.0.0:8080`, then `Terminating session: None` after each MCP request: the SDK closing the one-request session of stateless mode.

   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=python%20main.py&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=Uvicorn%20running&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            python main.py  main.py                │
   INFO:     Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
   ```

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: {"jsonrpc":"2.0","id":1,"result":{"content":[{"text":"{\n  \"id\": 1001,\n  \"status\": \"shipped\",\n  \"total\": \"53.50\",\n  \"updated_at\": \"2026-10-30T03:52:05.839506Z\"\n}","type":"text"}],"isError":false}}
   ```

   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. With `stateless_http=True`, any instance answers any request, so the server works with any number of instances.

Without it, the SDK gives each client that opens with `initialize` a session that lives in the memory of the instance that created it, and every later request that reaches another instance fails with `404` and `{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Session not found"}}`. In a test with 2 instances, 29 of 50 requests failed this way and 7 of 10 MCP Inspector runs couldn't complete a tool call; with `stateless_http=True`, the same server answered 200 of 200 tool calls.

Stateless mode has one cost: on those older protocol revisions, a tool can't send a request back to the client in the middle of a call, such as asking the user a question with `ctx.elicit()`. Neither tool in this guide does. Keep anything a tool must remember in the database, not in the process.

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 "Are there any mugs in stock?": it calls `search_products` and answers that the ceramic mug is out of stock. `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 python:3.14-slim-trixie AS build
COPY --from=ghcr.io/astral-sh/uv:0.13 /uv /usr/local/bin/
ENV UV_COMPILE_BYTECODE=1 \
    UV_PYTHON_DOWNLOADS=0
WORKDIR /app
COPY pyproject.toml uv.lock .python-version ./
RUN uv sync --locked --no-dev

FROM python:3.14-slim-trixie
RUN useradd --uid 1000 --create-home app
ENV PATH="/app/.venv/bin:$PATH" \
    PYTHONUNBUFFERED=1 \
    PORT=8080
WORKDIR /app
COPY --from=build /app/.venv .venv
COPY . .
USER 1000:1000
EXPOSE 8080
CMD ["python", "main.py"]
```

| Line or choice | Why |
| - | - |
| `python:3.14-slim-trixie` | The Python line that `.python-version` pins, on Debian 13, as Ingot builds it. A line tag picks up each patch release and security fix when you deploy again; a digest would stay on one release. |
| Two stages | The first stage installs the packages with uv. The final image gets only the virtual environment and the code, not uv. |
| `ghcr.io/astral-sh/uv:0.13` | uv's official image, which Ingot uses too. `COPY --from` takes its `uv` binary without a download script. |
| `uv sync --locked --no-dev` | Installs exactly what `uv.lock` pins, and fails if the lock file doesn't match `pyproject.toml`. `UV_COMPILE_BYTECODE=1` compiles the packages at build time, so instances start faster. `UV_PYTHON_DOWNLOADS=0` makes uv use the image's Python rather than download one. |
| Lock files before the code | Docker reuses the installed packages until `pyproject.toml`, `uv.lock` or `.python-version` change, so a code change rebuilds only the last layers. |
| `useradd` and `USER 1000:1000` | Runs the server as an unprivileged user, the same user ID as Ingot's images. The code and packages stay owned by root, so the server can't change them. |
| `CMD ["python", "main.py"]` | Python runs as the container's first process, and Uvicorn handles `SIGTERM` itself, so the image needs no init process. `uvicorn.run()` reads `PORT`; the image's default matches Sevalla's `8080`. |

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
.venv
**/__pycache__
.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).

## 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. After you add a package with `uv add`, commit `pyproject.toml` and `uv.lock` together. 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 |
| - | - | - |
| Every request gets `421 Misdirected Request` and `Invalid Host header`, and the runtime logs show `Invalid Host header: storefront-mcp-x7k2m.sevalla.app` | `streamable_http_app()` has no `transport_security`, so the SDK accepts only `localhost`. | Pass `transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)`, as `main.py` does. |
| 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 `KeyError: 'MCP_TOKEN'` | 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 | `stateless_http=True` is missing and the web process runs 2 or more instances. | Add `stateless_http=True`, as in [Run more than one instance](#run-more-than-one-instance), or run 1 instance. |
| A tool returns `Error executing tool <name>`, and the runtime logs show `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 pool without `options`, and mark them with `read_only_hint=False` so clients ask before they run. |
| A tool returns only `Error executing tool get_order_status`, without a reason | The tool raised an exception other than `ToolError`. The SDK hides its message from the client and logs the traceback. | Read the runtime logs. Raise `ToolError` for errors the model should read. |

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 TypeScript, see [Deploy an MCP server with TypeScript](https://docs.sevalla.com/guides/ai/mcp-typescript.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 psycopg 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: {"jsonrpc":"2.0","id":1,"result":{"content":[{"text":"{\n  \"id\": 1001,\n  \"status\": \"shipped\",\n  \"total\": \"53.50\",\n  \"updated_at\": \"2026-10-30T05:04:24.188120Z\"\n}","type":"text"}],"isError":false}}
```

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.