Adios
← MCP Server Hosting

Language walkthrough

MCP server in Python

Build one authenticated MCP service, connect it to the Orders API, test a real tool call, and deploy it on Adios.

Python 3.13 · Streamable HTTP · JWT · Adios

Start with a running example

Launch it, then make it yours

Create the Orders API, a PostgreSQL database and your chosen MCP server in Adios. We upload the source, configure separate JWT credentials and start development previews.

Launch to Adios →

No account yet? Create one and return to this example. Select a team and review the resources before launching. The full example needs a paid plan with room for two workspaces and a database.

Build it step by step

Follow the manual guide

Design the API and schema, add JWT authentication, write the MCP tools and run them in your own development workspaces. Each step includes source and commands.

Follow the manual walkthrough →

Use the running example to explore first. OAuth for user sign-in and production deployment are later steps.

Start with the API and database

Build an MCP server in Python that looks up orders through a REST API. You’ll add JWT authentication, test the server in an Adios workspace, and deploy it.

Before you start, you’ll need a running API and its JWT signing key. The main guide provides an example Orders API using Node.js 24 and PostgreSQL, with instructions for starting its development workspace.

The API process listens on port 8081 and MCP on port 8080 inside their workspaces. Clients use the generated HTTPS preview URLs. Keep API_JWT_SECRET and MCP_JWT_SECRET separate.

Create an MCP server in Python

In python/, create a virtual environment and install requirements.txt. FastMCP generates a tool schema from the function signature. The handler validates the ID, calls the same API with HTTPX and returns a typed dictionary. The ASGI wrapper verifies the client JWT while preserving the SDK application’s lifespan handling.

python/requirements.txtDownload file
mcp==1.30.0
httpx==0.28.1
uvicorn==0.35.0
PyJWT==2.15.1
python/server.pyDownload file
import os
import re
import time
from urllib.parse import urlparse

import httpx
import jwt
import uvicorn
from mcp.server.fastmcp import Context, FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from mcp.types import ToolAnnotations
from starlette.responses import JSONResponse

api_base = os.getenv("API_BASE_URL", "http://127.0.0.1:8081").rstrip("/")
api_secret = os.environ["API_JWT_SECRET"]
mcp_secret = os.environ["MCP_JWT_SECRET"]
if min(len(api_secret), len(mcp_secret)) < 32 or api_secret == mcp_secret:
    raise ValueError("Use separate API and MCP signing secrets of at least 32 characters")
origin = os.getenv("PUBLIC_ORIGIN", "http://127.0.0.1:8080").rstrip("/")
mcp = FastMCP(
    "orders-mcp", stateless_http=True, json_response=True,
    transport_security=TransportSecuritySettings(
        enable_dns_rebinding_protection=True,
        allowed_hosts=[urlparse(origin).netloc, "127.0.0.1:8080", "localhost:8080"],
        allowed_origins=[origin],
    ),
)


@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
async def get_order(order_id: str, ctx: Context) -> dict[str, str]:
    """Read an order's shipping status from the Orders API."""
    if not re.fullmatch(r"[a-zA-Z0-9-]{1,64}", order_id):
        raise ValueError("Use an order ID containing letters, numbers, or hyphens")
    # Identity comes from verified HTTP state, not a user-supplied tool argument.
    subject = ctx.request_context.request.state.principal
    now = int(time.time())
    api_token = jwt.encode(
        {"iss": "orders-demo", "aud": "orders-api", "sub": subject,
         "scope": "orders:read", "iat": now, "exp": now + 300},
        api_secret, algorithm="HS256",
    )
    try:
        async with httpx.AsyncClient(timeout=5.0, follow_redirects=False) as client:
            response = await client.get(
                f"{api_base}/orders/{order_id}",
                headers={"Authorization": f"Bearer {api_token}"},
            )
            response.raise_for_status()
            data = response.json()
            if not isinstance(data.get("id"), str) or not isinstance(data.get("status"), str):
                raise ValueError("Unexpected API response")
            return {"id": data["id"], "status": data["status"]}
    except (httpx.HTTPError, ValueError, AttributeError):
        raise ValueError("Could not read this order. Check its ID and your API access.") from None


@mcp.custom_route("/healthz", methods=["GET"])
async def health(_request):
    return JSONResponse({"ok": True})


class JWTGate:
    def __init__(self, application):
        self.application = application

    async def __call__(self, scope, receive, send):
        if scope["type"] == "http" and scope["path"] == "/mcp":
            headers = dict(scope["headers"])
            try:
                match = re.fullmatch(rb"Bearer (\S+)", headers.get(b"authorization", b""))
                if not match:
                    raise ValueError("Missing token")
                claims = jwt.decode(
                    match[1], mcp_secret, algorithms=["HS256"],
                    issuer="orders-demo", audience="orders-mcp",
                    options={"require": ["sub", "exp"]},
                )
                if not isinstance(claims["sub"], str) or not claims["sub"]:
                    raise ValueError("Missing subject")
                if not isinstance(claims.get("scope"), str) or "orders:read" not in claims["scope"].split():
                    await JSONResponse({"error": "orders:read permission required"}, status_code=403)(scope, receive, send)
                    return
                scope.setdefault("state", {})["principal"] = claims["sub"]
            except (jwt.PyJWTError, ValueError):
                await JSONResponse(
                    {"error": "Invalid or expired access token"}, status_code=401,
                    headers={"WWW-Authenticate": 'Bearer error="invalid_token"'},
                )(scope, receive, send)
                return
        await self.application(scope, receive, send)


app = JWTGate(mcp.streamable_http_app())
if __name__ == "__main__":
    uvicorn.run(app, host=os.getenv("HOST", "127.0.0.1"), port=int(os.getenv("PORT", "8080")))

Use the official SDK’s mcp.server.fastmcp import shown here. This example is pinned to the MCP Python SDK v1; similarly named packages and newer major versions can have different setup and authorization APIs.

Official reference: MCP Python SDK v1.

Test in an Adios workspace

The MCP workspace verifies the caller before calling the shared API.

First start the API workspace from the main guide. In this language’s manifest, set API_BASE_URL to the API preview origin and use the same development team’s signing secrets.

Terminal
adios ws create --name orders-mcp-dev --json
export MCP_WORKSPACE_ID=YOUR_MCP_WORKSPACE_ID
(cd python && adios sync "$MCP_WORKSPACE_ID")
adios ws run start "$MCP_WORKSPACE_ID" --wait --json
adios ws get "$MCP_WORKSPACE_ID"

Copy the generated MCP preview origin into PUBLIC_ORIGIN in adios.yaml. Sync and restart before testing: the host check must match the preview’s actual hostname. Health probes can run before this update.

Terminal
(cd python && adios sync "$MCP_WORKSPACE_ID")
adios ws run restart "$MCP_WORKSPACE_ID" --wait --json
export MCP_URL=https://YOUR-MCP-PREVIEW-HOST/mcp
curl --fail https://YOUR-MCP-PREVIEW-HOST/healthz
curl -i "$MCP_URL"
# Expected: 401 without a JWT.
export MCP_ACCESS_TOKEN="$(node issue-token.mjs)"
python3.13 -m venv .client-venv
.client-venv/bin/pip install -r python/requirements.txt
.client-venv/bin/python check.py
# Expected result: {'id': 'demo-1001', 'status': 'shipped'}
Inspect the client check
check.pyDownload file
import asyncio
import os

from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client


async def main():
    url = os.getenv("MCP_URL", "http://127.0.0.1:8080/mcp")
    headers = {"Authorization": "Bearer " + os.environ["MCP_ACCESS_TOKEN"]}
    async with streamablehttp_client(url, headers=headers) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            assert any(tool.name == "get_order" for tool in tools.tools)
            result = await session.call_tool("get_order", {"order_id": "demo-1001"})
            assert not result.isError, result
            assert result.structuredContent == {"id": "demo-1001", "status": "shipped"}, result
            print("MCP initialize, tools/list, and get_order passed:", result.structuredContent)


asyncio.run(main())

Test an expired JWT, a wrong audience, and a missing orders:read scope. A demo-client token must not read other-1002. The API returns 404 for that other tenant’s order; MCP returns a tool error.

Review build and runtime logs in the workspace. After source changes, sync and restart the preview; this guide does not assume automatic hot reload.

Terminal
adios ws run stop "$MCP_WORKSPACE_ID"

Keep JWT verification and plan OAuth separately

This implementation verifies operator-issued JWTs. It does not implement user sign-in, consent, or refresh. Keep issuer, audience, signature, expiry, scope, and tenant checks when adding an OAuth provider.

For a user-facing integration, follow the main guide’s resource metadata, token verification, and client onboarding requirements. The included JavaScript resource-server gate is a separate implementation; it is not a drop-in OAuth switch for this server.

Use the SDK’s resource-server authorization support with a token verifier and provider metadata. Carry the verified identity into the API request.

Review the shared OAuth architecture

Deploy this MCP server on Adios

After verifying the workspace preview, follow the main guide to deploy the release database and API. Use the release team’s signing secrets and the release API origin for this MCP service.

Deploy the shared database and API
python/adios.yamlDownload file
name: orders-mcp
region: de
replicas: 1
build_cmd: python -m venv .venv && .venv/bin/pip install -r requirements.txt
start_cmd: .venv/bin/python server.py
runtime:
  name: python@3.13
  port: 8080
  health_path: /healthz
env:
  HOST: 0.0.0.0
  PORT: "8080"
  PUBLIC_ORIGIN: https://mcp.example.com
  API_BASE_URL: https://api.example.com
  API_JWT_SECRET: secret://ORDERS_API_JWT_SECRET
  MCP_JWT_SECRET: secret://ORDERS_MCP_JWT_SECRET

In this manifest, replace API_BASE_URL with the deployed API’s HTTPS origin and PUBLIC_ORIGIN with the MCP service’s actual origin. Keep the secret references, port 8080, and public health path.

If the default MCP hostname is not known yet, obtain it from the first deployment, update PUBLIC_ORIGIN, and deploy again before connecting a client. The host check must match the actual route.

Deploy the MCP service
(cd python && adios up)
adios apps get orders-mcp

adios up promotes a healthy release. It is a deployment command, not a local preview; verify the selected team and target before running it.

Check the hosted MCP tool
export MCP_ACCESS_TOKEN="$(node issue-token.mjs)"
export MCP_URL=https://YOUR-MCP-HOST/mcp
.client-venv/bin/python check.py

Regenerate the demo token after 15 minutes. Confirm the hosted tool call and permission checks before sharing the endpoint.

Continue the shared walkthrough

Once the hosted tool call succeeds, follow the main guide to connect an AI client, check permissions, inspect runtime logs, and operate the service.

Connect and operate the hosted server →

Explore another implementation