Follow Discord
Sweep 22 Oct 2026 · 04:32Z Build v2.1.308 527 read Stable v2.1.308 Latest v2.1.308 Next v2.1.308 Feeds RSS JSON llms.txt llms-full.txt Unofficial
One change · mcp

Understanding Authorization in MCP changeddocs/2024-11-05/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+841added
Lines−861removed
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
/
lines

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/2024-11-05/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/2024-11-05/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![Keycloak admin dashboard authentication dialog.](https://mintcdn.com/mcp/sAd4SGUO-cEUqgzn/images/tutorial-authorization/keycloak-browser.png?fit=max\&auto=format\&n=sAd4SGUO-cEUqgzn\&q=85\&s=cba689d986e113cbe937d732ac0558b6)
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![Configuring Keycloak scopes.](https://mintcdn.com/mcp/sAd4SGUO-cEUqgzn/images/tutorial-authorization/keycloak-scopes.png?fit=max\&auto=format\&n=sAd4SGUO-cEUqgzn\&q=85\&s=3cd49dc2e070027609ae495751e0db58)
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![Configuring an audience for a token in Keycloak.](https://mintcdn.com/mcp/sAd4SGUO-cEUqgzn/images/tutorial-authorization/scope-add-audience.gif?s=6ea9cf20c397f4c79c491c2e39019272)
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![Setting up client registration details in Keycloak.](https://mintcdn.com/mcp/sAd4SGUO-cEUqgzn/images/tutorial-authorization/keycloak-client.gif?s=b5d40b36a5f1ea1e818821bb8ea77f6b)
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![Creating a new client in Keycloak.](https://mintcdn.com/mcp/sAd4SGUO-cEUqgzn/images/tutorial-authorization/keycloak-client-auth.gif?s=7152c41a5746994fd399024bc4659e40)
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}/`,
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;
328 console.log(
329 `${req.method} ${req.originalUrl} -> ${res.statusCode} ${ms}ms`,
317330 );
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 };
331 });
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");
333370 }
334371 
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 };
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);
346379 }
347380 
348 const app = express();
349 
350 app.use(
351 express.json({
352 verify: (req: any, _res, buf) => {
353 req.rawBody = buf?.toString() ?? "";
381 let response: Response;
382 try {
383 response = await fetch(endpoint, {
384 method: "POST",
385 headers: {
386 "Content-Type": "application/x-www-form-urlencoded",
354387 },
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 checkResourceAllowed({
431 requestedResource: a,
432 configuredResource: mcpServerUrl,
355433 }),
356434 );
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"],
435 if (!allowed) {
436 throw new Error(
437 `None of the provided audiences are allowed. Expected ${mcpServerUrl}, got: ${audiences.join(", ")}`,
438 );
439 }
440 
441 return {
442 token,
443 clientId: data.client_id,
444 scopes: data.scope ? data.scope.split(" ") : [],
445 expiresAt: data.exp,
373446 };
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");
447 },
448};
449app.use(
450 mcpAuthMetadataRouter({
451 oauthMetadata,
452 resourceServerUrl: mcpServerUrl,
453 scopesSupported: ["mcp:tools"],
454 resourceName: "MCP Demo Server",
455 }),
456);
457 
458const authMiddleware = requireBearerAuth({
459 verifier: tokenVerifier,
460 requiredScopes: [],
461 resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
462});
463 
464const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
465 
466function createMcpServer() {
467 const server = new McpServer({
468 name: "example-server",
469 version: "1.0.0",
470 });
471 
472 server.registerTool(
473 "add",
474 {
475 title: "Addition Tool",
476 description: "Add two numbers together",
477 inputSchema: {
478 a: z.number().describe("First number to add"),
479 b: z.number().describe("Second number to add"),
480 },
481 },
482 async ({ a, b }) => ({
483 content: [{ type: "text", text: `${a} + ${b} = ${a + b}` }],
484 }),
485 );
486 
487 server.registerTool(
488 "multiply",
489 {
490 title: "Multiplication Tool",
491 description: "Multiply two numbers together",
492 inputSchema: {
493 x: z.number().describe("First number to multiply"),
494 y: z.number().describe("Second number to multiply"),
495 },
496 },
497 async ({ x, y }) => ({
498 content: [{ type: "text", text: `${x} × ${y} = ${x * y}` }],
499 }),
500 );
501 
502 return server;
503}
504 
505const mcpPostHandler = async (req: express.Request, res: express.Response) => {
506 const sessionId = req.headers["mcp-session-id"] as string | undefined;
507 let transport: StreamableHTTPServerTransport;
508 
509 if (sessionId && transports[sessionId]) {
510 transport = transports[sessionId];
511 } else if (!sessionId && isInitializeRequest(req.body)) {
512 transport = new StreamableHTTPServerTransport({
513 sessionIdGenerator: () => randomUUID(),
514 onsessioninitialized: (sessionId) => {
515 transports[sessionId] = transport;
516 },
517 });
518 
519 transport.onclose = () => {
520 if (transport.sessionId) {
521 delete transports[transport.sessionId];
522 }
523 };
524 
525 const server = createMcpServer();
526 await server.connect(transport);
527 } else {
528 res.status(400).json({
529 jsonrpc: "2.0",
530 error: {
531 code: -32000,
532 message: "Bad Request: No valid session ID provided",
533 },
534 id: null,
535 });
536 return;
537 }
538 
539 await transport.handleRequest(req, res, req.body);
540};
541 
542const handleSessionRequest = async (
543 req: express.Request,
544 res: express.Response,
545) => {
546 const sessionId = req.headers["mcp-session-id"] as string | undefined;
547 if (!sessionId || !transports[sessionId]) {
548 res.status(400).send("Invalid or missing session ID");
549 return;
550 }
551 
552 const transport = transports[sessionId];
553 await transport.handleRequest(req, res);
554};
555 
556app.post("/", authMiddleware, mcpPostHandler);
557app.get("/", authMiddleware, handleSessionRequest);
558app.delete("/", authMiddleware, handleSessionRequest);
559 
560app.listen(CONFIG.port, CONFIG.host, () => {
561 console.log(`🚀 MCP Server running on ${mcpServerUrl.origin}`);
562 console.log(`📡 MCP endpoint available at ${mcpServerUrl.origin}`);
563 console.log(
564 `🔐 OAuth metadata available at ${getOAuthProtectedResourceMetadataUrl(mcpServerUrl)}`,
565 );
566});
567```
568 
569When you run the server, you can add it to your MCP client, such as Visual Studio Code, by providing the MCP server endpoint.
570 
571For more details about implementing MCP servers in TypeScript, refer to the [TypeScript SDK documentation](https://github.com/modelcontextprotocol/typescript-sdk).
572 
573**Python**
574 
575You can see the complete Python project in the [sample repository](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-auth).
576 
577To simplify our authorization interaction, in Python scenarios we rely on [FastMCP](https://gofastmcp.com/getting-started/welcome). 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.
578 
579Prior 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:
580 
581```python theme={null}
582"""Configuration settings for the MCP auth server."""
583 
584import os
585from typing import Optional
586 
587 
588class Config:
589 """Configuration class that loads from environment variables with sensible defaults."""
590 
591 # Server settings
592 HOST: str = os.getenv("HOST", "localhost")
593 PORT: int = int(os.getenv("PORT", "3000"))
594 
595 # Auth server settings
596 AUTH_HOST: str = os.getenv("AUTH_HOST", "localhost")
597 AUTH_PORT: int = int(os.getenv("AUTH_PORT", "8080"))
598 AUTH_REALM: str = os.getenv("AUTH_REALM", "master")
599 
600 # OAuth client settings
601 OAUTH_CLIENT_ID: str = os.getenv("OAUTH_CLIENT_ID", "mcp-server")
602 OAUTH_CLIENT_SECRET: str = os.getenv("OAUTH_CLIENT_SECRET", "UO3rmozkFFkXr0QxPTkzZ0LMXDidIikB")
603 
604 # Server settings
605 MCP_SCOPE: str = os.getenv("MCP_SCOPE", "mcp:tools")
606 OAUTH_STRICT: bool = os.getenv("OAUTH_STRICT", "false").lower() in ("true", "1", "yes")
607 TRANSPORT: str = os.getenv("TRANSPORT", "streamable-http")
608 
609 @property
610 def server_url(self) -> str:
611 """Build the server URL."""
612 return f"http://{self.HOST}:{self.PORT}"
613 
614 @property
615 def auth_base_url(self) -> str:
616 """Build the auth server base URL."""
617 return f"http://{self.AUTH_HOST}:{self.AUTH_PORT}/realms/{self.AUTH_REALM}/"
618 
619 def validate(self) -> None:
620 """Validate configuration."""
621 if self.TRANSPORT not in ["sse", "streamable-http"]:
622 raise ValueError(f"Invalid transport: {self.TRANSPORT}. Must be 'sse' or 'streamable-http'")
623 
624 
625# Global configuration instance
626config = Config()
627 
628```
629 
630The server implementation is as follows:
631 
632```python theme={null}
633import datetime
634import logging
635from typing import Any
636 
637from pydantic import AnyHttpUrl
638 
639from mcp.server.auth.settings import AuthSettings
640from mcp.server.fastmcp.server import FastMCP
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 from urllib.parse import urljoin
651 
652 auth_base_url = config.auth_base_url
653 
654 return {
655 "issuer": auth_base_url,
656 "introspection_endpoint": urljoin(auth_base_url, "protocol/openid-connect/token/introspect"),
657 "authorization_endpoint": urljoin(auth_base_url, "protocol/openid-connect/auth"),
658 "token_endpoint": urljoin(auth_base_url, "protocol/openid-connect/token"),
659 }
660 
661 
662def create_server() -> FastMCP:
663 """Create and configure the FastMCP server."""
664 
665 config.validate()
666 
667 oauth_urls = create_oauth_urls()
668 
669 token_verifier = IntrospectionTokenVerifier(
670 introspection_endpoint=oauth_urls["introspection_endpoint"],
671 server_url=config.server_url,
672 client_id=config.OAUTH_CLIENT_ID,
673 client_secret=config.OAUTH_CLIENT_SECRET,
674 )
675 
676 app = FastMCP(
677 name="MCP Resource Server",
678 instructions="Resource Server that validates tokens via Authorization Server introspection",
679 host=config.HOST,
680 port=config.PORT,
681 debug=True,
682 streamable_http_path="/",
683 token_verifier=token_verifier,
684 auth=AuthSettings(
685 issuer_url=AnyHttpUrl(oauth_urls["issuer"]),
686 required_scopes=[config.MCP_SCOPE],
687 resource_server_url=AnyHttpUrl(config.server_url),
688 ),
689 )
690 
691 @app.tool()
692 async def add_numbers(a: float, b: float) -> dict[str, Any]:
693 """
694 Add two numbers together.
695 This tool demonstrates basic arithmetic operations with OAuth authentication.
696 
697 Args:
698 a: The first number to add
699 b: The second number to add
700 """
701 result = a + b
702 return {
703 "operation": "addition",
704 "operand_a": a,
705 "operand_b": b,
706 "result": result,
707 "timestamp": datetime.datetime.now().isoformat()
382708 }
383709 
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);
710 @app.tool()
711 async def multiply_numbers(x: float, y: float) -> dict[str, Any]:
712 """
713 Multiply two numbers together.
714 This tool demonstrates basic arithmetic operations with OAuth authentication.
715 
716 Args:
717 x: The first number to multiply
718 y: The second number to multiply
719 """
720 result = x * y
721 return {
722 "operation": "multiplication",
723 "operand_x": x,
724 "operand_y": y,
725 "result": result,
726 "timestamp": datetime.datetime.now().isoformat()
391727 }
392728 
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;
729 return app
730 
731 
732def main() -> int:
733 """
734 Run the MCP Resource Server.
735 
736 This server:
737 - Provides RFC 9728 Protected Resource Metadata
738 - Validates tokens via Authorization Server introspection
739 - Serves MCP tools requiring authentication
740 
741 Configuration is loaded from config.py and environment variables.
742 """
743 logging.basicConfig(level=logging.INFO)
744 
745 try:
746 config.validate()
747 oauth_urls = create_oauth_urls()
748 
749 except ValueError as e:
750 logger.error("Configuration error: %s", e)
751 return 1
752 
753 try:
754 mcp_server = create_server()
755 
756 logger.info("Starting MCP Server on %s:%s", config.HOST, config.PORT)
757 logger.info("Authorization Server: %s", oauth_urls["issuer"])
758 logger.info("Transport: %s", config.TRANSPORT)
759 
760 mcp_server.run(transport=config.TRANSPORT)
761 return 0
762 
763 except Exception:
764 logger.exception("Server error")
765 return 1
766 
767 
768if __name__ == "__main__":
769 exit(main())
770```
771 
772Lastly, 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
773 
774```python theme={null}
775"""Token verifier implementation using OAuth 2.0 Token Introspection (RFC 7662)."""
776 
777import logging
778from typing import Any
779 
780from mcp.server.auth.provider import AccessToken, TokenVerifier
781from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url
782 
783logger = logging.getLogger(__name__)
784 
785 
786class IntrospectionTokenVerifier(TokenVerifier):
787 """Token verifier that uses OAuth 2.0 Token Introspection (RFC 7662).
788 """
789 
790 def __init__(
791 self,
792 introspection_endpoint: str,
793 server_url: str,
794 client_id: str,
795 client_secret: str,
796 ):
797 self.introspection_endpoint = introspection_endpoint
798 self.server_url = server_url
799 self.client_id = client_id
800 self.client_secret = client_secret
801 self.resource_url = resource_url_from_server_url(server_url)
802 
803 async def verify_token(self, token: str) -> AccessToken | None:
804 """Verify token via introspection endpoint."""
805 import httpx
806 
807 if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")):
808 return None
809 
810 timeout = httpx.Timeout(10.0, connect=5.0)
811 limits = httpx.Limits(max_connections=10, max_keepalive_connections=5)
812 
813 async with httpx.AsyncClient(
814 timeout=timeout,
815 limits=limits,
816 verify=True,
817 ) as client:
818 try:
819 form_data = {
820 "token": token,
821 "client_id": self.client_id,
822 "client_secret": self.client_secret,
823 }
824 headers = {"Content-Type": "application/x-www-form-urlencoded"}
825 
826 response = await client.post(
827 self.introspection_endpoint,
828 data=form_data,
829 headers=headers,
830 )
831 
832 if response.status_code != 200:
833 return None
834 
835 data = response.json()
836 if not data.get("active", False):
837 return None
838 
839 if not self._validate_resource(data):
840 return None
841 
842 return AccessToken(
843 token=token,
844 client_id=data.get("client_id", "unknown"),
845 scopes=data.get("scope", "").split() if data.get("scope") else [],
846 expires_at=data.get("exp"),
847 resource=data.get("aud"), # Include resource in token
848 )
849 
850 except Exception as e:
851 return None
852 
853 def _validate_resource(self, token_data: dict[str, Any]) -> bool:
854 """Validate token was issued for this resource server.
855 
856 Rules:
857 - Reject if 'aud' missing.
858 - Accept if any audience entry matches the derived resource URL.
859 - Supports string or list forms per JWT spec.
860 """
861 if not self.server_url or not self.resource_url:
862 return False
863 
864 aud: list[str] | str | None = token_data.get("aud")
865 if isinstance(aud, list):
866 return any(self._is_valid_resource(a) for a in aud)
867 if isinstance(aud, str):
868 return self._is_valid_resource(aud)
869 return False
870 
871 def _is_valid_resource(self, resource: str) -> bool:
872 """Check if the given resource matches our server."""
873 return check_resource_allowed(self.resource_url, resource)
874```
875 
876For more details, see the [Python SDK documentation](https://github.com/modelcontextprotocol/python-sdk).
877 
878**C#**
879 
880You can see the complete C# project in the [sample repository](https://github.com/localden/min-cs-mcp-auth).
881 
882To 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.
883 
884```csharp theme={null}
885using Microsoft.AspNetCore.Authentication.JwtBearer;
886using Microsoft.IdentityModel.Tokens;
887using ModelContextProtocol.AspNetCore.Authentication;
888using ProtectedMcpServer.Tools;
889using System.Security.Claims;
890 
891var builder = WebApplication.CreateBuilder(args);
892 
893var serverUrl = "http://localhost:3000/";
894var authorizationServerUrl = "http://localhost:8080/realms/master/";
895 
896builder.Services.AddAuthentication(options =>
897{
898 options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
899 options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
900})
901.AddJwtBearer(options =>
902{
903 options.Authority = authorizationServerUrl;
904 var normalizedServerAudience = serverUrl.TrimEnd('/');
905 options.TokenValidationParameters = new TokenValidationParameters
906 {
907 ValidIssuer = authorizationServerUrl,
908 ValidAudiences = new[] { normalizedServerAudience, serverUrl },
909 AudienceValidator = (audiences, securityToken, validationParameters) =>
910 {
911 if (audiences == null) return false;
912 foreach (var aud in audiences)
913 {
914 if (string.Equals(aud.TrimEnd('/'), normalizedServerAudience, StringComparison.OrdinalIgnoreCase))
915 {
916 return true;
917 }
918 }
919 return false;
405920 }
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 checkResourceAllowed({
443 requestedResource: a,
444 configuredResource: mcpServerUrl,
445 }),
446 );
447 if (!allowed) {
448 throw new Error(
449 `None of the provided audiences are allowed. Expected ${mcpServerUrl}, got: ${audiences.join(", ")}`,
450 );
451 }
452 
453 return {
454 token,
455 clientId: data.client_id,
456 scopes: data.scope ? data.scope.split(" ") : [],
457 expiresAt: data.exp,
458 };
459 },
460921 };
461 app.use(
462 mcpAuthMetadataRouter({
463 oauthMetadata,
464 resourceServerUrl: mcpServerUrl,
465 scopesSupported: ["mcp:tools"],
466 resourceName: "MCP Demo Server",
467 }),
468 );
469 
470 const authMiddleware = requireBearerAuth({
471 verifier: tokenVerifier,
472 requiredScopes: [],
473 resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
474 });
475 
476 const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
477 
478 function createMcpServer() {
479 const server = new McpServer({
480 name: "example-server",
481 version: "1.0.0",
482 });
483 
484 server.registerTool(
485 "add",
922 
923 options.RequireHttpsMetadata = false; // Set to true in production
924 
925 options.Events = new JwtBearerEvents
926 {
927 OnTokenValidated = context =>
486928 {
487 title: "Addition Tool",
488 description: "Add two numbers together",
489 inputSchema: {
490 a: z.number().describe("First number to add"),
491 b: z.number().describe("Second number to add"),
492 },
929 var name = context.Principal?.Identity?.Name ?? "unknown";
930 var email = context.Principal?.FindFirstValue("preferred_username") ?? "unknown";
931 Console.WriteLine($"Token validated for: {name} ({email})");
932 return Task.CompletedTask;
493933 },
494 async ({ a, b }) => ({
495 content: [{ type: "text", text: `${a} + ${b} = ${a + b}` }],
496 }),
497 );
498 
499 server.registerTool(
500 "multiply",
934 OnAuthenticationFailed = context =>
501935 {
502 title: "Multiplication Tool",
503 description: "Multiply two numbers together",
504 inputSchema: {
505 x: z.number().describe("First number to multiply"),
506 y: z.number().describe("Second number to multiply"),
507 },
936 Console.WriteLine($"Authentication failed: {context.Exception.Message}");
937 return Task.CompletedTask;
508938 },
509 async ({ x, y }) => ({
510 content: [{ type: "text", text: `${x} × ${y} = ${x * y}` }],
511 }),
512 );
513 
514 return server;
515 }
516 
517 const mcpPostHandler = async (req: express.Request, res: express.Response) => {
518 const sessionId = req.headers["mcp-session-id"] as string | undefined;
519 let transport: StreamableHTTPServerTransport;
520 
521 if (sessionId && transports[sessionId]) {
522 transport = transports[sessionId];
523 } else if (!sessionId && isInitializeRequest(req.body)) {
524 transport = new StreamableHTTPServerTransport({
525 sessionIdGenerator: () => randomUUID(),
526 onsessioninitialized: (sessionId) => {
527 transports[sessionId] = transport;
528 },
529 });
530 
531 transport.onclose = () => {
532 if (transport.sessionId) {
533 delete transports[transport.sessionId];
534 }
535 };
536 
537 const server = createMcpServer();
538 await server.connect(transport);
539 } else {
540 res.status(400).json({
541 jsonrpc: "2.0",
542 error: {
543 code: -32000,
544 message: "Bad Request: No valid session ID provided",
545 },
546 id: null,
547 });
548 return;
549 }
550 
551 await transport.handleRequest(req, res, req.body);
552939 };
553 
554 const handleSessionRequest = async (
555 req: express.Request,
556 res: express.Response,
557 ) => {
558 const sessionId = req.headers["mcp-session-id"] as string | undefined;
559 if (!sessionId || !transports[sessionId]) {
560 res.status(400).send("Invalid or missing session ID");
561 return;
562 }
563 
564 const transport = transports[sessionId];
565 await transport.handleRequest(req, res);
940})
941.AddMcp(options =>
942{
943 options.ResourceMetadata = new()
944 {
945 Resource = new Uri(serverUrl),
946 ResourceDocumentation = new Uri("https://docs.example.com/api/math"),
947 AuthorizationServers = { new Uri(authorizationServerUrl) },
948 ScopesSupported = ["mcp:tools"]
566949 };
567 
568 app.post("/", authMiddleware, mcpPostHandler);
569 app.get("/", authMiddleware, handleSessionRequest);
570 app.delete("/", authMiddleware, handleSessionRequest);
571 
572 app.listen(CONFIG.port, CONFIG.host, () => {
573 console.log(`🚀 MCP Server running on ${mcpServerUrl.origin}`);
574 console.log(`📡 MCP endpoint available at ${mcpServerUrl.origin}`);
575 console.log(
576 `🔐 OAuth metadata available at ${getOAuthProtectedResourceMetadataUrl(mcpServerUrl)}`,
577 );
578 });
579 ```
580 
581 When you run the server, you can add it to your MCP client, such as Visual Studio Code, by providing the MCP server endpoint.
582 
583 For more details about implementing MCP servers in TypeScript, refer to the [TypeScript SDK documentation](https://github.com/modelcontextprotocol/typescript-sdk).
584 </Tab>
585 
586 <Tab title="Python">
587 You can see the complete Python project in the [sample repository](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-auth).
588 
589 To simplify our authorization interaction, in Python scenarios we rely on [FastMCP](https://gofastmcp.com/getting-started/welcome). 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.
590 
591 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:
592 
593 ```python theme={null}
594 """Configuration settings for the MCP auth server."""
595 
596 import os
597 from typing import Optional
598 
599 
600 class Config:
601 """Configuration class that loads from environment variables with sensible defaults."""
602 
603 # Server settings
604 HOST: str = os.getenv("HOST", "localhost")
605 PORT: int = int(os.getenv("PORT", "3000"))
606 
607 # Auth server settings
608 AUTH_HOST: str = os.getenv("AUTH_HOST", "localhost")
609 AUTH_PORT: int = int(os.getenv("AUTH_PORT", "8080"))
610 AUTH_REALM: str = os.getenv("AUTH_REALM", "master")
611 
612 # OAuth client settings
613 OAUTH_CLIENT_ID: str = os.getenv("OAUTH_CLIENT_ID", "mcp-server")
614 OAUTH_CLIENT_SECRET: str = os.getenv("OAUTH_CLIENT_SECRET", "UO3rmozkFFkXr0QxPTkzZ0LMXDidIikB")
615 
616 # Server settings
617 MCP_SCOPE: str = os.getenv("MCP_SCOPE", "mcp:tools")
618 OAUTH_STRICT: bool = os.getenv("OAUTH_STRICT", "false").lower() in ("true", "1", "yes")
619 TRANSPORT: str = os.getenv("TRANSPORT", "streamable-http")
620 
621 @property
622 def server_url(self) -> str:
623 """Build the server URL."""
624 return f"http://{self.HOST}:{self.PORT}"
625 
626 @property
627 def auth_base_url(self) -> str:
628 """Build the auth server base URL."""
629 return f"http://{self.AUTH_HOST}:{self.AUTH_PORT}/realms/{self.AUTH_REALM}/"
630 
631 def validate(self) -> None:
632 """Validate configuration."""
633 if self.TRANSPORT not in ["sse", "streamable-http"]:
634 raise ValueError(f"Invalid transport: {self.TRANSPORT}. Must be 'sse' or 'streamable-http'")
635 
636 
637 # Global configuration instance
638 config = Config()
639 
640 ```
641 
642 The server implementation is as follows:
643 
644 ```python theme={null}
645 import datetime
646 import logging
647 from typing import Any
648 
649 from pydantic import AnyHttpUrl
650 
651 from mcp.server.auth.settings import AuthSettings
652 from mcp.server.fastmcp.server import FastMCP
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 from urllib.parse import urljoin
663 
664 auth_base_url = config.auth_base_url
665 
666 return {
667 "issuer": auth_base_url,
668 "introspection_endpoint": urljoin(auth_base_url, "protocol/openid-connect/token/introspect"),
669 "authorization_endpoint": urljoin(auth_base_url, "protocol/openid-connect/auth"),
670 "token_endpoint": urljoin(auth_base_url, "protocol/openid-connect/token"),
671 }
672 
673 
674 def create_server() -> FastMCP:
675 """Create and configure the FastMCP server."""
676 
677 config.validate()
678 
679 oauth_urls = create_oauth_urls()
680 
681 token_verifier = IntrospectionTokenVerifier(
682 introspection_endpoint=oauth_urls["introspection_endpoint"],
683 server_url=config.server_url,
684 client_id=config.OAUTH_CLIENT_ID,
685 client_secret=config.OAUTH_CLIENT_SECRET,
686 )
687 
688 app = FastMCP(
689 name="MCP Resource Server",
690 instructions="Resource Server that validates tokens via Authorization Server introspection",
691 host=config.HOST,
692 port=config.PORT,
693 debug=True,
694 streamable_http_path="/",
695 token_verifier=token_verifier,
696 auth=AuthSettings(
697 issuer_url=AnyHttpUrl(oauth_urls["issuer"]),
698 required_scopes=[config.MCP_SCOPE],
699 resource_server_url=AnyHttpUrl(config.server_url),
700 ),
701 )
702 
703 @app.tool()
704 async def add_numbers(a: float, b: float) -> dict[str, Any]:
705 """
706 Add two numbers together.
707 This tool demonstrates basic arithmetic operations with OAuth authentication.
708 
709 Args:
710 a: The first number to add
711 b: The second number to add
712 """
713 result = a + b
714 return {
715 "operation": "addition",
716 "operand_a": a,
717 "operand_b": b,
718 "result": result,
719 "timestamp": datetime.datetime.now().isoformat()
720 }
721 
722 @app.tool()
723 async def multiply_numbers(x: float, y: float) -> dict[str, Any]:
724 """
725 Multiply two numbers together.
726 This tool demonstrates basic arithmetic operations with OAuth authentication.
727 
728 Args:
729 x: The first number to multiply
730 y: The second number to multiply
731 """
732 result = x * y
733 return {
734 "operation": "multiplication",
735 "operand_x": x,
736 "operand_y": y,
737 "result": result,
738 "timestamp": datetime.datetime.now().isoformat()
739 }
740 
741 return app
742 
743 
744 def main() -> int:
745 """
746 Run the MCP Resource Server.
747 
748 This server:
749 - Provides RFC 9728 Protected Resource Metadata
750 - Validates tokens via Authorization Server introspection
751 - Serves MCP tools requiring authentication
752 
753 Configuration is loaded from config.py and environment variables.
754 """
755 logging.basicConfig(level=logging.INFO)
756 
757 try:
758 config.validate()
759 oauth_urls = create_oauth_urls()
760 
761 except ValueError as e:
762 logger.error("Configuration error: %s", e)
763 return 1
764 
765 try:
766 mcp_server = create_server()
767 
768 logger.info("Starting MCP Server on %s:%s", config.HOST, config.PORT)
769 logger.info("Authorization Server: %s", oauth_urls["issuer"])
770 logger.info("Transport: %s", config.TRANSPORT)
771 
772 mcp_server.run(transport=config.TRANSPORT)
773 return 0
774 
775 except Exception:
776 logger.exception("Server error")
777 return 1
778 
779 
780 if __name__ == "__main__":
781 exit(main())
782 ```
783 
784 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
785 
786 ```python theme={null}
787 """Token verifier implementation using OAuth 2.0 Token Introspection (RFC 7662)."""
788 
789 import logging
790 from typing import Any
791 
792 from mcp.server.auth.provider import AccessToken, TokenVerifier
793 from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url
794 
795 logger = logging.getLogger(__name__)
796 
797 
798 class IntrospectionTokenVerifier(TokenVerifier):
799 """Token verifier that uses OAuth 2.0 Token Introspection (RFC 7662).
800 """
801 
802 def __init__(
803 self,
804 introspection_endpoint: str,
805 server_url: str,
806 client_id: str,
807 client_secret: str,
808 ):
809 self.introspection_endpoint = introspection_endpoint
810 self.server_url = server_url
811 self.client_id = client_id
812 self.client_secret = client_secret
813 self.resource_url = resource_url_from_server_url(server_url)
814 
815 async def verify_token(self, token: str) -> AccessToken | None:
816 """Verify token via introspection endpoint."""
817 import httpx
818 
819 if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")):
820 return None
821 
822 timeout = httpx.Timeout(10.0, connect=5.0)
823 limits = httpx.Limits(max_connections=10, max_keepalive_connections=5)
824 
825 async with httpx.AsyncClient(
826 timeout=timeout,
827 limits=limits,
828 verify=True,
829 ) as client:
830 try:
831 form_data = {
832 "token": token,
833 "client_id": self.client_id,
834 "client_secret": self.client_secret,
835 }
836 headers = {"Content-Type": "application/x-www-form-urlencoded"}
837 
838 response = await client.post(
839 self.introspection_endpoint,
840 data=form_data,
841 headers=headers,
842 )
843 
844 if response.status_code != 200:
845 return None
846 
847 data = response.json()
848 if not data.get("active", False):
849 return None
850 
851 if not self._validate_resource(data):
852 return None
853 
854 return AccessToken(
855 token=token,
856 client_id=data.get("client_id", "unknown"),
857 scopes=data.get("scope", "").split() if data.get("scope") else [],
858 expires_at=data.get("exp"),
859 resource=data.get("aud"), # Include resource in token
860 )
861 
862 except Exception as e:
863 return None
864 
865 def _validate_resource(self, token_data: dict[str, Any]) -> bool:
866 """Validate token was issued for this resource server.
867 
868 Rules:
869 - Reject if 'aud' missing.
870 - Accept if any audience entry matches the derived resource URL.
871 - Supports string or list forms per JWT spec.
872 """
873 if not self.server_url or not self.resource_url:
874 return False
875 
876 aud: list[str] | str | None = token_data.get("aud")
877 if isinstance(aud, list):
878 return any(self._is_valid_resource(a) for a in aud)
879 if isinstance(aud, str):
880 return self._is_valid_resource(aud)
881 return False
882 
883 def _is_valid_resource(self, resource: str) -> bool:
884 """Check if the given resource matches our server."""
885 return check_resource_allowed(self.resource_url, resource)
886 ```
887 
888 For more details, see the [Python SDK documentation](https://github.com/modelcontextprotocol/python-sdk).
889 </Tab>
890 
891 <Tab title="C#">
892 You can see the complete C# project in the [sample repository](https://github.com/localden/min-cs-mcp-auth).
893 
894 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.
895 
896 ```csharp theme={null}
897 using Microsoft.AspNetCore.Authentication.JwtBearer;
898 using Microsoft.IdentityModel.Tokens;
899 using ModelContextProtocol.AspNetCore.Authentication;
900 using ProtectedMcpServer.Tools;
901 using System.Security.Claims;
902 
903 var builder = WebApplication.CreateBuilder(args);
904 
905 var serverUrl = "http://localhost:3000/";
906 var authorizationServerUrl = "http://localhost:8080/realms/master/";
907 
908 builder.Services.AddAuthentication(options =>
909 {
910 options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
911 options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
912 })
913 .AddJwtBearer(options =>
914 {
915 options.Authority = authorizationServerUrl;
916 var normalizedServerAudience = serverUrl.TrimEnd('/');
917 options.TokenValidationParameters = new TokenValidationParameters
918 {
919 ValidIssuer = authorizationServerUrl,
920 ValidAudiences = new[] { normalizedServerAudience, serverUrl },
921 AudienceValidator = (audiences, securityToken, validationParameters) =>
922 {
923 if (audiences == null) return false;
924 foreach (var aud in audiences)
925 {
926 if (string.Equals(aud.TrimEnd('/'), normalizedServerAudience, StringComparison.OrdinalIgnoreCase))
927 {
928 return true;
929 }
930 }
931 return false;
932 }
933 };
934 
935 options.RequireHttpsMetadata = false; // Set to true in production
936 
937 options.Events = new JwtBearerEvents
938 {
939 OnTokenValidated = context =>
940 {
941 var name = context.Principal?.Identity?.Name ?? "unknown";
942 var email = context.Principal?.FindFirstValue("preferred_username") ?? "unknown";
943 Console.WriteLine($"Token validated for: {name} ({email})");
944 return Task.CompletedTask;
945 },
946 OnAuthenticationFailed = context =>
947 {
948 Console.WriteLine($"Authentication failed: {context.Exception.Message}");
949 return Task.CompletedTask;
950 },
951 };
952 })
953 .AddMcp(options =>
954 {
955 options.ResourceMetadata = new()
956 {
957 Resource = new Uri(serverUrl),
958 ResourceDocumentation = new Uri("https://docs.example.com/api/math"),
959 AuthorizationServers = { new Uri(authorizationServerUrl) },
960 ScopesSupported = ["mcp:tools"]
961 };
962 });
963 
964 builder.Services.AddAuthorization();
965 
966 builder.Services.AddHttpContextAccessor();
967 builder.Services.AddMcpServer()
968 .WithTools<MathTools>()
969 .WithHttpTransport();
970 
971 var app = builder.Build();
972 
973 app.UseAuthentication();
974 app.UseAuthorization();
975 
976 app.MapMcp().RequireAuthorization();
977 
978 Console.WriteLine($"Starting MCP server with authorization at {serverUrl}");
979 Console.WriteLine($"Using Keycloak server at {authorizationServerUrl}");
980 Console.WriteLine($"Protected Resource Metadata URL: {serverUrl}.well-known/oauth-protected-resource");
981 Console.WriteLine("Exposed Math tools: Add, Multiply");
982 Console.WriteLine("Press Ctrl+C to stop the server");
983 
984 app.Run(serverUrl);
985 ```
986 
987 For more details, see the [C# SDK documentation](https://github.com/modelcontextprotocol/csharp-sdk).
988 </Tab>
989</Tabs>
950});
951 
952builder.Services.AddAuthorization();
953 
954builder.Services.AddHttpContextAccessor();
955builder.Services.AddMcpServer()
956 .WithTools<MathTools>()
957 .WithHttpTransport();
958 
959var app = builder.Build();
960 
961app.UseAuthentication();
962app.UseAuthorization();
963 
964app.MapMcp().RequireAuthorization();
965 
966Console.WriteLine($"Starting MCP server with authorization at {serverUrl}");
967Console.WriteLine($"Using Keycloak server at {authorizationServerUrl}");
968Console.WriteLine($"Protected Resource Metadata URL: {serverUrl}.well-known/oauth-protected-resource");
969Console.WriteLine("Exposed Math tools: Add, Multiply");
970Console.WriteLine("Press Ctrl+C to stop the server");
971 
972app.Run(serverUrl);
973```
974 
975For more details, see the [C# SDK documentation](https://github.com/modelcontextprotocol/csharp-sdk).
990976 
991977## Testing the MCP Server
992978 
from line 989
1003989 
1004990On 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.
1005991 
1006<Frame>
1007 <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" />
1008</Frame>
992![Keycloak consent form for VS Code.](https://mintcdn.com/mcp/sAd4SGUO-cEUqgzn/images/tutorial-authorization/keycloak-vscode.png?fit=max\&auto=format\&n=sAd4SGUO-cEUqgzn\&q=85\&s=d5183fb7c257993aed1b2246f0bbbb27)
1009993 
1010994After consenting, you will see the tools listed right above the server entry in `mcp.json`.
1011995 
1012<Frame>
1013 <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" />
1014</Frame>
996![Tools listed in VS Code.](https://mintcdn.com/mcp/sAd4SGUO-cEUqgzn/images/tutorial-authorization/tools-vs-code.png?fit=max\&auto=format\&n=sAd4SGUO-cEUqgzn\&q=85\&s=f7c34d1bf115fe6934e01b4a5a91168b)
1015997 
1016998You will be able to invoke individual tools with the help of the `#` sign in the chat view.
1017999 
1018<Frame>
1019 <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" />
1020</Frame>
1000![Invoking MCP tools in VS Code.](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)
10211001 
10221002## Common Pitfalls and How to Avoid Them
10231003 
10241004 
Feedback