Skip to content

Serve an MCP server ​

How-to. Expose an application's use cases as Model Context Protocol tools, behind the same authentication as its API.

Install @btravstack/mcp-server and @modelcontextprotocol/sdk beside the HTTP runtime. Build the SDK's own McpServer in server; mcp() serves it under /mcp:

ts
import { defineAuth, protectedResource } from "@btravstack/http-server";
import { jwtAuthenticator } from "@btravstack/http-server/jwt";
import { McpModule, mcp } from "@btravstack/mcp-server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { OkAsync } from "unthrown";

type Caller = { readonly tenantId: string };

const auth = defineAuth({
  authenticators: {
    user: jwtAuthenticator<Caller>()({
      scopes: ["orders:read"],
      principal: (claims) =>
        typeof claims["tenant"] === "string" ? { tenantId: claims["tenant"] } : undefined,
    }),
  },
});

const answerer = mcp(auth, {
  requires: [{ user: ["orders:read"] }],
  server: ({ principal, toolResult }) => {
    const server = new McpServer({ name: "orders", version: "1.0.0" });
    server.registerTool(
      "current_tenant",
      { description: "The tenant this token acts for", inputSchema: {} },
      () => toolResult(OkAsync(principal.tenantId)),
    );
    return server;
  },
});

export const McpApi = McpModule("McpApi")({
  answerer,
  provides: [...protectedResource({ scopes: ["orders:read"] })],
});
  • McpModule is the deployment root, as GraphqlModule is GraphQL's: it composes the listener, the answerer's authenticators and its declared request modules. To serve MCP beside an oRPC router instead, put the answerer and its authenticators in that HttpModule's provides — when the answerer declares no units of its own: HttpModule binds the kinds its router and fragments declare, so an MCP answerer with units for another scheme belongs in its own McpModule deployment.
  • Each request gets its own server, over a stateless transport answering JSON, so any replica answers any request and nothing outlives the call.
  • server is handed the call's context: principal, what requires resolved; unit, the services of the request's unit, declared with units and unit exactly as for GraphQL; the request's signal; and toolResult.
  • toolResult answers a tool from a Result. Ok is text for a string, structured content beside its JSON text when that JSON is an object, and its JSON text alone for anything else — an array or a number included. Err is a ToolRefused your triage minted — an exhaustive mapErrCases over the use case's errors — and its text is the tool error the model reads. A defect, or a value JSON cannot encode, is reported to Observers and answered with a fixed text, so its cause never reaches the model.
  • A refused caller gets a bare 401 or 403.protectedResource() adds the challenge that tells an MCP client which authorization server to sign in with. A verified Bearer token lacking the mount's scope gets error="insufficient_scope" and one sufficient required scope alternative; an API-key refusal or a tool's business refusal gets no OAuth scope error.
  • The body is bounded by HTTP_BODY_LIMIT, as for every answerer.

Serve one resource per tenant domain ​

When each tenant reaches the server on a domain of its own — https://tenant-a.example/mcp, https://tenant-b.example/mcp — each URL is a protected resource of its own, and its metadata must name it exactly. Compose protectedResourcePerHost() in place of protectedResource(), and answer which hosts you serve from your own tenancy data with ServedHosts:

ts
import { Provider } from "@btravstack/di";
import { ServedHosts, protectedResourcePerHost } from "@btravstack/http-server";

export const TenantMcpApi = McpModule("TenantMcpApi")({
  answerer,
  imports: [TenantsModule],
  provides: [
    ...protectedResourcePerHost({ path: "/mcp", scopes: ["orders:read"] }),
    Provider(ServedHosts)({
      inject: { tenants: Tenants },
      sync: ({ tenants }) => tenants.servesDomain,
    }),
  ],
});
  • The resource is the request's own origin plus path: a client calling https://tenant-a.example/mcp is challenged with, and reads, metadata naming exactly that URL. ServedHosts is asked with the host lowercase and its default port dropped (tenant-a.example), so store verified domains that way.
  • A host you do not serve gets nothing: its metadata URL is a 404 and its 401 carries no challenge, whatever Host a caller forges. A domain verified at run time is served from its next request, with no restart.
  • ServedHosts is asked on every request to /mcp and to its metadata, before the answerer runs, and on no other. Back it with a cache when the answer comes from a database, and pass its second argument, the request's AbortSignal, to the query: a client that leaves mid-lookup is not waited for, and the signal aborts.
  • The path comes from HTTP_RESOURCE_PATH unless pinned, as here; the issuers from HTTP_RESOURCE_AUTHORIZATION_SERVERS.
  • jwtAuthenticator checks one audience. A client sending RFC 8707's resource asks for a token whose audience is its tenant's own URL, which one configured audience refuses for every other tenant: have the authorization server mint the same audience for every tenant's resource.

Prompts and resources are the SDK's too: server.registerPrompt and server.registerResource work unchanged. What the server would ask of the client — sampling, elicitation, roots — is not available: the server is stateless and answers JSON, so it neither keeps the capabilities the client declared at initialize nor holds a stream to send a request on. For the same reason a notifications/cancelled reaches no running call; a client cancels by dropping its request, which aborts signal, so a long-running tool should read it.

Released under the MIT License.