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:
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"] })],
});McpModuleis the deployment root, asGraphqlModuleis 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 itsauthenticatorsin thatHttpModule'sprovides— when the answerer declares nounitsof its own:HttpModulebinds the kinds its router and fragments declare, so an MCP answerer with units for another scheme belongs in its ownMcpModuledeployment.- Each request gets its own server, over a stateless transport answering JSON, so any replica answers any request and nothing outlives the call.
serveris handed the call's context:principal, whatrequiresresolved;unit, the services of the request's unit, declared withunitsandunitexactly as for GraphQL; the request'ssignal; andtoolResult.toolResultanswers a tool from aResult.Okis 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.Erris aToolRefusedyour triage minted — an exhaustivemapErrCasesover the use case's errors — and itstextis the tool error the model reads. A defect, or a value JSON cannot encode, is reported toObserversand answered with a fixed text, so its cause never reaches the model.- A refused caller gets a bare
401or403.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 getserror="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:
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 callinghttps://tenant-a.example/mcpis challenged with, and reads, metadata naming exactly that URL.ServedHostsis 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
404and its401carries no challenge, whateverHosta caller forges. A domain verified at run time is served from its next request, with no restart. ServedHostsis asked on every request to/mcpand 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'sAbortSignal, to the query: a client that leaves mid-lookup is not waited for, and the signal aborts.- The path comes from
HTTP_RESOURCE_PATHunless pinned, as here; the issuers fromHTTP_RESOURCE_AUTHORIZATION_SERVERS. jwtAuthenticatorchecks one audience. A client sending RFC 8707'sresourceasks 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.