MCP Server with OAuth
This guideline shows how to build a Model Context Protocol (MCP) server that authenticates MCP clients (Claude, Cursor, VS Code) with OAuth 2.1, using only ttoss packages. No external auth framework is required: @ttoss/http-server provides the Koa runtime, @ttoss/http-server-mcp provides both halves of MCP authorization, and @ttoss/auth-core provides the token primitives. Your app keeps its own user model, signing keys, and login UI — ttoss owns only the protocol mechanics.
It is the MCP-specific application of two general patterns: issuing tokens (OAuth Authorization Server) and consuming a third party's tokens (OAuth Client).
The two halves
OAuth for MCP splits into two independent responsibilities. A server can play either role, or both.
The resource server is the MCP endpoint itself: it verifies the Bearer token on every request and runs tools. The authorization server issues those tokens through the standard /authorize → /token flow. If you authenticate against an existing provider (Amazon Cognito, Auth0), you only need the resource-server half. If your app issues its own tokens, you add the authorization-server half too.
Resource server: verifying tokens
createMcpRouter gates requests through its auth option. Invalid or missing tokens get 401 Unauthorized before any tool runs — except for the MCP lifecycle methods initialize and tools/list, which stay public so a client can discover the server before it has a token (see Client discovery).
Against Amazon Cognito
Pass cognitoUserPool and the router builds a CognitoJwtVerifier (from @ttoss/auth-core) internally:
import { App, bodyParser, cors } from '@ttoss/http-server';
import { createMcpRouter, McpServer, z } from '@ttoss/http-server-mcp';
const mcpServer = new McpServer({ name: 'my-mcp-server', version: '1.0.0' });
mcpServer.registerTool(
'get-weather',
{ description: 'Get weather', inputSchema: { location: z.string() } },
async ({ location }) => ({
content: [{ type: 'text', text: `Weather in ${location}: Sunny` }],
})
);
const mcpRouter = createMcpRouter(mcpServer, {
auth: {
cognitoUserPool: {
userPoolId: process.env.COGNITO_USER_POOL_ID!,
clientId: process.env.COGNITO_CLIENT_ID!,
},
// Advertise where clients should obtain tokens (OAuth discovery).
resourceServerUrl: 'https://mcp.example.com',
authorizationServerUrl: process.env.COGNITO_ISSUER_URL!,
},
});
const app = new App();
app.use(cors());
app.use(bodyParser());
app.use(mcpRouter.routes());
app.listen(3000);
Against your own tokens
When your app signs its own JWTs with @ttoss/auth-core, verify them with a custom verifyToken. The contract is minimal: resolve with an identity payload, or throw to reject.
import { verifyJwt } from '@ttoss/auth-core';
import { createMcpRouter } from '@ttoss/http-server-mcp';
const mcpRouter = createMcpRouter(mcpServer, {
auth: {
verifyToken: async (token) => {
const payload = verifyJwt({ token, secret: process.env.JWT_SECRET! });
if (!payload) throw new Error('Invalid token');
return payload;
},
resourceServerUrl: 'https://mcp.example.com',
authorizationServerUrl: 'https://api.example.com',
},
});
Opaque API tokens work the same way — hash the presented token with @ttoss/auth-core and look it up in your database, throwing when it is missing or revoked. See the @ttoss/http-server-mcp README for the opaque-token recipe and the getIdentity() / checkScopes() helpers used inside tool handlers.
Client discovery
The MCP authorization spec requires two behaviors so clients like Claude and Cursor can bootstrap OAuth without being pre-configured, and createMcpRouter handles both. The lifecycle methods initialize and tools/list bypass verification so the client can discover the server before authenticating — override the set with publicMethods (pass [] to require a token for every method). And when resourceMetadataUrl is set, a 401 advertises the RFC 9728 protected-resource document via WWW-Authenticate: Bearer resource_metadata="…", pointing the client at the metadata that names the authorization server.
const mcpRouter = createMcpRouter(mcpServer, {
auth: {
cognitoUserPool: { userPoolId: '...', clientId: '...' },
resourceServerUrl: 'https://mcp.example.com',
authorizationServerUrl: process.env.COGNITO_ISSUER_URL!,
// Emit RFC 9728 discovery on 401.
resourceMetadataUrl:
'https://mcp.example.com/.well-known/oauth-protected-resource',
// Set publicMethods: [] when you need OAuth clients to authenticate
// before anything else (see note below). Defaults to ['initialize', 'tools/list'].
publicMethods: [],
},
});
Setting both resourceServerUrl and authorizationServerUrl also serves that metadata document at /.well-known/oauth-protected-resource, completing the discovery chain the WWW-Authenticate header points to.
One document per path. oauthServer({ resource }) serves /.well-known/oauth-protected-resource too, so a deployment that hosts both halves ends up with two routers answering the same path — whichever mounted first wins, and nothing breaks because the bodies agree, which is exactly why nobody notices there are now two sources for one contract. When the authorization server is in the same deployment, set only resourceMetadataUrl on the MCP router and let the authorization server serve the document.
Error envelopes swallow the 401. The router rejects with ctx.throw(401, 'Unauthorized', { headers }), and an app whose catch-all error middleware recognizes only its own error class turns that into a 500 with no WWW-Authenticate header. The client then gets an opaque server error where it expected the pointer to the authorization server, so discovery silently never starts and it reads like a client bug. Run caught values through toHttpError and applyHttpErrorHeaders from @ttoss/http-server before falling back to 500.
publicMethods and the OAuth flow. With the default ['initialize', 'tools/list'], an OAuth-aware client (Claude connector, Cursor) receives 200 on initialize and concludes the server is public — it never starts the OAuth PKCE flow, while notifications/initialized still returns 401, silently breaking the handshake. The visible symptom is "connected, no tools available, no sign-in prompt". Setting publicMethods: [] makes initialize return 401 + WWW-Authenticate, which is what triggers the client's OAuth discovery and login. Use the default only when auth is handled outside the client-initiated OAuth flow (e.g. API keys or tokens injected by a gateway); set publicMethods: [] whenever you want the client to authenticate itself before any other interaction.
Authorization server: issuing tokens
To make your server first-party — so an MCP client discovers it, registers itself, and runs the full login flow against it — mount oauthServer() from @ttoss/http-server-auth. It serves the discovery, /authorize, /token, and /register endpoints that MCP clients auto-discover, and you pair it with the verifyToken resource server above so one deployment both issues and verifies its tokens (set scopesSupported: ['mcp:access']).
These are general OAuth 2.1 primitives, not MCP-specific — the runner-agnostic engine is createOAuthHandlers in @ttoss/auth-core. The full setup — discovery, dynamic client registration, the authorize/PKCE flow, the token grants, and the ttoss-vs-app responsibility split — lives in the OAuth Authorization Server guideline.
Enforcing scopes
Gate the whole endpoint with requiredScopes (returns 403 before any tool runs), or call checkScopes() inside individual handlers for per-tool control:
createMcpRouter(mcpServer, {
auth: {
cognitoUserPool: { userPoolId: '...', clientId: '...' },
requiredScopes: ['mcp:access'],
},
});
requiredScopes and first-party credentials. An endpoint that accepts both OAuth tokens and the app's own API keys or session JWTs has a problem: those credentials carry no scope claim, so the scope check throws verifyToken returned no scope/scopes but requiredScopes is set and the caller gets a 403 for a claim it can never present. Report first-party credentials as holding the required scope in verifyToken — they already carry the user's full authority:
verifyToken: async (token) => {
const principal = await authenticateBearer(token);
return {
sub: principal.userId,
// Session JWTs and API keys predate OAuth and hold the user's full
// authority; without this they fail a scope check they cannot satisfy.
scope: (principal.scopes ?? ['mcp:access']).join(' '),
};
},
Choosing your setup
| You authenticate against… | Use |
|---|---|
| Amazon Cognito | createMcpRouter({ auth: { cognitoUserPool } }) |
| Another OAuth provider | createMcpRouter({ auth: { verifyToken } }) with jose |
| Tokens your app issues | oauthServer + createMcpRouter({ auth: { verifyToken } }) |
In every case the only runtime dependencies are ttoss packages. Refer to the @ttoss/http-server-mcp and @ttoss/auth-core documentation for the complete API surface.