· 10 min read
Securing an MCP v2 Server with OAuth and Keycloak
What changes for OAuth when you move an MCP server to v2. The MCP TypeScript v2 SDK moves the auth helpers into @modelcontextprotocol/express and surfaces auth info via ctx.http.authInfo — your Keycloak setup stays exactly the same.
Copy a command, then paste it into the command palette (Ctrl K to open).
Introduction
If you already secured an MCP server with OAuth and Keycloak, here is the good news: v2 barely touches it.
The Keycloak side is completely unchanged. Same realm, same client, same test user, same redirect URIs, same mcp:tools scope, same Dynamic Client Registration setup. Not one screenshot in the v1 guide is out of date, because OAuth 2.1 and OIDC did not change. MCP v2 changed how the TypeScript SDK is packaged, not how tokens work.
So this is a short article on purpose. It covers only what actually moved.
What you'll learn:
- Which imports move, and where they move to
- How auth info reaches your tool handlers now that
extrabecamectx - Why OAuth is no longer tied to the "stateful" template
- Why three guarded routes collapse into one
Prerequisites: a working Keycloak realm from the v1 guide, and Node.js 22.19 or later.
Don't have the realm set up yet? You don't have to leave this page to get it. Open the Agentailor agent (the terminal widget in the corner) and ask for the setup steps directly:
Give me the step-by-step Keycloak setup from the OAuth for MCP Servers
guide: realm, client, test user, and the mcp:tools scope. Setup steps
only, no explanation.
That returns the commands and console steps in order, so you can follow along in the widget and come back here for the v2 delta.
NOTE
The Node floor is higher than v1 required, and the v2 SDK is not the reason. It comes from @modelcontextprotocol/inspector v2, which the scaffold installs as a dev dependency. On Node 20, npm install fails on the engine check. If you already read the v2 quickstart, this is the same constraint.
Scaffold a v2 Server with OAuth
One command, with OAuth switched on:
npx @agentailor/[email protected] --name=my-secure-mcp-server --oauth
cd my-secure-mcp-server
npm install
The pin matters. v0.7.0 is the first release that emits MCP v2 and v0.6.2 is the last that emits v1, so anything from 0.7.x gives you a v2 server. This guide pins 0.7.1 because of an auth fix worth having. Passing --name also puts the CLI in non-interactive mode, so it generates immediately instead of prompting.
You get the same three files as v1:
my-secure-mcp-server/
├── src/
│ ├── server.ts # tools, prompts, resources
│ ├── index.ts # Express app and the guarded route
│ └── auth.ts # OAuth middleware
├── package.json
└── .env.example
.env.example is unchanged from v1, so your existing Keycloak values drop straight in:
PORT=3000
OAUTH_ISSUER_URL=http://localhost:8080/realms/mcp-realm
OAUTH_AUDIENCE= #leave empty for Keycloak
OAuth is no longer tied to a template
In v1 the CLI gated OAuth behind the Stateful template. Its own help text said so: --oauth was documented as (sdk+stateful only). That made sense then, because only the stateful template had the session apparatus the auth flow was wired into.
v2 collapsed the stateless/stateful distinction entirely, so the gate is gone. On v0.7.0 the flag reads (sdk HTTP only), and --oauth works with the default template. OAuth is now orthogonal to everything except the transport: it is HTTP-only, because a stdio server has no HTTP request to carry a bearer token.
If you follow the v1 guide's instruction to pick "Stateful," nothing breaks. --template is accepted but has no effect on SDK projects; both values generate the same server.
What Actually Changed in auth.ts
Here is the whole diff between the v1 and v2 generated auth.ts, and it is smaller than you would expect. Three import lines and one type annotation.
The helpers move out of deep @modelcontextprotocol/sdk subpaths and into the new packages:
// v1
import {
mcpAuthMetadataRouter,
getOAuthProtectedResourceMetadataUrl,
} from '@modelcontextprotocol/sdk/server/auth/router.js'
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js'
import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth.js'
// v2
import {
mcpAuthMetadataRouter,
getOAuthProtectedResourceMetadataUrl,
requireBearerAuth,
} from '@modelcontextprotocol/express'
import type { OAuthMetadata, OAuthTokenVerifier } from '@modelcontextprotocol/server'
Three v1 import statements collapse into two. The runtime helpers all live in @modelcontextprotocol/express, and the types come from @modelcontextprotocol/server.
The one other change is that OAuthTokenVerifier is now exported as a type you can annotate with, which v1 did not offer:
// v1
const tokenVerifier = { ... }
// v2
const tokenVerifier: OAuthTokenVerifier = { ... }
That is the entire migration for this file. The JWKS fetching, the jwtVerify call, the OIDC discovery at startup, the scope parsing: all identical. requireBearerAuth is called with exactly the same options in both versions:
export const authMiddleware: RequestHandler = requireBearerAuth({
verifier: tokenVerifier,
requiredScopes: [],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
})
The Guarded Route Collapses to One Line
This is where v2 shows its real gain, and it is inherited from the stateless redesign rather than from anything auth-specific.
In v1, every MCP method needed its own guarded route, because the session apparatus dispatched by HTTP verb:
app.post('/mcp', authMiddleware, async (req: Request, res: Response) => {
/* session lookup, isInitializeRequest branching, transport creation */
})
app.get('/mcp', authMiddleware, async (req: Request, res: Response) => {
/* SSE stream for this session */
})
app.delete('/mcp', authMiddleware, async (req: Request, res: Response) => {
/* terminate the session */
})
In v2 the handler owns method dispatch internally, so there is one route and the middleware wraps it once:
const handler = createMcpHandler(() => getServer())
const node = toNodeHandler(handler)
app.all('/mcp', authMiddleware, (req: Request, res: Response) => void node(req, res, req.body))
Three guarded routes become one. There is no way to accidentally protect POST and forget DELETE, which was a real footgun in v1.
The v2 quickstart covers the stateless rewrite in full. The only auth-relevant detail is the middleware position: authMiddleware sits between the path and the handler, exactly as it did in v1.
How Auth Info Reaches Your Handlers
In v1 you read auth info from the handler's extra parameter. In v2 that parameter is ctx, and the auth info sits under an optional http key:
server.registerTool(
'whoami',
{
description: 'Returns the authenticated client id.',
inputSchema: z.object({}),
},
async (_args, ctx) => {
const clientId = ctx.http?.authInfo?.clientId ?? 'anonymous'
return { content: [{ type: 'text', text: `You are ${clientId}` }] }
}
)
The generated auth.ts carries a comment that spells out the mechanism, and it is worth internalizing:
requireBearerAuthattaches the validatedAuthInfotoreq.auth, whichtoNodeHandlerforwards to MCP handlers asctx.http.authInfo.
That is the whole chain: Express middleware validates the JWT and writes to req.auth; toNodeHandler bridges the Express request into the MCP handler and republishes it as ctx.http.authInfo.
Read http defensively with ?.. It is optional because the same server code can run over stdio, where there is no HTTP request behind the call and no bearer token to validate.
AGENT BRIEFINGS
What actually matters for building and scaling AI agents in production — and what's just hype. Straight from the work, no filler.
Dependencies
OAuth adds exactly one runtime dependency to the v2 baseline: jose, for JWT verification against Keycloak's JWKS endpoint. The full runtime dependency list with --oauth:
{
"dependencies": {
"@modelcontextprotocol/server": "^2.0.0",
"@modelcontextprotocol/express": "^2.0.0",
"@modelcontextprotocol/node": "^2.0.0",
"express": "^5.2.1",
"hono": "^4.11.5",
"zod": "^4.4.3",
"dotenv": "^17.4.2",
"jose": "^6.2.3"
}
}
jose and dotenv were both there in v1 too. The change is the three @modelcontextprotocol/* packages replacing the single @modelcontextprotocol/sdk, plus hono arriving as an optional peer dependency of @modelcontextprotocol/node.
The old @modelcontextprotocol/sdk package stays on 1.x and is a different package, not an older version of these. Do not try to pin it to 2.x.
Verifying It Works
Start Keycloak and your server as in the v1 guide. A healthy startup logs the OIDC discovery it performed:
[auth] Validating OAuth configuration for issuer: http://localhost:8080/realms/mcp-realm
[auth] Successfully fetched OIDC discovery document
[auth] JWKS endpoint is accessible
[auth] OAuth configuration validated successfully
MCP HTTP Server listening on port 3000
OAuth metadata available at http://localhost:3000/.well-known/oauth-protected-resource
An unauthenticated call should be refused with a 401 and, importantly, a WWW-Authenticate header pointing clients at your metadata document. That header is what lets an MCP client discover where to authenticate:
curl -i -X POST http://localhost:3000/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token",
error_description="Missing Authorization header",
resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource"
And the metadata document itself, which the middleware serves for you:
curl http://localhost:3000/.well-known/oauth-protected-resource
{
"resource": "http://localhost:3000/",
"authorization_servers": ["http://localhost:8080/realms/mcp-realm"],
"scopes_supported": ["mcp:tools"],
"resource_name": "MCP Server"
}
NOTE
If the server logs a port other than the one you expect, it fell back to a random port because 3000 was taken. The npm run inspect script hardcodes 3000, so it will point at the wrong place. Check the startup log for the real port.
With a valid token, the call succeeds and the identity reaches your handler. Calling the whoami tool from above returns:
You are mcp-server-client | scopes: openid,mcp:tools,profile,email
That is the full chain working: Keycloak signs the JWT, requireBearerAuth validates it against JWKS and writes req.auth, and toNodeHandler republishes it as ctx.http.authInfo. The clientId comes from the token's azp claim, and mcp:tools is the scope you configured in the v1 guide.
Client setup for VS Code, Cursor, and the terminal DCR client is unchanged. Those steps live in the v1 guide and still apply verbatim.
Upgrade Note: Rejected Tokens Returned 500
If you scaffolded with 0.7.0, this one is worth a look before you ship.
requireBearerAuth picks the status code from what your verifier throws: the SDK's OAuthError becomes a 401 with a WWW-Authenticate header, and anything else becomes a 500. The 0.7.0 template threw plain Errors, so rejected tokens came back as server_error with no challenge header, leaving the client no way to learn it should re-authenticate. That covered forged signatures and expired tokens, not just malformed ones.
Fixed in 0.7.1 (issue #27), which is what the command above pins, so a fresh scaffold already does the right thing. Updating the CLI will not rewrite an auth.ts already in your repo, though. If yours throws bare Errors, wrap them:
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token verification failed')
Keep the message generic like that rather than forwarding jose's. The specific reason belongs in your server log, not in a response to an unauthenticated caller. The rule generalizes: any rejection your verifier performs, including scope and tenant checks you add later, should throw OAuthError.
Migration Checklist
If you have a v1 OAuth server, the whole job is:
- Swap dependencies. Remove
@modelcontextprotocol/sdk; add@modelcontextprotocol/server,/express,/node, andhono. Keepjose. - Fix the imports in
auth.ts. Runtime helpers from@modelcontextprotocol/express, types from@modelcontextprotocol/server. - Collapse the routes in
index.ts. Three guarded routes become oneapp.all('/mcp', authMiddleware, ...). - Rename
extratoctxin handlers, and readctx.http?.authInfodefensively. - Leave Keycloak alone. Genuinely nothing to do.
Step 2 is a find-and-replace. Step 3 is part of the stateless migration you are doing anyway.
Conclusion
OAuth was the part of MCP v2 most likely to have broken, and it mostly did not. The auth model is the same: validate a bearer token against your IdP's JWKS, advertise a protected-resource metadata document, let clients discover it. v2 rearranged which package the helpers ship in and renamed one handler parameter.
The real gain is not auth-specific at all. Because v2 servers are stateless, protecting them means guarding one route instead of three.
If you have not migrated the server itself yet, start with the v2 quickstart, then come back and apply the five steps above.
Resources
- create-mcp-server — the CLI used here
- TypeScript SDK v2 Migration Guide — the official upgrade path
- MCP Authorization Specification
- Keycloak Documentation
- mcp-oauth-client — terminal client for DCR testing
Related Articles
- Securing MCP Servers: A Practical Guide with Keycloak — the v1 guide, and still where the Keycloak setup lives
- Build an MCP v2 Server in TypeScript — the v2 quickstart this guide builds on
- MCP v2: What's Changing, What's Deprecated, and Why — the conceptual companion
AGENT BRIEFINGS
Stay measured as the field moves.
What actually matters for building and scaling AI agents in production — and what's just hype. Straight from the work, no filler.
$ subscribe agent-briefings
→ what works in production, what doesn't.
→ frameworks, MCP, evals, managed services.
→ signal over hype.