Adios
← MCP Server Hosting

Language walkthrough

MCP server in JavaScript

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

Node.js 24 · 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 JavaScript 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 JavaScript

In javascript/, install dependencies with npm ci. Register get_order with a Zod input schema, verify the client JWT, call the API with a five-second timeout, and return text plus structured data. A fresh transport handles each POST so there is no shared MCP session state.

Dependencies and start command
javascript/package.jsonDownload file
{
  "name": "adios-orders-mcp-example",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node server.mjs"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "1.32.1",
    "express": "^5.1.0",
    "jose": "^6.1.0",
    "zod": "^4.1.0"
  }
}
javascript/server.mjsDownload file
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
import { jwtVerify, SignJWT } from "jose";
import { z } from "zod";
import { installOAuthGate } from "./oauth.mjs";

const apiBase = new URL(process.env.API_BASE_URL || "http://127.0.0.1:8081");
const apiSecret = process.env.API_JWT_SECRET;
const mcpSecret = process.env.MCP_JWT_SECRET;
const authMode = process.env.MCP_AUTH_MODE || "jwt";
if (!["jwt", "oauth"].includes(authMode))
  throw new Error("Unknown MCP_AUTH_MODE");
if (
  !apiSecret ||
  apiSecret.length < 32 ||
  (authMode === "jwt" && (!mcpSecret || mcpSecret.length < 32))
) {
  throw new Error(
    "Set distinct API_JWT_SECRET and MCP_JWT_SECRET values of at least 32 characters",
  );
}
if (apiSecret === mcpSecret)
  throw new Error("Use separate API and MCP credentials");
const apiKey = new TextEncoder().encode(apiSecret);
const origin = new URL(process.env.PUBLIC_ORIGIN || "http://127.0.0.1:8080");
const app = express();
app.get("/healthz", (_req, res) => res.json({ ok: true }));
app.use("/mcp", (req, res, next) => {
  if (
    ![origin.host, "127.0.0.1:8080", "localhost:8080"].includes(
      req.headers.host,
    )
  ) {
    return res.status(403).json({ error: "Invalid host" });
  }
  if (req.headers.origin && req.headers.origin !== origin.origin) {
    return res.status(403).json({ error: "Invalid origin" });
  }
  next();
});
if (authMode === "oauth") {
  installOAuthGate(app, origin.origin);
} else {
  app.use("/mcp", async (req, res, next) => {
    try {
      const match = /^Bearer (\S+)$/.exec(req.headers.authorization || "");
      if (!match) throw new Error("Missing token");
      const { payload } = await jwtVerify(
        match[1],
        new TextEncoder().encode(mcpSecret),
        {
          issuer: "orders-demo",
          audience: "orders-mcp",
          algorithms: ["HS256"],
          requiredClaims: ["sub", "exp"],
        },
      );
      if (typeof payload.sub !== "string" || !payload.sub)
        throw new Error("Missing subject");
      if (
        typeof payload.scope !== "string" ||
        !payload.scope.split(" ").includes("orders:read")
      ) {
        return res
          .status(403)
          .json({ error: "orders:read permission required" });
      }
      res.locals.principal = { subject: payload.sub };
      next();
    } catch {
      res.set("WWW-Authenticate", 'Bearer error="invalid_token"');
      res.status(401).json({ error: "Invalid or expired access token" });
    }
  });
}
app.use(express.json({ limit: "64kb" }));

function createServer(subject) {
  const server = new McpServer({ name: "orders-mcp", version: "1.0.0" });
  server.registerTool(
    "get_order",
    {
      description: "Read an order's shipping status from the Orders API.",
      inputSchema: { order_id: z.string().regex(/^[a-zA-Z0-9-]{1,64}$/) },
      annotations: { readOnlyHint: true },
    },
    async ({ order_id }) => {
      try {
        // A new API-audience JWT carries the verified caller, never tool input.
        const apiToken = await new SignJWT({ scope: "orders:read" })
          .setProtectedHeader({ alg: "HS256", typ: "JWT" })
          .setIssuer("orders-demo")
          .setAudience("orders-api")
          .setSubject(subject)
          .setIssuedAt()
          .setExpirationTime("5m")
          .sign(apiKey);
        const response = await fetch(new URL(`/orders/${order_id}`, apiBase), {
          headers: { Authorization: `Bearer ${apiToken}` },
          signal: AbortSignal.timeout(5000),
          redirect: "error",
        });
        if (!response.ok) throw new Error("API lookup failed");
        const order = z
          .object({ id: z.string(), status: z.string() })
          .parse(await response.json());
        return {
          content: [{ type: "text", text: JSON.stringify(order) }],
          structuredContent: order,
        };
      } catch {
        return {
          isError: true,
          content: [
            {
              type: "text",
              text: "Could not read this order. Check its ID and your API access.",
            },
          ],
        };
      }
    },
  );
  return server;
}

app.post("/mcp", async (req, res) => {
  const server = createServer(res.locals.principal.subject);
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
    enableJsonResponse: true,
  });
  res.on("close", () => {
    void transport.close();
    void server.close();
  });
  try {
    await server.connect(transport);
    await transport.handleRequest(req, res, req.body);
  } catch {
    if (!res.headersSent) res.status(500).json({ error: "MCP request failed" });
  }
});
// This stateless example does not keep an SSE stream or session to delete.
app.get("/mcp", (_req, res) => res.sendStatus(405));
app.delete("/mcp", (_req, res) => res.sendStatus(405));
app.listen(Number(process.env.PORT || 8080), process.env.HOST || "127.0.0.1");

The imported oauth.mjs is included in the download. The default mode validates a signed JWT; the OAuth section in this guide explains how to switch modes. Keep the host and origin checks, and set PUBLIC_ORIGIN to the actual public origin when hosting it.

Official reference: MCP JavaScript server SDK.

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 javascript && 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 javascript && 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"

Add OAuth to the JavaScript server

Optional JavaScript OAuth gate

The downloadable JavaScript server includes this resource-server gate. It verifies RS256 JWT access tokens using your provider’s public JWKS and publishes MCP resource metadata. It requires an audience equal to the full MCP URL and a space-separated scope claim containing orders:read.

javascript/oauth.mjsDownload file
import { createRemoteJWKSet, jwtVerify } from "jose";

// This is a resource server, not an OAuth authorization server.
// Configure a provider that issues RS256 JWT access tokens for this resource.
export function installOAuthGate(app, publicOrigin) {
  const issuer = process.env.OAUTH_ISSUER;
  const jwksURL = process.env.OAUTH_JWKS_URL;
  if (
    !issuer ||
    !jwksURL ||
    new URL(issuer).protocol !== "https:" ||
    new URL(jwksURL).protocol !== "https:"
  ) {
    throw new Error(
      "Set HTTPS OAUTH_ISSUER and OAUTH_JWKS_URL from your provider",
    );
  }
  const resource = `${publicOrigin}/mcp`;
  const metadataURL = `${publicOrigin}/.well-known/oauth-protected-resource/mcp`;
  const keys = createRemoteJWKSet(new URL(jwksURL));
  app.get("/.well-known/oauth-protected-resource/mcp", (_req, res) => {
    res.json({
      resource,
      authorization_servers: [issuer],
      scopes_supported: ["orders:read"],
      bearer_methods_supported: ["header"],
    });
  });
  app.use("/mcp", async (req, res, next) => {
    const match = /^Bearer (\S+)$/.exec(req.headers.authorization || "");
    if (!match) {
      res.set(
        "WWW-Authenticate",
        `Bearer resource_metadata="${metadataURL}", scope="orders:read"`,
      );
      return res.status(401).json({ error: "Sign in through your MCP client" });
    }
    try {
      const { payload } = await jwtVerify(match[1], keys, {
        issuer,
        audience: resource,
        algorithms: ["RS256"],
        requiredClaims: ["sub", "exp"],
      });
      if (typeof payload.sub !== "string" || !payload.sub)
        throw new Error("Missing subject");
      const scopes =
        typeof payload.scope === "string" ? payload.scope.split(" ") : [];
      if (!scopes.includes("orders:read")) {
        res.set(
          "WWW-Authenticate",
          `Bearer error="insufficient_scope", scope="orders:read", resource_metadata="${metadataURL}"`,
        );
        return res
          .status(403)
          .json({ error: "orders:read permission required" });
      }
      // The handler carries this identity in a distinct API-audience JWT.
      res.locals.principal = { subject: payload.sub, scopes };
      next();
    } catch {
      res.set(
        "WWW-Authenticate",
        `Bearer error="invalid_token", resource_metadata="${metadataURL}"`,
      );
      res.status(401).json({ error: "Invalid or expired access token" });
    }
  });
}

Configure the provider first: register this resource, allow its scope, support authorization-code PKCE and arrange client onboarding. Use the exact issuer and JWKS URLs from provider metadata. This middleware does not implement login, consent, registration or token issuance. Opaque tokens need introspection; different signing algorithms or scope claims need a corresponding verifier.

Replace demo JWT settings in javascript/adios.yaml
env:
  HOST: 0.0.0.0
  PORT: "8080"
  PUBLIC_ORIGIN: https://YOUR-MCP-HOST
  API_BASE_URL: https://YOUR-API-HOST
  API_JWT_SECRET: secret://ORDERS_API_JWT_SECRET
  MCP_AUTH_MODE: oauth
  OAUTH_ISSUER: https://YOUR-AUTHORIZATION-SERVER
  OAUTH_JWKS_URL: https://YOUR-AUTHORIZATION-SERVER/YOUR-JWKS-PATH
# Remove MCP_JWT_SECRET in OAuth mode.

The gate records the verified subject in res.locals.principal. The handler carries it in a newly signed API-audience JWT, and the API looks up its tenant in principals. Provision your provider’s real subjects in that table; the fixture only includes demo-client and other-client. Keep identity provisioning controlled, and never let a tool caller assign their own tenant.

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
javascript/adios.yamlDownload file
name: orders-mcp
region: de
replicas: 1
build_cmd: npm ci
start_cmd: npm start
runtime:
  name: node@24
  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 javascript && 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