Understanding Authorization in MCP changeddocs/draft/tutorials/security/authorization
Upstream edited this page at 28 Jul 2026 15:42 UTC, give or take a minute or two: the time comes from Anthropic’s own sitemap rather than from a commit. This site recorded the change at 20 Oct 2026 20:07 UTC.
Upstream edited
Recorded here
Lines+893added
Lines−913removed
From line
16
where the diff opens
First seen
26 Aug 2026
this site's first read of the page
Recorded edits2to this page, all time
The whole hunk
from line 16, old and new numbered
/
The two sides of this change are more than 400 edits apart, too far apart to line up, so this is the differ's own diff of it and the words inside a line are not marked.
from line 16
1616* You're building for enterprise environments with strict access controls
1717* You want to implement rate limiting or usage tracking per user
1818
19<Tip>
20 **Authorization for Local MCP Servers**
21
22 For MCP servers using the [STDIO transport](/specification/latest/basic/transports#stdio), you can use environment-based credentials or credentials provided by third-party libraries embedded directly in the MCP server instead. Because a STDIO-built MCP server runs locally, it has access to a range of flexible options when it comes to acquiring user credentials that may or may not rely on in-browser authentication and authorization flows.
23
24 OAuth flows, in turn, are designed for HTTP-based transports where the MCP server is remotely-hosted and the client uses OAuth to establish that a user is authorized to access said remote server.
25</Tip>
19> [!TIP]
20>
21> **Authorization for Local MCP Servers**
22>
23> For MCP servers using the [STDIO transport](/specification/latest/basic/transports#stdio), you can use environment-based credentials or credentials provided by third-party libraries embedded directly in the MCP server instead. Because a STDIO-built MCP server runs locally, it has access to a range of flexible options when it comes to acquiring user credentials that may or may not rely on in-browser authentication and authorization flows.
24>
25> OAuth flows, in turn, are designed for HTTP-based transports where the MCP server is remotely-hosted and the client uses OAuth to establish that a user is authorized to access said remote server.
2626
2727## The Authorization Flow: Step by Step
2828
2929Let's walk through what happens when a client wants to connect to your protected MCP server:
3030
31<Steps>
32 <Step title="Initial Handshake">
33 When your MCP client first tries to connect, your server responds with a `401 Unauthorized` and tells the client where to find authorization information, captured in a [Protected Resource Metadata (PRM) document](https://datatracker.ietf.org/doc/html/rfc9728). The document is hosted by the MCP server, follows a predictable path pattern, and is provided to the client in the `resource_metadata` parameter within the `WWW-Authenticate` header.
34
35 ```http theme={null}
36 HTTP/1.1 401 Unauthorized
37 WWW-Authenticate: Bearer realm="mcp",
38 resource_metadata="https://your-server.com/.well-known/oauth-protected-resource"
39 ```
40
41 This tells the client that authorization is required for the MCP server and where to get the necessary information to kickstart the authorization flow.
42 </Step>
43
44 <Step title="Protected Resource Metadata Discovery">
45 With the URI pointer to the PRM document, the client will fetch the metadata to learn about the authorization server, supported scopes, and other resource information. The data is typically encapsulated in a JSON blob, similar to the one below.
46
47 ```json theme={null}
48 {
49 "resource": "https://your-server.com/mcp",
50 "authorization_servers": ["https://auth.your-server.com"],
51 "scopes_supported": ["mcp:tools", "mcp:resources"]
52 }
53 ```
54
55 You can see a more comprehensive example in [RFC 9728 Section 3.2](https://datatracker.ietf.org/doc/html/rfc9728#name-protected-resource-metadata-r).
56 </Step>
57
58 <Step title="Authorization Server Discovery">
59 Next, the client discovers what the authorization server can do by fetching its metadata. If the PRM document lists more than one authorization server, the client can decide which one to use.
60
61 With an authorization server selected, the client will then construct a standard metadata URI and issue a request to the [OpenID Connect (OIDC) Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html) or [OAuth 2.0 Auth Server Metadata](https://datatracker.ietf.org/doc/html/rfc8414) endpoints (depending on authorization server support)
62 and retrieve another set of metadata properties that will allow it to know the endpoints it needs to complete the authorization flow.
63
64 ```json theme={null}
65 {
66 "issuer": "https://auth.your-server.com",
67 "authorization_endpoint": "https://auth.your-server.com/authorize",
68 "token_endpoint": "https://auth.your-server.com/token",
69 "registration_endpoint": "https://auth.your-server.com/register"
70 }
71 ```
72 </Step>
73
74 <Step title="Client Registration">
75 With all the metadata out of the way, the client now needs to make sure that it's registered with the authorization server. This can be done in two ways.
76
77 First, the client can be **pre-registered** with a given authorization server, in which case it can have embedded client registration information that it uses to complete the authorization flow.
78
79 Alternatively, the client can use **Dynamic Client Registration** (DCR) to dynamically register itself with the authorization server. The latter scenario requires the authorization server to support DCR. If the authorization server does support DCR, the client will send a request to the `registration_endpoint` with its information:
80
81 ```json theme={null}
82 {
83 "client_name": "My MCP Client",
84 "redirect_uris": ["http://localhost:3000/callback"],
85 "grant_types": ["authorization_code", "refresh_token"],
86 "response_types": ["code"]
87 }
88 ```
89
90 If the registration succeeds, the authorization server will return a JSON blob with client registration information.
91
92 <Tip>
93 **No DCR or Pre-Registration**
94
95 In case an MCP client connects to an MCP server that doesn't use an authorization server that supports DCR and the client is not pre-registered with said authorization server, it's the responsibility of the client developer to provide an affordance for the end-user to enter client information manually.
96 </Tip>
97 </Step>
98
99 <Step title="User Authorization">
100 The client will now need to open a browser to the `/authorize` endpoint, where the user can log in and grant the required permissions. The authorization server will then redirect back to the client with an authorization code that the client exchanges for tokens:
101
102 ```json theme={null}
103 {
104 "access_token": "eyJhbGciOiJSUzI1NiIs...",
105 "refresh_token": "def502...",
106 "token_type": "Bearer",
107 "expires_in": 3600
108 }
109 ```
110
111 The access token is what the client will use to authenticate requests to the MCP server. This step follows standard [OAuth 2.1 authorization code with PKCE](https://oauth.net/2/grant-types/authorization-code/) conventions.
112 </Step>
113
114 <Step title="Making Authenticated Requests">
115 Finally, the client can make requests to your MCP server using the access token embedded in the `Authorization` header:
116
117 ```http theme={null}
118 GET /mcp HTTP/1.1
119 Host: your-server.com
120 Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
121 ```
122
123 The MCP server will need to validate the token and process the request if the token is valid and has the required permissions.
124 </Step>
125</Steps>
31**1. Initial Handshake**
32
33When your MCP client first tries to connect, your server responds with a `401 Unauthorized` and tells the client where to find authorization information, captured in a [Protected Resource Metadata (PRM) document](https://datatracker.ietf.org/doc/html/rfc9728). The document is hosted by the MCP server, follows a predictable path pattern, and is provided to the client in the `resource_metadata` parameter within the `WWW-Authenticate` header.
34
35```http theme={null}
36HTTP/1.1 401 Unauthorized
37WWW-Authenticate: Bearer realm="mcp",
38 resource_metadata="https://your-server.com/.well-known/oauth-protected-resource"
39```
40
41This tells the client that authorization is required for the MCP server and where to get the necessary information to kickstart the authorization flow.
42
43**2. Protected Resource Metadata Discovery**
44
45With the URI pointer to the PRM document, the client will fetch the metadata to learn about the authorization server, supported scopes, and other resource information. The data is typically encapsulated in a JSON blob, similar to the one below.
46
47```json theme={null}
48{
49 "resource": "https://your-server.com/mcp",
50 "authorization_servers": ["https://auth.your-server.com"],
51 "scopes_supported": ["mcp:tools", "mcp:resources"]
52}
53```
54
55You can see a more comprehensive example in [RFC 9728 Section 3.2](https://datatracker.ietf.org/doc/html/rfc9728#name-protected-resource-metadata-r).
56
57**3. Authorization Server Discovery**
58
59Next, the client discovers what the authorization server can do by fetching its metadata. If the PRM document lists more than one authorization server, the client can decide which one to use.
60
61With an authorization server selected, the client will then construct a standard metadata URI and issue a request to the [OpenID Connect (OIDC) Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html) or [OAuth 2.0 Auth Server Metadata](https://datatracker.ietf.org/doc/html/rfc8414) endpoints (depending on authorization server support)
62and retrieve another set of metadata properties that will allow it to know the endpoints it needs to complete the authorization flow.
63
64```json theme={null}
65{
66 "issuer": "https://auth.your-server.com",
67 "authorization_endpoint": "https://auth.your-server.com/authorize",
68 "token_endpoint": "https://auth.your-server.com/token",
69 "registration_endpoint": "https://auth.your-server.com/register"
70}
71```
72
73**4. Client Registration**
74
75With all the metadata out of the way, the client now needs to make sure that it's registered with the authorization server. This can be done in two ways.
76
77First, the client can be **pre-registered** with a given authorization server, in which case it can have embedded client registration information that it uses to complete the authorization flow.
78
79Alternatively, the client can use **Dynamic Client Registration** (DCR) to dynamically register itself with the authorization server. The latter scenario requires the authorization server to support DCR. If the authorization server does support DCR, the client will send a request to the `registration_endpoint` with its information:
80
81```json theme={null}
82{
83 "client_name": "My MCP Client",
84 "redirect_uris": ["http://localhost:3000/callback"],
85 "grant_types": ["authorization_code", "refresh_token"],
86 "response_types": ["code"]
87}
88```
89
90If the registration succeeds, the authorization server will return a JSON blob with client registration information.
91
92> [!TIP]
93>
94> **No DCR or Pre-Registration**
95>
96> In case an MCP client connects to an MCP server that doesn't use an authorization server that supports DCR and the client is not pre-registered with said authorization server, it's the responsibility of the client developer to provide an affordance for the end-user to enter client information manually.
97
98**5. User Authorization**
99
100The client will now need to open a browser to the `/authorize` endpoint, where the user can log in and grant the required permissions. The authorization server will then redirect back to the client with an authorization code that the client exchanges for tokens:
101
102```json theme={null}
103{
104 "access_token": "eyJhbGciOiJSUzI1NiIs...",
105 "refresh_token": "def502...",
106 "token_type": "Bearer",
107 "expires_in": 3600
108}
109```
110
111The access token is what the client will use to authenticate requests to the MCP server. This step follows standard [OAuth 2.1 authorization code with PKCE](https://oauth.net/2/grant-types/authorization-code/) conventions.
112
113**6. Making Authenticated Requests**
114
115Finally, the client can make requests to your MCP server using the access token embedded in the `Authorization` header:
116
117```http theme={null}
118GET /mcp HTTP/1.1
119Host: your-server.com
120Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
121```
122
123The MCP server will need to validate the token and process the request if the token is valid and has the required permissions.
126124
127125## Implementation Example
128126
from line 138
140138
141139This command will pull the Keycloak container image locally and bootstrap the basic configuration. It will run on port `8080` and have an `admin` user with `admin` password.
142140
143<Warning>
144 **Not for Production**
145
146 The configuration above may be suitable for testing and experimentation; however, you should never use it in production. Refer to the [Configuring Keycloak for production](https://www.keycloak.org/server/configuration-production) guide for additional details on how to deploy the authorization server for scenarios that require reliability, security, and high availability.
147</Warning>
141> [!WARNING]
142>
143> **Not for Production**
144>
145> The configuration above may be suitable for testing and experimentation; however, you should never use it in production. Refer to the [Configuring Keycloak for production](https://www.keycloak.org/server/configuration-production) guide for additional details on how to deploy the authorization server for scenarios that require reliability, security, and high availability.
148146
149147You will be able to access the Keycloak authorization server from your browser at `http://localhost:8080`.
150148
151<Frame>
152 <img src="https://mintcdn.com/mcp/sAd4SGUO-cEUqgzn/images/tutorial-authorization/keycloak-browser.png?fit=max&auto=format&n=sAd4SGUO-cEUqgzn&q=85&s=cba689d986e113cbe937d732ac0558b6" alt="Keycloak admin dashboard authentication dialog." width="1834" height="1450" data-path="images/tutorial-authorization/keycloak-browser.png" />
153</Frame>
149
154150
155151When running with the default configuration, Keycloak will already support many of the capabilities that we need for MCP servers, including Dynamic Client Registration. You can check this by looking at the OIDC configuration, available at:
156152
from line 158
162158
163159Go to **Client scopes** in the Keycloak dashboard and create a new `mcp:tools` scope. We will use this to access all of the tools on our MCP server.
164160
165<Frame>
166 <img src="https://mintcdn.com/mcp/sAd4SGUO-cEUqgzn/images/tutorial-authorization/keycloak-scopes.png?fit=max&auto=format&n=sAd4SGUO-cEUqgzn&q=85&s=3cd49dc2e070027609ae495751e0db58" alt="Configuring Keycloak scopes." width="1999" height="1710" data-path="images/tutorial-authorization/keycloak-scopes.png" />
167</Frame>
161
168162
169163After creating the scope, make sure that you assign its type to **Default** and have flipped the **Include in token scope** switch, as this will be needed for token validation.
170164
from line 166
172166
173167To do this, open your `mcp:tools` client scope and click on **Mappers**, followed by **Configure a new mapper**. Select **Audience**.
174168
175<Frame>
176 <img src="https://mintcdn.com/mcp/sAd4SGUO-cEUqgzn/images/tutorial-authorization/scope-add-audience.gif?s=6ea9cf20c397f4c79c491c2e39019272" alt="Configuring an audience for a token in Keycloak." width="1080" height="921" data-path="images/tutorial-authorization/scope-add-audience.gif" />
177</Frame>
169
178170
179171For **Name**, use `audience-config`. Add a value for **Included Custom Audience**, set to `http://localhost:3000`. This will be the URI of our test server.
180172
181<Warning>
182 **Not for Production**
183
184 The audience configuration above is meant for testing. For production scenarios, additional set-up and configuration will be required to ensure that audiences are properly constrained for issued tokens. Specifically, the audience needs to be based on the resource parameter passed from the client, not a fixed value.
185</Warning>
173> [!WARNING]
174>
175> **Not for Production**
176>
177> The audience configuration above is meant for testing. For production scenarios, additional set-up and configuration will be required to ensure that audiences are properly constrained for issued tokens. Specifically, the audience needs to be based on the resource parameter passed from the client, not a fixed value.
186178
187179Now, navigate to **Clients**, then **Client registration**, and then **Trusted Hosts**. Disable the **Client URIs Must Match** setting and add the hosts from which you're testing. You can get your current host IP by running the `ifconfig` command on Linux or macOS, or `ipconfig` on Windows. You can see the IP address you need to add by looking at the keycloak logs for a line that looks like `Failed to verify remote host : 192.168.215.1`. Check that the IP address is associated with your host. This may be for a bridge network depending on your docker setup.
188180
189<Frame>
190 <img src="https://mintcdn.com/mcp/sAd4SGUO-cEUqgzn/images/tutorial-authorization/keycloak-client.gif?s=b5d40b36a5f1ea1e818821bb8ea77f6b" alt="Setting up client registration details in Keycloak." width="1199" height="1027" data-path="images/tutorial-authorization/keycloak-client.gif" />
191</Frame>
192
193<Warning>
194 **Getting the Host**
195
196 If you are running Keycloak from a container, you will also be able to see the host IP from the Terminal in the container logs.
197</Warning>
181
182
183> [!WARNING]
184>
185> **Getting the Host**
186>
187> If you are running Keycloak from a container, you will also be able to see the host IP from the Terminal in the container logs.
198188
199189Lastly, we need to register a new client that we can use with the **MCP server itself** to talk to Keycloak for things like [token introspection](https://oauth.net/2/token-introspection/). To do that:
200190
from line 198
208198
209199When you open the client details, go to **Credentials** and take note of the **Client Secret**.
210200
211<Frame>
212 <img src="https://mintcdn.com/mcp/sAd4SGUO-cEUqgzn/images/tutorial-authorization/keycloak-client-auth.gif?s=7152c41a5746994fd399024bc4659e40" alt="Creating a new client in Keycloak." width="1200" height="1023" data-path="images/tutorial-authorization/keycloak-client-auth.gif" />
213</Frame>
214
215<Warning>
216 **Handling Secrets**
217
218 Never embed client credentials directly in your code. We recommend using environment variables or specialized solutions for secret storage.
219</Warning>
201
202
203> [!WARNING]
204>
205> **Handling Secrets**
206>
207> Never embed client credentials directly in your code. We recommend using environment variables or specialized solutions for secret storage.
220208
221209With Keycloak configured, every time the authorization flow is triggered, your MCP server will receive a token like this:
222210
from line 234
246234}.[Signature]
247235```
248236
249<Warning>
250 **Embedded Audience**
251
252 Notice the `aud` claim embedded in the token - it's currently set to be the URI of the test MCP server and it's inferred from the scope that we've previously configured. This will be important in our implementation to validate.
253</Warning>
237> [!WARNING]
238>
239> **Embedded Audience**
240>
241> Notice the `aud` claim embedded in the token - it's currently set to be the URI of the test MCP server and it's inferred from the scope that we've previously configured. This will be important in our implementation to validate.
254242
255243### MCP Server Setup
256244
from line 246
258246
259247For our testing purposes, we will create an extremely simple MCP server that exposes two tools - one for addition and another for multiplication. The server will require authorization to access these.
260248
261<Tabs>
262 <Tab title="TypeScript">
263 You can see the complete TypeScript project in the [sample repository](https://github.com/localden/min-ts-mcp-auth).
264
265 Prior to running the code below, ensure that you have a `.env` file with the following content:
266
267 ```env theme={null}
268 # Server host/port
269 HOST=localhost
270 PORT=3000
271
272 # Auth server location
273 AUTH_HOST=localhost
274 AUTH_PORT=8080
275 AUTH_REALM=master
276
277 # Keycloak OAuth client credentials
278 OAUTH_CLIENT_ID=<YOUR_SERVER_CLIENT_ID>
279 OAUTH_CLIENT_SECRET=<YOUR_SERVER_CLIENT_SECRET>
280 ```
281
282 `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` are associated with the MCP server client we created earlier.
283
284 In addition to implementing the MCP authorization specification, the server below also does token introspection via Keycloak to make sure that the token it receives from the client is valid. It also implements basic logging to allow you to easily diagnose any issues.
285
286 ```typescript theme={null}
287 import "dotenv/config";
288 import express from "express";
289 import { randomUUID } from "node:crypto";
290 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
291 import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
292 import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
293 import { z } from "zod";
294 import cors from "cors";
295 import {
296 mcpAuthMetadataRouter,
297 getOAuthProtectedResourceMetadataUrl,
298 } from "@modelcontextprotocol/sdk/server/auth/router.js";
299 import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
300 import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
301 import { checkResourceAllowed } from "@modelcontextprotocol/sdk/shared/auth-utils.js";
302 const CONFIG = {
303 host: process.env.HOST || "localhost",
304 port: Number(process.env.PORT) || 3000,
305 auth: {
306 host: process.env.AUTH_HOST || process.env.HOST || "localhost",
307 port: Number(process.env.AUTH_PORT) || 8080,
308 realm: process.env.AUTH_REALM || "master",
309 clientId: process.env.OAUTH_CLIENT_ID || "mcp-server",
310 clientSecret: process.env.OAUTH_CLIENT_SECRET || "",
311 },
312 };
313
314 function createOAuthUrls() {
315 const authBaseUrl = new URL(
316 `http://${CONFIG.auth.host}:${CONFIG.auth.port}/realms/${CONFIG.auth.realm}/`,
317 );
318 return {
319 issuer: authBaseUrl.toString(),
320 introspection_endpoint: new URL(
321 "protocol/openid-connect/token/introspect",
322 authBaseUrl,
323 ).toString(),
324 authorization_endpoint: new URL(
325 "protocol/openid-connect/auth",
326 authBaseUrl,
327 ).toString(),
328 token_endpoint: new URL(
329 "protocol/openid-connect/token",
330 authBaseUrl,
331 ).toString(),
332 };
333 }
334
335 function createRequestLogger() {
336 return (req: any, res: any, next: any) => {
337 const start = Date.now();
338 res.on("finish", () => {
339 const ms = Date.now() - start;
340 console.log(
341 `${req.method} ${req.originalUrl} -> ${res.statusCode} ${ms}ms`,
342 );
343 });
344 next();
345 };
346 }
347
348 const app = express();
349
350 app.use(
351 express.json({
352 verify: (req: any, _res, buf) => {
353 req.rawBody = buf?.toString() ?? "";
354 },
355 }),
356 );
357
358 app.use(
359 cors({
360 origin: "*",
361 exposedHeaders: ["Mcp-Session-Id"],
362 }),
363 );
364
365 app.use(createRequestLogger());
366
367 const mcpServerUrl = new URL(`http://${CONFIG.host}:${CONFIG.port}`);
368 const oauthUrls = createOAuthUrls();
369
370 const oauthMetadata: OAuthMetadata = {
371 ...oauthUrls,
372 response_types_supported: ["code"],
373 };
374
375 const tokenVerifier = {
376 verifyAccessToken: async (token: string) => {
377 const endpoint = oauthMetadata.introspection_endpoint;
378
379 if (!endpoint) {
380 console.error("[auth] no introspection endpoint in metadata");
381 throw new Error("No token verification endpoint available in metadata");
382 }
383
384 const params = new URLSearchParams({
385 token: token,
386 client_id: CONFIG.auth.clientId,
387 });
388
389 if (CONFIG.auth.clientSecret) {
390 params.set("client_secret", CONFIG.auth.clientSecret);
391 }
392
393 let response: Response;
394 try {
395 response = await fetch(endpoint, {
396 method: "POST",
397 headers: {
398 "Content-Type": "application/x-www-form-urlencoded",
399 },
400 body: params.toString(),
401 });
402 } catch (e) {
403 console.error("[auth] introspection fetch threw", e);
404 throw e;
405 }
406
407 if (!response.ok) {
408 const txt = await response.text();
409 console.error("[auth] introspection non-OK", { status: response.status });
410
411 try {
412 const obj = JSON.parse(txt);
413 console.log(JSON.stringify(obj, null, 2));
414 } catch {
415 console.error(txt);
416 }
417 throw new Error(`Invalid or expired token: ${txt}`);
418 }
419
420 let data: any;
421 try {
422 data = await response.json();
423 } catch (e) {
424 const txt = await response.text();
425 console.error("[auth] failed to parse introspection JSON", {
426 error: String(e),
427 body: txt,
428 });
429 throw e;
430 }
431
432 if (data.active === false) {
433 throw new Error("Inactive token");
434 }
435
436 if (!data.aud) {
437 throw new Error("Resource indicator (aud) missing");
438 }
439
440 const audiences: string[] = Array.isArray(data.aud) ? data.aud : [data.aud];
441 const allowed = audiences.some((a) => {
442 try {
443 return checkResourceAllowed({
444 requestedResource: a,
445 configuredResource: mcpServerUrl,
446 });
447 } catch {
448 // Keycloak tokens include non-URL audiences (e.g. "account", "test-client").
449 // Those are never our resource, so treat them as "no match" instead of crashing.
450 return false;
451 }
452 });
453 if (!allowed) {
454 throw new Error(
455 `None of the provided audiences are allowed. Expected ${mcpServerUrl}, got: ${audiences.join(", ")}`,
456 );
457 }
458
459 return {
460 token,
461 clientId: data.client_id,
462 scopes: data.scope ? data.scope.split(" ") : [],
463 expiresAt: data.exp,
464 };
465 },
466 };
467 app.use(
468 mcpAuthMetadataRouter({
469 oauthMetadata,
470 resourceServerUrl: mcpServerUrl,
471 scopesSupported: ["mcp:tools"],
472 resourceName: "MCP Demo Server",
473 }),
474 );
475
476 const authMiddleware = requireBearerAuth({
477 verifier: tokenVerifier,
478 requiredScopes: [],
479 resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
480 });
481
482 const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
483
484 function createMcpServer() {
485 const server = new McpServer({
486 name: "example-server",
487 version: "1.0.0",
488 });
489
490 server.registerTool(
491 "add",
492 {
493 title: "Addition Tool",
494 description: "Add two numbers together",
495 inputSchema: {
496 a: z.number().describe("First number to add"),
497 b: z.number().describe("Second number to add"),
498 },
499 },
500 async ({ a, b }) => ({
501 content: [{ type: "text", text: `${a} + ${b} = ${a + b}` }],
502 }),
503 );
504
505 server.registerTool(
506 "multiply",
507 {
508 title: "Multiplication Tool",
509 description: "Multiply two numbers together",
510 inputSchema: {
511 x: z.number().describe("First number to multiply"),
512 y: z.number().describe("Second number to multiply"),
513 },
514 },
515 async ({ x, y }) => ({
516 content: [{ type: "text", text: `${x} × ${y} = ${x * y}` }],
517 }),
518 );
519
520 return server;
521 }
522
523 const mcpPostHandler = async (req: express.Request, res: express.Response) => {
524 const sessionId = req.headers["mcp-session-id"] as string | undefined;
525 let transport: StreamableHTTPServerTransport;
526
527 if (sessionId && transports[sessionId]) {
528 transport = transports[sessionId];
529 } else if (!sessionId && isInitializeRequest(req.body)) {
530 transport = new StreamableHTTPServerTransport({
531 sessionIdGenerator: () => randomUUID(),
532 onsessioninitialized: (sessionId) => {
533 transports[sessionId] = transport;
534 },
535 });
536
537 transport.onclose = () => {
538 if (transport.sessionId) {
539 delete transports[transport.sessionId];
540 }
541 };
542
543 const server = createMcpServer();
544 await server.connect(transport);
545 } else {
546 res.status(400).json({
547 jsonrpc: "2.0",
548 error: {
549 code: -32000,
550 message: "Bad Request: No valid session ID provided",
551 },
552 id: null,
553 });
554 return;
555 }
556
557 await transport.handleRequest(req, res, req.body);
558 };
559
560 const handleSessionRequest = async (
561 req: express.Request,
562 res: express.Response,
563 ) => {
564 const sessionId = req.headers["mcp-session-id"] as string | undefined;
565 if (!sessionId || !transports[sessionId]) {
566 res.status(400).send("Invalid or missing session ID");
567 return;
568 }
569
570 const transport = transports[sessionId];
571 await transport.handleRequest(req, res);
572 };
573
574 app.post("/", authMiddleware, mcpPostHandler);
575 app.get("/", authMiddleware, handleSessionRequest);
576 app.delete("/", authMiddleware, handleSessionRequest);
577
578 app.listen(CONFIG.port, CONFIG.host, () => {
579 console.log(`🚀 MCP Server running on ${mcpServerUrl.origin}`);
580 console.log(`📡 MCP endpoint available at ${mcpServerUrl.origin}`);
249**TypeScript**
250
251You can see the complete TypeScript project in the [sample repository](https://github.com/localden/min-ts-mcp-auth).
252
253Prior to running the code below, ensure that you have a `.env` file with the following content:
254
255```env theme={null}
256# Server host/port
257HOST=localhost
258PORT=3000
259
260# Auth server location
261AUTH_HOST=localhost
262AUTH_PORT=8080
263AUTH_REALM=master
264
265# Keycloak OAuth client credentials
266OAUTH_CLIENT_ID=<YOUR_SERVER_CLIENT_ID>
267OAUTH_CLIENT_SECRET=<YOUR_SERVER_CLIENT_SECRET>
268```
269
270`OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` are associated with the MCP server client we created earlier.
271
272In addition to implementing the MCP authorization specification, the server below also does token introspection via Keycloak to make sure that the token it receives from the client is valid. It also implements basic logging to allow you to easily diagnose any issues.
273
274```typescript theme={null}
275import "dotenv/config";
276import express from "express";
277import { randomUUID } from "node:crypto";
278import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
279import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
280import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
281import { z } from "zod";
282import cors from "cors";
283import {
284 mcpAuthMetadataRouter,
285 getOAuthProtectedResourceMetadataUrl,
286} from "@modelcontextprotocol/sdk/server/auth/router.js";
287import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
288import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
289import { checkResourceAllowed } from "@modelcontextprotocol/sdk/shared/auth-utils.js";
290const CONFIG = {
291 host: process.env.HOST || "localhost",
292 port: Number(process.env.PORT) || 3000,
293 auth: {
294 host: process.env.AUTH_HOST || process.env.HOST || "localhost",
295 port: Number(process.env.AUTH_PORT) || 8080,
296 realm: process.env.AUTH_REALM || "master",
297 clientId: process.env.OAUTH_CLIENT_ID || "mcp-server",
298 clientSecret: process.env.OAUTH_CLIENT_SECRET || "",
299 },
300};
301
302function createOAuthUrls() {
303 const authBaseUrl = new URL(
304 `http://${CONFIG.auth.host}:${CONFIG.auth.port}/realms/${CONFIG.auth.realm}/`,
305 );
306 return {
307 issuer: authBaseUrl.toString(),
308 introspection_endpoint: new URL(
309 "protocol/openid-connect/token/introspect",
310 authBaseUrl,
311 ).toString(),
312 authorization_endpoint: new URL(
313 "protocol/openid-connect/auth",
314 authBaseUrl,
315 ).toString(),
316 token_endpoint: new URL(
317 "protocol/openid-connect/token",
318 authBaseUrl,
319 ).toString(),
320 };
321}
322
323function createRequestLogger() {
324 return (req: any, res: any, next: any) => {
325 const start = Date.now();
326 res.on("finish", () => {
327 const ms = Date.now() - start;
581328 console.log(
582 `🔐 OAuth metadata available at ${getOAuthProtectedResourceMetadataUrl(mcpServerUrl)}`,
329 `${req.method} ${req.originalUrl} -> ${res.statusCode} ${ms}ms`,
583330 );
584331 });
585 ```
586
587 When you run the server, you can add it to your MCP client, such as Visual Studio Code, by providing the MCP server endpoint.
588
589 For more details about implementing MCP servers in TypeScript, refer to the [TypeScript SDK documentation](https://github.com/modelcontextprotocol/typescript-sdk).
590 </Tab>
591
592 <Tab title="Python">
593 You can see the complete Python project in the [sample repository](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-auth).
594
595 To simplify our authorization interaction, in Python scenarios we rely on the `MCPServer` class from the [Python SDK](https://py.sdk.modelcontextprotocol.io/v2/run/authorization/). It publishes the Protected Resource Metadata document, answers unauthenticated requests with a `401` whose `WWW-Authenticate` header points back at that document, and hands every bearer token to a verifier that we supply. Many of the conventions around authorization, like the endpoints and token validation logic, are consistent across languages, but some offer simpler ways of integrating them in production scenarios.
596
597 Prior to writing the actual server, we need to set up our configuration in `config.py` - the contents are entirely based on your local server setup:
598
599 ```python theme={null}
600 """Configuration settings for the MCP auth server."""
601
602 import os
603
604
605 class Config:
606 """Configuration class that loads from environment variables with sensible defaults."""
607
608 # Server settings
609 HOST: str = os.getenv("HOST", "localhost")
610 PORT: int = int(os.getenv("PORT", "3000"))
611
612 # Auth server settings
613 AUTH_HOST: str = os.getenv("AUTH_HOST", "localhost")
614 AUTH_PORT: int = int(os.getenv("AUTH_PORT", "8080"))
615 AUTH_REALM: str = os.getenv("AUTH_REALM", "master")
616
617 # OAuth client settings
618 OAUTH_CLIENT_ID: str = os.getenv("OAUTH_CLIENT_ID", "test-client")
619 OAUTH_CLIENT_SECRET: str = os.getenv("OAUTH_CLIENT_SECRET", "")
620
621 # Scope required on every token
622 MCP_SCOPE: str = os.getenv("MCP_SCOPE", "mcp:tools")
623
624 @property
625 def server_url(self) -> str:
626 """Build the server URL."""
627 return f"http://{self.HOST}:{self.PORT}"
628
629 @property
630 def auth_base_url(self) -> str:
631 """Build the auth server base URL."""
632 return f"http://{self.AUTH_HOST}:{self.AUTH_PORT}/realms/{self.AUTH_REALM}/"
633
634
635 # Global configuration instance
636 config = Config()
637 ```
638
639 `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` are associated with the MCP server client we created earlier. Set them in your environment before starting the server.
640
641 The server implementation is as follows:
642
643 ```python theme={null}
644 import datetime
645 import logging
646 from typing import Any
647 from urllib.parse import urljoin
648
649 from pydantic import AnyHttpUrl
650
651 from mcp.server import MCPServer
652 from mcp.server.auth.settings import AuthSettings
653
654 from .config import config
655 from .token_verifier import IntrospectionTokenVerifier
656
657 logger = logging.getLogger(__name__)
658
659
660 def create_oauth_urls() -> dict[str, str]:
661 """Create OAuth URLs based on configuration (Keycloak-style)."""
662 auth_base_url = config.auth_base_url
663
332 next();
333 };
334}
335
336const app = express();
337
338app.use(
339 express.json({
340 verify: (req: any, _res, buf) => {
341 req.rawBody = buf?.toString() ?? "";
342 },
343 }),
344);
345
346app.use(
347 cors({
348 origin: "*",
349 exposedHeaders: ["Mcp-Session-Id"],
350 }),
351);
352
353app.use(createRequestLogger());
354
355const mcpServerUrl = new URL(`http://${CONFIG.host}:${CONFIG.port}`);
356const oauthUrls = createOAuthUrls();
357
358const oauthMetadata: OAuthMetadata = {
359 ...oauthUrls,
360 response_types_supported: ["code"],
361};
362
363const tokenVerifier = {
364 verifyAccessToken: async (token: string) => {
365 const endpoint = oauthMetadata.introspection_endpoint;
366
367 if (!endpoint) {
368 console.error("[auth] no introspection endpoint in metadata");
369 throw new Error("No token verification endpoint available in metadata");
370 }
371
372 const params = new URLSearchParams({
373 token: token,
374 client_id: CONFIG.auth.clientId,
375 });
376
377 if (CONFIG.auth.clientSecret) {
378 params.set("client_secret", CONFIG.auth.clientSecret);
379 }
380
381 let response: Response;
382 try {
383 response = await fetch(endpoint, {
384 method: "POST",
385 headers: {
386 "Content-Type": "application/x-www-form-urlencoded",
387 },
388 body: params.toString(),
389 });
390 } catch (e) {
391 console.error("[auth] introspection fetch threw", e);
392 throw e;
393 }
394
395 if (!response.ok) {
396 const txt = await response.text();
397 console.error("[auth] introspection non-OK", { status: response.status });
398
399 try {
400 const obj = JSON.parse(txt);
401 console.log(JSON.stringify(obj, null, 2));
402 } catch {
403 console.error(txt);
404 }
405 throw new Error(`Invalid or expired token: ${txt}`);
406 }
407
408 let data: any;
409 try {
410 data = await response.json();
411 } catch (e) {
412 const txt = await response.text();
413 console.error("[auth] failed to parse introspection JSON", {
414 error: String(e),
415 body: txt,
416 });
417 throw e;
418 }
419
420 if (data.active === false) {
421 throw new Error("Inactive token");
422 }
423
424 if (!data.aud) {
425 throw new Error("Resource indicator (aud) missing");
426 }
427
428 const audiences: string[] = Array.isArray(data.aud) ? data.aud : [data.aud];
429 const allowed = audiences.some((a) => {
430 try {
431 return checkResourceAllowed({
432 requestedResource: a,
433 configuredResource: mcpServerUrl,
434 });
435 } catch {
436 // Keycloak tokens include non-URL audiences (e.g. "account", "test-client").
437 // Those are never our resource, so treat them as "no match" instead of crashing.
438 return false;
439 }
440 });
441 if (!allowed) {
442 throw new Error(
443 `None of the provided audiences are allowed. Expected ${mcpServerUrl}, got: ${audiences.join(", ")}`,
444 );
445 }
446
447 return {
448 token,
449 clientId: data.client_id,
450 scopes: data.scope ? data.scope.split(" ") : [],
451 expiresAt: data.exp,
452 };
453 },
454};
455app.use(
456 mcpAuthMetadataRouter({
457 oauthMetadata,
458 resourceServerUrl: mcpServerUrl,
459 scopesSupported: ["mcp:tools"],
460 resourceName: "MCP Demo Server",
461 }),
462);
463
464const authMiddleware = requireBearerAuth({
465 verifier: tokenVerifier,
466 requiredScopes: [],
467 resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
468});
469
470const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
471
472function createMcpServer() {
473 const server = new McpServer({
474 name: "example-server",
475 version: "1.0.0",
476 });
477
478 server.registerTool(
479 "add",
480 {
481 title: "Addition Tool",
482 description: "Add two numbers together",
483 inputSchema: {
484 a: z.number().describe("First number to add"),
485 b: z.number().describe("Second number to add"),
486 },
487 },
488 async ({ a, b }) => ({
489 content: [{ type: "text", text: `${a} + ${b} = ${a + b}` }],
490 }),
491 );
492
493 server.registerTool(
494 "multiply",
495 {
496 title: "Multiplication Tool",
497 description: "Multiply two numbers together",
498 inputSchema: {
499 x: z.number().describe("First number to multiply"),
500 y: z.number().describe("Second number to multiply"),
501 },
502 },
503 async ({ x, y }) => ({
504 content: [{ type: "text", text: `${x} × ${y} = ${x * y}` }],
505 }),
506 );
507
508 return server;
509}
510
511const mcpPostHandler = async (req: express.Request, res: express.Response) => {
512 const sessionId = req.headers["mcp-session-id"] as string | undefined;
513 let transport: StreamableHTTPServerTransport;
514
515 if (sessionId && transports[sessionId]) {
516 transport = transports[sessionId];
517 } else if (!sessionId && isInitializeRequest(req.body)) {
518 transport = new StreamableHTTPServerTransport({
519 sessionIdGenerator: () => randomUUID(),
520 onsessioninitialized: (sessionId) => {
521 transports[sessionId] = transport;
522 },
523 });
524
525 transport.onclose = () => {
526 if (transport.sessionId) {
527 delete transports[transport.sessionId];
528 }
529 };
530
531 const server = createMcpServer();
532 await server.connect(transport);
533 } else {
534 res.status(400).json({
535 jsonrpc: "2.0",
536 error: {
537 code: -32000,
538 message: "Bad Request: No valid session ID provided",
539 },
540 id: null,
541 });
542 return;
543 }
544
545 await transport.handleRequest(req, res, req.body);
546};
547
548const handleSessionRequest = async (
549 req: express.Request,
550 res: express.Response,
551) => {
552 const sessionId = req.headers["mcp-session-id"] as string | undefined;
553 if (!sessionId || !transports[sessionId]) {
554 res.status(400).send("Invalid or missing session ID");
555 return;
556 }
557
558 const transport = transports[sessionId];
559 await transport.handleRequest(req, res);
560};
561
562app.post("/", authMiddleware, mcpPostHandler);
563app.get("/", authMiddleware, handleSessionRequest);
564app.delete("/", authMiddleware, handleSessionRequest);
565
566app.listen(CONFIG.port, CONFIG.host, () => {
567 console.log(`🚀 MCP Server running on ${mcpServerUrl.origin}`);
568 console.log(`📡 MCP endpoint available at ${mcpServerUrl.origin}`);
569 console.log(
570 `🔐 OAuth metadata available at ${getOAuthProtectedResourceMetadataUrl(mcpServerUrl)}`,
571 );
572});
573```
574
575When you run the server, you can add it to your MCP client, such as Visual Studio Code, by providing the MCP server endpoint.
576
577For more details about implementing MCP servers in TypeScript, refer to the [TypeScript SDK documentation](https://github.com/modelcontextprotocol/typescript-sdk).
578
579**Python**
580
581You can see the complete Python project in the [sample repository](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-auth).
582
583To simplify our authorization interaction, in Python scenarios we rely on the `MCPServer` class from the [Python SDK](https://py.sdk.modelcontextprotocol.io/v2/run/authorization/). It publishes the Protected Resource Metadata document, answers unauthenticated requests with a `401` whose `WWW-Authenticate` header points back at that document, and hands every bearer token to a verifier that we supply. Many of the conventions around authorization, like the endpoints and token validation logic, are consistent across languages, but some offer simpler ways of integrating them in production scenarios.
584
585Prior to writing the actual server, we need to set up our configuration in `config.py` - the contents are entirely based on your local server setup:
586
587```python theme={null}
588"""Configuration settings for the MCP auth server."""
589
590import os
591
592
593class Config:
594 """Configuration class that loads from environment variables with sensible defaults."""
595
596 # Server settings
597 HOST: str = os.getenv("HOST", "localhost")
598 PORT: int = int(os.getenv("PORT", "3000"))
599
600 # Auth server settings
601 AUTH_HOST: str = os.getenv("AUTH_HOST", "localhost")
602 AUTH_PORT: int = int(os.getenv("AUTH_PORT", "8080"))
603 AUTH_REALM: str = os.getenv("AUTH_REALM", "master")
604
605 # OAuth client settings
606 OAUTH_CLIENT_ID: str = os.getenv("OAUTH_CLIENT_ID", "test-client")
607 OAUTH_CLIENT_SECRET: str = os.getenv("OAUTH_CLIENT_SECRET", "")
608
609 # Scope required on every token
610 MCP_SCOPE: str = os.getenv("MCP_SCOPE", "mcp:tools")
611
612 @property
613 def server_url(self) -> str:
614 """Build the server URL."""
615 return f"http://{self.HOST}:{self.PORT}"
616
617 @property
618 def auth_base_url(self) -> str:
619 """Build the auth server base URL."""
620 return f"http://{self.AUTH_HOST}:{self.AUTH_PORT}/realms/{self.AUTH_REALM}/"
621
622
623# Global configuration instance
624config = Config()
625```
626
627`OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` are associated with the MCP server client we created earlier. Set them in your environment before starting the server.
628
629The server implementation is as follows:
630
631```python theme={null}
632import datetime
633import logging
634from typing import Any
635from urllib.parse import urljoin
636
637from pydantic import AnyHttpUrl
638
639from mcp.server import MCPServer
640from mcp.server.auth.settings import AuthSettings
641
642from .config import config
643from .token_verifier import IntrospectionTokenVerifier
644
645logger = logging.getLogger(__name__)
646
647
648def create_oauth_urls() -> dict[str, str]:
649 """Create OAuth URLs based on configuration (Keycloak-style)."""
650 auth_base_url = config.auth_base_url
651
652 return {
653 "issuer": auth_base_url,
654 "introspection_endpoint": urljoin(auth_base_url, "protocol/openid-connect/token/introspect"),
655 "authorization_endpoint": urljoin(auth_base_url, "protocol/openid-connect/auth"),
656 "token_endpoint": urljoin(auth_base_url, "protocol/openid-connect/token"),
657 }
658
659
660def create_server() -> MCPServer:
661 """Create and configure the MCP server."""
662
663 oauth_urls = create_oauth_urls()
664
665 token_verifier = IntrospectionTokenVerifier(
666 introspection_endpoint=oauth_urls["introspection_endpoint"],
667 server_url=config.server_url,
668 client_id=config.OAUTH_CLIENT_ID,
669 client_secret=config.OAUTH_CLIENT_SECRET,
670 )
671
672 app = MCPServer(
673 name="MCP Resource Server",
674 instructions="Resource Server that validates tokens via Authorization Server introspection",
675 debug=True,
676 token_verifier=token_verifier,
677 auth=AuthSettings(
678 issuer_url=AnyHttpUrl(oauth_urls["issuer"]),
679 required_scopes=[config.MCP_SCOPE],
680 resource_server_url=AnyHttpUrl(config.server_url),
681 ),
682 )
683
684 @app.tool()
685 async def add_numbers(a: float, b: float) -> dict[str, Any]:
686 """
687 Add two numbers together.
688 This tool demonstrates basic arithmetic operations with OAuth authentication.
689
690 Args:
691 a: The first number to add
692 b: The second number to add
693 """
694 result = a + b
664695 return {
665 "issuer": auth_base_url,
666 "introspection_endpoint": urljoin(auth_base_url, "protocol/openid-connect/token/introspect"),
667 "authorization_endpoint": urljoin(auth_base_url, "protocol/openid-connect/auth"),
668 "token_endpoint": urljoin(auth_base_url, "protocol/openid-connect/token"),
696 "operation": "addition",
697 "operand_a": a,
698 "operand_b": b,
699 "result": result,
700 "timestamp": datetime.datetime.now().isoformat(),
669701 }
670702
671
672 def create_server() -> MCPServer:
673 """Create and configure the MCP server."""
674
675 oauth_urls = create_oauth_urls()
676
677 token_verifier = IntrospectionTokenVerifier(
678 introspection_endpoint=oauth_urls["introspection_endpoint"],
679 server_url=config.server_url,
680 client_id=config.OAUTH_CLIENT_ID,
681 client_secret=config.OAUTH_CLIENT_SECRET,
703 @app.tool()
704 async def multiply_numbers(x: float, y: float) -> dict[str, Any]:
705 """
706 Multiply two numbers together.
707 This tool demonstrates basic arithmetic operations with OAuth authentication.
708
709 Args:
710 x: The first number to multiply
711 y: The second number to multiply
712 """
713 result = x * y
714 return {
715 "operation": "multiplication",
716 "operand_x": x,
717 "operand_y": y,
718 "result": result,
719 "timestamp": datetime.datetime.now().isoformat(),
720 }
721
722 return app
723
724
725def main() -> int:
726 """
727 Run the MCP Resource Server.
728
729 This server:
730 - Provides RFC 9728 Protected Resource Metadata
731 - Validates tokens via Authorization Server introspection
732 - Serves MCP tools requiring authentication
733
734 Configuration is loaded from config.py and environment variables.
735 """
736 logging.basicConfig(level=logging.INFO)
737
738 oauth_urls = create_oauth_urls()
739
740 try:
741 mcp_server = create_server()
742
743 logger.info("Starting MCP Server on %s:%s", config.HOST, config.PORT)
744 logger.info("Authorization Server: %s", oauth_urls["issuer"])
745
746 mcp_server.run(
747 transport="streamable-http",
748 host=config.HOST,
749 port=config.PORT,
750 streamable_http_path="/",
682751 )
683
684 app = MCPServer(
685 name="MCP Resource Server",
686 instructions="Resource Server that validates tokens via Authorization Server introspection",
687 debug=True,
688 token_verifier=token_verifier,
689 auth=AuthSettings(
690 issuer_url=AnyHttpUrl(oauth_urls["issuer"]),
691 required_scopes=[config.MCP_SCOPE],
692 resource_server_url=AnyHttpUrl(config.server_url),
693 ),
694 )
695
696 @app.tool()
697 async def add_numbers(a: float, b: float) -> dict[str, Any]:
698 """
699 Add two numbers together.
700 This tool demonstrates basic arithmetic operations with OAuth authentication.
701
702 Args:
703 a: The first number to add
704 b: The second number to add
705 """
706 result = a + b
707 return {
708 "operation": "addition",
709 "operand_a": a,
710 "operand_b": b,
711 "result": result,
712 "timestamp": datetime.datetime.now().isoformat(),
752 return 0
753
754 except Exception:
755 logger.exception("Server error")
756 return 1
757
758
759if __name__ == "__main__":
760 exit(main())
761```
762
763Lastly, the token verification logic is delegated entirely to `token_verifier.py`, ensuring that we can use the Keycloak introspection endpoint to verify the validity of any credential artifacts.
764
765```python theme={null}
766"""Token verifier implementation using OAuth 2.0 Token Introspection (RFC 7662)."""
767
768import logging
769from typing import Any
770
771import httpx2
772
773from mcp.server.auth.provider import AccessToken, TokenVerifier
774from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url
775
776logger = logging.getLogger(__name__)
777
778
779class IntrospectionTokenVerifier(TokenVerifier):
780 """Token verifier that uses OAuth 2.0 Token Introspection (RFC 7662)."""
781
782 def __init__(
783 self,
784 introspection_endpoint: str,
785 server_url: str,
786 client_id: str,
787 client_secret: str,
788 ):
789 self.introspection_endpoint = introspection_endpoint
790 self.server_url = server_url
791 self.client_id = client_id
792 self.client_secret = client_secret
793 self.resource_url = resource_url_from_server_url(server_url)
794
795 async def verify_token(self, token: str) -> AccessToken | None:
796 """Verify token via introspection endpoint."""
797 if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")):
798 return None
799
800 timeout = httpx2.Timeout(10.0, connect=5.0)
801 limits = httpx2.Limits(max_connections=10, max_keepalive_connections=5)
802
803 async with httpx2.AsyncClient(
804 timeout=timeout,
805 limits=limits,
806 verify=True,
807 ) as client:
808 try:
809 form_data = {
810 "token": token,
811 "client_id": self.client_id,
812 }
813 # Only send client_secret when one is configured
814 # Public clients authenticate with client_id alone.
815 if self.client_secret:
816 form_data["client_secret"] = self.client_secret
817 headers = {"Content-Type": "application/x-www-form-urlencoded"}
818
819 response = await client.post(
820 self.introspection_endpoint,
821 data=form_data,
822 headers=headers,
823 )
824
825 if response.status_code != 200:
826 return None
827
828 data = response.json()
829 if not data.get("active", False):
830 return None
831
832 if not self._validate_resource(data):
833 return None
834
835 return AccessToken(
836 token=token,
837 client_id=data.get("client_id", "unknown"),
838 scopes=data.get("scope", "").split() if data.get("scope") else [],
839 expires_at=data.get("exp"),
840 # AccessToken.resource is `str | None`. Keycloak returns `aud`
841 # as a *list* here (e.g. ["test-client", "http://localhost:3000",
842 # "account"]); passing that list straight in raises a pydantic
843 # ValidationError that the broad `except` below turns into a
844 # silent 401. We already confirmed this server's resource is a
845 # valid audience in `_validate_resource`, so record that.
846 resource=self.resource_url,
847 subject=data.get("sub"), # RFC 7662 subject (resource owner)
848 claims=data,
849 )
850
851 except Exception:
852 logger.exception("Token introspection failed")
853 return None
854
855 def _validate_resource(self, token_data: dict[str, Any]) -> bool:
856 """Validate token was issued for this resource server.
857
858 Rules:
859 - Reject if 'aud' missing.
860 - Accept if any audience entry matches the derived resource URL.
861 - Supports string or list forms per JWT spec.
862 """
863 if not self.server_url or not self.resource_url:
864 return False
865
866 aud: list[str] | str | None = token_data.get("aud")
867 if isinstance(aud, list):
868 return any(self._is_valid_resource(a) for a in aud)
869 if isinstance(aud, str):
870 return self._is_valid_resource(aud)
871 return False
872
873 def _is_valid_resource(self, resource: str) -> bool:
874 """Check if the given resource matches our server."""
875 return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource)
876```
877
878For more details, see below or the [Python SDK documentation](https://github.com/modelcontextprotocol/python-sdk).
879
880**Python MCP Server**
881
882In the server's root have a `pyproject.toml` file and a `mcp_server` folder. Put all the Python files in the `mcp_server` folder, and fill the `pyproject.toml` file like:
883
884```toml theme={null}
885[project]
886name = "mcp-simple-auth"
887version = "0.1.0"
888description = "A simple MCP server demonstrating OAuth authentication"
889requires-python = ">=3.10"
890authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }]
891license = { text = "MIT" }
892dependencies = [
893 "httpx2>=2.5.0",
894 "mcp>=2.0.0rc1",
895 "pydantic>=2.0",
896]
897
898[project.scripts]
899mcp-simple-auth-rs = "mcp_server.server:main"
900
901[build-system]
902requires = ["hatchling"]
903build-backend = "hatchling.build"
904
905[tool.hatch.build.targets.wheel]
906packages = ["mcp_server"]
907
908[dependency-groups]
909dev = ["pyright>=1.1.391", "pytest>=8.3.4", "ruff>=0.8.5"]
910```
911
912Then run the commands below to start the server.
913
914```bash theme={null}
915uv sync
916uv run mcp-simple-auth-rs
917```
918
919**C#**
920
921You can see the complete C# project in the [sample repository](https://github.com/localden/min-cs-mcp-auth).
922
923To set up authorization in your MCP server using the MCP C# SDK, you can lean on the standard ASP.NET Core builder pattern. Instead of using the introspection endpoint provided by Keycloak, we will use built-in ASP.NET Core capabilities for token validation.
924
925```csharp theme={null}
926using Microsoft.AspNetCore.Authentication.JwtBearer;
927using Microsoft.IdentityModel.Tokens;
928using ModelContextProtocol.AspNetCore.Authentication;
929using ProtectedMcpServer.Tools;
930using System.Security.Claims;
931
932var builder = WebApplication.CreateBuilder(args);
933
934var serverUrl = "http://localhost:3000/";
935var authorizationServerUrl = "http://localhost:8080/realms/master/";
936
937builder.Services.AddAuthentication(options =>
938{
939 options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
940 options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
941})
942.AddJwtBearer(options =>
943{
944 options.Authority = authorizationServerUrl;
945 var normalizedServerAudience = serverUrl.TrimEnd('/');
946 options.TokenValidationParameters = new TokenValidationParameters
947 {
948 ValidIssuer = authorizationServerUrl,
949 ValidAudiences = new[] { normalizedServerAudience, serverUrl },
950 AudienceValidator = (audiences, securityToken, validationParameters) =>
951 {
952 if (audiences == null) return false;
953 foreach (var aud in audiences)
954 {
955 if (string.Equals(aud.TrimEnd('/'), normalizedServerAudience, StringComparison.OrdinalIgnoreCase))
956 {
957 return true;
958 }
713959 }
714
715 @app.tool()
716 async def multiply_numbers(x: float, y: float) -> dict[str, Any]:
717 """
718 Multiply two numbers together.
719 This tool demonstrates basic arithmetic operations with OAuth authentication.
720
721 Args:
722 x: The first number to multiply
723 y: The second number to multiply
724 """
725 result = x * y
726 return {
727 "operation": "multiplication",
728 "operand_x": x,
729 "operand_y": y,
730 "result": result,
731 "timestamp": datetime.datetime.now().isoformat(),
732 }
733
734 return app
735
736
737 def main() -> int:
738 """
739 Run the MCP Resource Server.
740
741 This server:
742 - Provides RFC 9728 Protected Resource Metadata
743 - Validates tokens via Authorization Server introspection
744 - Serves MCP tools requiring authentication
745
746 Configuration is loaded from config.py and environment variables.
747 """
748 logging.basicConfig(level=logging.INFO)
749
750 oauth_urls = create_oauth_urls()
751
752 try:
753 mcp_server = create_server()
754
755 logger.info("Starting MCP Server on %s:%s", config.HOST, config.PORT)
756 logger.info("Authorization Server: %s", oauth_urls["issuer"])
757
758 mcp_server.run(
759 transport="streamable-http",
760 host=config.HOST,
761 port=config.PORT,
762 streamable_http_path="/",
763 )
764 return 0
765
766 except Exception:
767 logger.exception("Server error")
768 return 1
769
770
771 if __name__ == "__main__":
772 exit(main())
773 ```
774
775 Lastly, the token verification logic is delegated entirely to `token_verifier.py`, ensuring that we can use the Keycloak introspection endpoint to verify the validity of any credential artifacts.
776
777 ```python theme={null}
778 """Token verifier implementation using OAuth 2.0 Token Introspection (RFC 7662)."""
779
780 import logging
781 from typing import Any
782
783 import httpx2
784
785 from mcp.server.auth.provider import AccessToken, TokenVerifier
786 from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url
787
788 logger = logging.getLogger(__name__)
789
790
791 class IntrospectionTokenVerifier(TokenVerifier):
792 """Token verifier that uses OAuth 2.0 Token Introspection (RFC 7662)."""
793
794 def __init__(
795 self,
796 introspection_endpoint: str,
797 server_url: str,
798 client_id: str,
799 client_secret: str,
800 ):
801 self.introspection_endpoint = introspection_endpoint
802 self.server_url = server_url
803 self.client_id = client_id
804 self.client_secret = client_secret
805 self.resource_url = resource_url_from_server_url(server_url)
806
807 async def verify_token(self, token: str) -> AccessToken | None:
808 """Verify token via introspection endpoint."""
809 if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")):
810 return None
811
812 timeout = httpx2.Timeout(10.0, connect=5.0)
813 limits = httpx2.Limits(max_connections=10, max_keepalive_connections=5)
814
815 async with httpx2.AsyncClient(
816 timeout=timeout,
817 limits=limits,
818 verify=True,
819 ) as client:
820 try:
821 form_data = {
822 "token": token,
823 "client_id": self.client_id,
824 }
825 # Only send client_secret when one is configured
826 # Public clients authenticate with client_id alone.
827 if self.client_secret:
828 form_data["client_secret"] = self.client_secret
829 headers = {"Content-Type": "application/x-www-form-urlencoded"}
830
831 response = await client.post(
832 self.introspection_endpoint,
833 data=form_data,
834 headers=headers,
835 )
836
837 if response.status_code != 200:
838 return None
839
840 data = response.json()
841 if not data.get("active", False):
842 return None
843
844 if not self._validate_resource(data):
845 return None
846
847 return AccessToken(
848 token=token,
849 client_id=data.get("client_id", "unknown"),
850 scopes=data.get("scope", "").split() if data.get("scope") else [],
851 expires_at=data.get("exp"),
852 # AccessToken.resource is `str | None`. Keycloak returns `aud`
853 # as a *list* here (e.g. ["test-client", "http://localhost:3000",
854 # "account"]); passing that list straight in raises a pydantic
855 # ValidationError that the broad `except` below turns into a
856 # silent 401. We already confirmed this server's resource is a
857 # valid audience in `_validate_resource`, so record that.
858 resource=self.resource_url,
859 subject=data.get("sub"), # RFC 7662 subject (resource owner)
860 claims=data,
861 )
862
863 except Exception:
864 logger.exception("Token introspection failed")
865 return None
866
867 def _validate_resource(self, token_data: dict[str, Any]) -> bool:
868 """Validate token was issued for this resource server.
869
870 Rules:
871 - Reject if 'aud' missing.
872 - Accept if any audience entry matches the derived resource URL.
873 - Supports string or list forms per JWT spec.
874 """
875 if not self.server_url or not self.resource_url:
876 return False
877
878 aud: list[str] | str | None = token_data.get("aud")
879 if isinstance(aud, list):
880 return any(self._is_valid_resource(a) for a in aud)
881 if isinstance(aud, str):
882 return self._is_valid_resource(aud)
883 return False
884
885 def _is_valid_resource(self, resource: str) -> bool:
886 """Check if the given resource matches our server."""
887 return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource)
888 ```
889
890 For more details, see below or the [Python SDK documentation](https://github.com/modelcontextprotocol/python-sdk).
891
892 **Python MCP Server**
893
894 In the server's root have a `pyproject.toml` file and a `mcp_server` folder. Put all the Python files in the `mcp_server` folder, and fill the `pyproject.toml` file like:
895
896 ```toml theme={null}
897 [project]
898 name = "mcp-simple-auth"
899 version = "0.1.0"
900 description = "A simple MCP server demonstrating OAuth authentication"
901 requires-python = ">=3.10"
902 authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }]
903 license = { text = "MIT" }
904 dependencies = [
905 "httpx2>=2.5.0",
906 "mcp>=2.0.0rc1",
907 "pydantic>=2.0",
908 ]
909
910 [project.scripts]
911 mcp-simple-auth-rs = "mcp_server.server:main"
912
913 [build-system]
914 requires = ["hatchling"]
915 build-backend = "hatchling.build"
916
917 [tool.hatch.build.targets.wheel]
918 packages = ["mcp_server"]
919
920 [dependency-groups]
921 dev = ["pyright>=1.1.391", "pytest>=8.3.4", "ruff>=0.8.5"]
922 ```
923
924 Then run the commands below to start the server.
925
926 ```bash theme={null}
927 uv sync
928 uv run mcp-simple-auth-rs
929 ```
930 </Tab>
931
932 <Tab title="C#">
933 You can see the complete C# project in the [sample repository](https://github.com/localden/min-cs-mcp-auth).
934
935 To set up authorization in your MCP server using the MCP C# SDK, you can lean on the standard ASP.NET Core builder pattern. Instead of using the introspection endpoint provided by Keycloak, we will use built-in ASP.NET Core capabilities for token validation.
936
937 ```csharp theme={null}
938 using Microsoft.AspNetCore.Authentication.JwtBearer;
939 using Microsoft.IdentityModel.Tokens;
940 using ModelContextProtocol.AspNetCore.Authentication;
941 using ProtectedMcpServer.Tools;
942 using System.Security.Claims;
943
944 var builder = WebApplication.CreateBuilder(args);
945
946 var serverUrl = "http://localhost:3000/";
947 var authorizationServerUrl = "http://localhost:8080/realms/master/";
948
949 builder.Services.AddAuthentication(options =>
960 return false;
961 }
962 };
963
964 options.RequireHttpsMetadata = false; // Set to true in production
965
966 options.Events = new JwtBearerEvents
950967 {
951 options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
952 options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
953 })
954 .AddJwtBearer(options =>
968 OnTokenValidated = context =>
969 {
970 var name = context.Principal?.Identity?.Name ?? "unknown";
971 var email = context.Principal?.FindFirstValue("preferred_username") ?? "unknown";
972 Console.WriteLine($"Token validated for: {name} ({email})");
973 return Task.CompletedTask;
974 },
975 OnAuthenticationFailed = context =>
976 {
977 Console.WriteLine($"Authentication failed: {context.Exception.Message}");
978 return Task.CompletedTask;
979 },
980 };
981})
982.AddMcp(options =>
983{
984 options.ResourceMetadata = new()
955985 {
956 options.Authority = authorizationServerUrl;
957 var normalizedServerAudience = serverUrl.TrimEnd('/');
958 options.TokenValidationParameters = new TokenValidationParameters
959 {
960 ValidIssuer = authorizationServerUrl,
961 ValidAudiences = new[] { normalizedServerAudience, serverUrl },
962 AudienceValidator = (audiences, securityToken, validationParameters) =>
963 {
964 if (audiences == null) return false;
965 foreach (var aud in audiences)
966 {
967 if (string.Equals(aud.TrimEnd('/'), normalizedServerAudience, StringComparison.OrdinalIgnoreCase))
968 {
969 return true;
970 }
971 }
972 return false;
973 }
974 };
975
976 options.RequireHttpsMetadata = false; // Set to true in production
977
978 options.Events = new JwtBearerEvents
979 {
980 OnTokenValidated = context =>
981 {
982 var name = context.Principal?.Identity?.Name ?? "unknown";
983 var email = context.Principal?.FindFirstValue("preferred_username") ?? "unknown";
984 Console.WriteLine($"Token validated for: {name} ({email})");
985 return Task.CompletedTask;
986 },
987 OnAuthenticationFailed = context =>
988 {
989 Console.WriteLine($"Authentication failed: {context.Exception.Message}");
990 return Task.CompletedTask;
991 },
992 };
993 })
994 .AddMcp(options =>
995 {
996 options.ResourceMetadata = new()
997 {
998 Resource = new Uri(serverUrl),
999 ResourceDocumentation = new Uri("https://docs.example.com/api/math"),
1000 AuthorizationServers = { new Uri(authorizationServerUrl) },
1001 ScopesSupported = ["mcp:tools"]
1002 };
1003 });
1004
1005 builder.Services.AddAuthorization();
1006
1007 builder.Services.AddHttpContextAccessor();
1008 builder.Services.AddMcpServer()
1009 .WithTools<MathTools>()
1010 .WithHttpTransport();
1011
1012 var app = builder.Build();
1013
1014 app.UseAuthentication();
1015 app.UseAuthorization();
1016
1017 app.MapMcp().RequireAuthorization();
1018
1019 Console.WriteLine($"Starting MCP server with authorization at {serverUrl}");
1020 Console.WriteLine($"Using Keycloak server at {authorizationServerUrl}");
1021 Console.WriteLine($"Protected Resource Metadata URL: {serverUrl}.well-known/oauth-protected-resource");
1022 Console.WriteLine("Exposed Math tools: Add, Multiply");
1023 Console.WriteLine("Press Ctrl+C to stop the server");
1024
1025 app.Run(serverUrl);
1026 ```
1027
1028 For more details, see the [C# SDK documentation](https://github.com/modelcontextprotocol/csharp-sdk).
1029 </Tab>
1030</Tabs>
986 Resource = new Uri(serverUrl),
987 ResourceDocumentation = new Uri("https://docs.example.com/api/math"),
988 AuthorizationServers = { new Uri(authorizationServerUrl) },
989 ScopesSupported = ["mcp:tools"]
990 };
991});
992
993builder.Services.AddAuthorization();
994
995builder.Services.AddHttpContextAccessor();
996builder.Services.AddMcpServer()
997 .WithTools<MathTools>()
998 .WithHttpTransport();
999
1000var app = builder.Build();
1001
1002app.UseAuthentication();
1003app.UseAuthorization();
1004
1005app.MapMcp().RequireAuthorization();
1006
1007Console.WriteLine($"Starting MCP server with authorization at {serverUrl}");
1008Console.WriteLine($"Using Keycloak server at {authorizationServerUrl}");
1009Console.WriteLine($"Protected Resource Metadata URL: {serverUrl}.well-known/oauth-protected-resource");
1010Console.WriteLine("Exposed Math tools: Add, Multiply");
1011Console.WriteLine("Press Ctrl+C to stop the server");
1012
1013app.Run(serverUrl);
1014```
1015
1016For more details, see the [C# SDK documentation](https://github.com/modelcontextprotocol/csharp-sdk).
10311017
10321018## Testing the MCP Server
10331019
from line 1030
10441030
10451031On connection, you will be taken to the browser, where you will be prompted to consent to Visual Studio Code having access to the `mcp:tools` scope.
10461032
1047<Frame>
1048 <img src="https://mintcdn.com/mcp/sAd4SGUO-cEUqgzn/images/tutorial-authorization/keycloak-vscode.png?fit=max&auto=format&n=sAd4SGUO-cEUqgzn&q=85&s=d5183fb7c257993aed1b2246f0bbbb27" alt="Keycloak consent form for VS Code." width="1915" height="1536" data-path="images/tutorial-authorization/keycloak-vscode.png" />
1049</Frame>
1033
10501034
10511035After consenting, you will see the tools listed right above the server entry in `mcp.json`.
10521036
1053<Frame>
1054 <img src="https://mintcdn.com/mcp/sAd4SGUO-cEUqgzn/images/tutorial-authorization/tools-vs-code.png?fit=max&auto=format&n=sAd4SGUO-cEUqgzn&q=85&s=f7c34d1bf115fe6934e01b4a5a91168b" alt="Tools listed in VS Code." width="496" height="160" data-path="images/tutorial-authorization/tools-vs-code.png" />
1055</Frame>
1037
10561038
10571039You will be able to invoke individual tools with the help of the `#` sign in the chat view.
10581040
1059<Frame>
1060 <img src="https://mintcdn.com/mcp/sAd4SGUO-cEUqgzn/images/tutorial-authorization/tools-vs-code-invoke.png?fit=max&auto=format&n=sAd4SGUO-cEUqgzn&q=85&s=76cbef68e48821a3c5467bd20c7e89fe" alt="Invoking MCP tools in VS Code." width="1276" height="396" data-path="images/tutorial-authorization/tools-vs-code-invoke.png" />
1061</Frame>
1041
10621042
10631043## Common Pitfalls and How to Avoid Them
10641044
10651045
No line in this hunk matches that.