Remote MCP Server Template
Allows the MCP server to trust Auth0 as an external OAuth 2.1/OpenID Connect authorization server, validating JWT access tokens, issuer, audience, and per-tool scopes for incoming MCP client requests.
Allows the MCP server to trust Clerk as an external OAuth 2.1/OpenID Connect authorization server, validating JWT access tokens and enforcing MCP tool scopes for authenticated clients.
Allows the MCP server to trust Keycloak as an external OAuth 2.1/OpenID Connect authorization server, validating JWT access tokens and enforcing MCP tool scopes for authenticated clients.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Remote MCP Server Templateshow me all my notes"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
remote-mcp-server-template
A production-ready starting point for a remote MCP server in TypeScript: Streamable HTTP transport, OAuth 2.1 resource-server authorization, stateless so it scales horizontally.
Clone it, point it at your identity provider, replace the example tools with yours. Any MCP client that supports the authorization flow can connect.
What you get
Streamable HTTP transport on Express 5 with
@modelcontextprotocol/sdk1.32, in stateless mode (sessionIdGenerator: undefined). Every POST is self-contained, so any replica can answer it.OAuth 2.1 resource server, following the current MCP authorization spec:
Protected Resource Metadata (RFC 9728) at
/.well-known/oauth-protected-resource.401withWWW-Authenticate: Bearer resource_metadata="...".JWT validation against the issuer's JWKS with
jose: signature, issuer, audience = this server (RFC 8707), expiry.Per-tool scopes, answered with
403andinsufficient_scopeso clients can step up.No token passthrough: the bearer token is dropped after verification.
Origin validation against DNS rebinding, with an allowlist from the environment (and CORS for allowlisted browser clients).
Three example tools:
whoami,notes_list(notes:read),notes_create(notes:write, input validated with zod), backed by an in-memory store isolated per user (sub).Environment config validated with zod,
/healthz, and JSON logs that never contain a token.A Vitest suite that boots the real app on an ephemeral port and forges tokens locally. No network, no real identity provider.
Strict TypeScript, Biome, multi-stage Dockerfile (non-root), CI, Dependabot, and an MCP Registry
server.jsontemplate.
Related MCP server: identity-aware-mcp-server
Quick start
Requires Node.js 22 or newer.
git clone https://github.com/romulorgc/remote-mcp-server-template.git
cd remote-mcp-server-template
npm install
cp .env.example .env # set AUTH_ISSUER to your identity provider
npm run devThe server listens on 127.0.0.1:3000. Without a token it tells clients where to authenticate:
$ curl -i -X POST http://localhost:3000/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer scope="notes:read", resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource"
$ curl http://localhost:3000/.well-known/oauth-protected-resource
{"resource":"http://localhost:3000","authorization_servers":["https://your-tenant.example.com/"],"scopes_supported":["notes:read","notes:write"],"bearer_methods_supported":["header"],"resource_name":"Remote MCP server template"}Point an MCP client at http://localhost:3000/mcp (use the same host as PUBLIC_URL: clients check that the metadata resource matches the URL they connect to). To try it from a browser-based client, add that client's origin to ALLOWED_ORIGINS.
Script | What it does |
| Run with |
| Compile to |
| Run the compiled server |
|
|
| Biome check (lint, format, import order) |
| Apply Biome fixes |
| Vitest |
Connect your identity provider
This server never issues tokens. It trusts an authorization server you already run or rent: Auth0, Keycloak, Clerk, WorkOS, or any other OpenID Connect or OAuth 2.0 provider that issues JWT access tokens. The steps are the same everywhere, only the names change.
Register this server as an API (a "resource"). Use
PUBLIC_URLas its identifier, for examplehttps://mcp.example.com. Providers call it an API, resource server, or audience.Define the scopes
notes:readandnotes:write, plus any of your own.Make access tokens carry this server in
aud. The MCP client sends the RFC 8707resourceparameter; the provider must put that value, or a fixed audience you configure for the API, into the token. If your provider cannot use the URL as audience, setAUTH_AUDIENCEto the value it does use.Allow MCP clients to register. The spec asks authorization servers to support OAuth Client ID Metadata Documents, with Dynamic Client Registration as a fallback. Enable whichever your provider offers, or pre-register the clients you care about. Clients use authorization code with PKCE.
Configure this server. Copy the
issuervalue fromhttps://<your-idp>/.well-known/openid-configurationintoAUTH_ISSUER, character for character (some providers end it with a slash, and the comparison is exact). LeaveAUTH_JWKS_URLempty to discover the key set from the issuer, or set it to skip discovery.Check it. Fetch a token for your user, then call
whoamithrough any MCP client. It returns thesuband scopes the server read from the token.
Scopes are read from the scope claim (RFC 9068, space-separated) or scp (string or array). If your provider puts permissions elsewhere, change readScopes in src/auth/verifier.ts.
Configuration
Variable | Required | Default | Meaning |
| yes | Canonical origin of this server (scheme, host, optional port; no path). Published as the metadata | |
| yes | Issuer of your authorization server. https required, except localhost. | |
| no | discovered | JWKS endpoint. Empty means discover it from the issuer metadata (OIDC discovery, then RFC 8414). |
| no |
| Expected |
| no | none | Comma-separated browser origins allowed to call the server. Empty refuses every request with an |
| no |
| Interface to bind. The Docker image sets |
| no |
| Port to listen on. |
Invalid configuration stops the process at startup with a message that names each bad variable.
Endpoints
Method | Path | Auth | Purpose |
|
| Bearer token | The MCP endpoint (Streamable HTTP, JSON responses) |
|
| Bearer token |
|
|
| none | Protected Resource Metadata (RFC 9728) |
|
| none | Liveness: |
How the server answers, and why:
Situation | Status |
|
No credentials |
|
|
Bad signature, wrong issuer or audience, expired, malformed |
|
|
Valid token, tool needs a scope it lacks |
|
|
|
| none |
Identity provider unreachable (cannot fetch keys) |
| none, so clients do not loop on re-authentication |
A valid token is enough to connect, call initialize and tools/list, and use tools that need no scope. A tool call that needs more is refused at the HTTP layer, before any tool code runs. The scope parameter lists everything that call needs, in one challenge, so a client can re-authorize with the union of its old and new scopes and retry once. A JSON-RPC batch counts as one operation. The scope check lives in src/auth/scope-guard.ts and reads the same tool definitions that register the tools, so the two cannot drift apart. Tool handlers check again as defense in depth.
The scope hierarchy is declared in src/auth/scopes.ts: the umbrella scope notes implies notes:read and notes:write. Empty the map if your scopes are flat.
Add a tool
Create a file under src/mcp/tools/:
// src/mcp/tools/notes-search.ts
import { z } from "zod";
import { SCOPES } from "../../auth/scopes.js";
import { defineTool, jsonResult } from "./define-tool.js";
export const notesSearch = defineTool({
name: "notes_search",
title: "Search notes",
description: "Finds the caller's notes whose title or body contains the query.",
scopes: [SCOPES.notesRead],
inputSchema: {
query: z.string().trim().min(1).max(200).describe("Text to look for, case-insensitive"),
},
outputSchema: {
notes: z.array(
z.object({ id: z.string(), title: z.string(), body: z.string(), createdAt: z.string() }),
),
},
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
async handler({ query }, { principal, notes }) {
const needle = query.toLowerCase();
const own = await notes.list(principal.sub, 100);
const matches = own.filter(
(note) =>
note.title.toLowerCase().includes(needle) || note.body.toLowerCase().includes(needle),
);
return jsonResult({ notes: matches });
},
});Register it in src/mcp/tools/index.ts:
export const tools: readonly ToolDefinition[] = [whoami, notesList, notesCreate, notesSearch];That is all. The tool appears in tools/list, its scopes are enforced by the HTTP guard, args is typed from inputSchema, and the handler receives principal (who is calling, from the token) and the store. Need a new scope? Add it to SCOPES in src/auth/scopes.ts; it is advertised in the metadata automatically.
Rules of thumb:
Take the user from
principal.sub, never from tool arguments.Scope your storage by owner.
NotesStoretakes the owner on every call, so isolation is part of the interface.Do not forward the caller's token to another service. If a tool needs to call one, use credentials of its own or an OAuth token exchange.
Replace
InMemoryNotesStorewith a database before running more than one replica.
Testing
npm testThe tests need no network and no identity provider. test/helpers/idp.ts plays the provider:
it generates an ES256 key pair with
joseand publishes the public half as a JWKS, plus an OIDC discovery document, on an ephemeral port;tokens are forged with
jose'sSignJWT, so a test can set anysub,scope,aud,issorexp, or sign with a key the JWKS does not publish;test/helpers/harness.tsruns the real configuration loader, starts the real app on port0, and connects with the SDK's ownClientandStreamableHTTPClientTransport.
Covered: the 401 challenge, the metadata document (also followed by the SDK client), initialize and tools/list, wrong audience, wrong issuer, expired and unsigned tokens, foreign signing keys, alg: none, insufficient scope (including batches, the scope hierarchy and the scp claim), isolation between two users, origin policy and preflight, JWKS discovery and caching, configuration validation, and that logs never contain a token.
Deploy with Docker
docker build -t remote-mcp-server .
docker run --rm -p 3000:3000 \
-e PUBLIC_URL=https://mcp.example.com \
-e AUTH_ISSUER=https://your-tenant.example.com/ \
remote-mcp-serverThe image is multi-stage on node:24-alpine, installs production dependencies from the lockfile, runs as the unprivileged node user, and has a health check on /healthz.
Put a reverse proxy or load balancer in front to terminate TLS. Serve it over https in production, and set PUBLIC_URL to the address clients actually use. Forward the Authorization header unchanged. Because the server is stateless you can run as many replicas as you need, without sticky sessions, once the notes store is shared.
Publish to the MCP Registry
server.example.json is a template for the official MCP Registry, written against the 2025-12-11 server.json schema. It describes a remote server, so there is no package to publish first.
Copy it to
server.jsonand replace the placeholders:name,title,description(100 characters at most),version, the repository URLs, and theremotes[0].urlof your deployed/mcpendpoint.The
namemust be in a namespace you can prove you own:io.github.<your-user>/<name>with GitHub login, or a reverse-DNS name for a domain you control.Install
mcp-publisher(see the registry quickstart), then:
mcp-publisher validate
mcp-publisher login github
mcp-publisher publishPublish after the server is deployed and reachable: clients that find it in the registry will connect to that URL and start the authorization flow described above.
Security notes
What the template guarantees, and where each guarantee lives:
Tokens are bound to this server.
audmust match, so tokens minted for other APIs, and ID tokens, are refused (src/auth/verifier.ts).No token passthrough. The raw bearer token is discarded after verification. Tools see a
Principal(sub, client id, scopes), never a credential they could forward.Only asymmetric algorithms. RS, PS, ES and EdDSA are accepted.
noneand HMAC algorithms are not, and keys come only from the issuer's JWKS.Exact issuer match. Discovery documents must name the issuer they were fetched for (RFC 8414 section 3.3).
expandsubare required.https for identity endpoints. Issuer, JWKS URL and the discovered
jwks_urimust use https, except for localhost.Key fetches are throttled. Keys are cached, and an unknown
kidtriggers at most one refetch every 30 seconds, so forged tokens cannot flood your identity provider.Scopes are enforced twice, at the HTTP layer and in the handler wrapper, from a single declaration per tool.
Origin validation on every route. A request with an
Originheader outside the allowlist gets403. With an empty allowlist, every browser request is refused. Non-browser clients send noOriginand are unaffected. The server binds to127.0.0.1unless told otherwise.Tenant isolation. Storage is keyed by the token's
sub, and the store API cannot be called without an owner.Quiet logs. One JSON line per request: method, path, status, duration, subject. No headers, no query string, no body, and a redaction filter on sensitive key names as a safety net.
Bounded inputs. 1 MB request bodies, length limits on tool input, a per-user cap on stored notes.
No session surface. There are no sessions to fixate or hijack, and no long-lived streams.
What it deliberately leaves to you:
Rate limiting and abuse controls. Do this at your gateway or proxy.
Revocation. Tokens are validated as JWTs, not introspected. Use short lifetimes.
Persistence. The notes store is in memory and per process.
TLS. Terminate it in front of the server.
The authorization server. This is a resource server only. It publishes where to authenticate and checks what comes back.
Protocol versions
The server is built on @modelcontextprotocol/sdk 1.32, which implements protocol revisions up to 2025-11-25 and the initialize handshake. The newest specification revision (2026-07-28) removes protocol-level sessions and that handshake. A request that declares the newer version gets a 400 listing the versions this server supports, which is what lets clients fall back. The authorization behavior here (resource metadata, challenges, audience binding, scope challenges) follows the current authorization spec and does not depend on that choice.
Project layout
src/
index.ts entry point: config, listen, graceful shutdown
app.ts Express app: middleware order, routes, stateless MCP handler
config.ts environment schema (zod)
logger.ts JSON logger with redaction
auth/
challenge.ts 401 for requests without credentials
verifier.ts JWT access token verification (jose)
jwks.ts JWKS resolution and issuer discovery
scopes.ts scopes, hierarchy, WWW-Authenticate builder
scope-guard.ts per-tool scope enforcement (403 insufficient_scope)
principal.ts the authenticated caller, as tools see it
http/
origin.ts Origin allowlist and CORS
metadata.ts Protected Resource Metadata (RFC 9728)
jsonrpc.ts JSON-RPC error responses
mcp/
server.ts builds an McpServer with every tool registered
tools/ tool definitions: whoami, notes_list, notes_create
notes/store.ts per-user notes store (in memory)
test/ Vitest suites and the test identity providerLicense
MIT © 2026 Rômulo Carvalho
This server cannot be deployed
Maintenance
Related MCP Connectors
Self-hosted federated MCP gateway: one OAuth 2.1 MCP server in front of N apps, user-level scopes.
Hosted MCP server with managed OAuth for 15+ toolkits: Google Workspace, Fitbit, Oura, Kalshi, etc.
- StytchOAuthdev.stytch.mcp
The Stytch MCP server is a reference implementation that demonstrates remote MCP server authentication and authorization using Stytch Connected Apps. It provides OAuth 2.1-compliant authorization (including PKCE), Dynamic Client Registration, and validates Stytch-issued access tokens to enable AI agents to securely interact with external services through permissioned access, supporting scopes like openid, email, profile, and manage:project_data.
An authenticated remote MCP server for user-owned devices and one-shot capability invocation.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables MCP client interaction via streamable HTTP, providing example tools (echo, getPostsByUser) and resources (posts, users) with pluggable authentication providers.6MIT
- FlicenseNot gradedqualityDmaintenanceA production-ready MCP server that authenticates agents via OAuth 2.1 Bearer tokens, validates JWTs with JWKS, enforces tool-level scopes and roles, and logs the full delegation chain.-
- AlicenseNot gradedqualityAmaintenanceA production-minded MCP server kit that provides OAuth 2.1/PKCE authentication, rate limiting, scope-gated tools, and two-phase confirmation for building secure MCP servers with custom tools, identity, and storage.176 npmMIT
- FlicenseNot gradedqualityCmaintenanceEnables developers to build MCP servers with registry-managed tool metadata, runtime hot-reloading, pluggable authentication and authorization, per-audience tool views, and resilient stateless operation.-