Sitemap

Build Remote MCP with Authorization

40 min readJul 14, 2025

On June 3, 2025, Anthropic announced that “integrations” are now available in Claude with the most affordable paid Pro plan:

While integrations were first announced back on May 1, 2025, this feature was initially limited to more expensive enterprise-grade plans. I believe the rollout to the broader user base, even if it’s still a paid tier, marks a fundamental shift in how AI assistants are designed, built, and used — one that other vendors will inevitably follow.

“Integrations” is Anthropic’s term for remote Model Context Protocol (MCP) servers, a protocol Anthropic open-sourced back in November 2024. If you’ve used Claude desktop, you might have tried MCP servers before, but those were local installations communicating through Standard Input/Output. That works fine for testing, but asking users to install and configure local servers? That’s really just for developers and tinkerers like us. Remote servers change the game entirely — they’re just APIs that AI Assistants can talk to, no local setup required.

As a software architect and engineer focused on building applications, I’ve been anticipating this for a while: it represents a fundamental architectural shift from both technical and organizational perspectives. AI assistants no longer need to be monolithic systems implementing integrations, but can now delegate domain-specific operations to the experts who built and operate those systems instead, similar to what microservices brought to monolithic services — truly enabling scale that can drive real growth in AI-enabled systems.

So when the rollout happened, I immediately started playing around with building my own remote MCP server. That’s when I hit a roadblock: while the MCP specification covers authorization in theory, practical implementation guidance is surprisingly limited. Most examples just point you toward OAuth providers, but what if you want or need something custom? I’ve been tinkering with this ever since, building a sample implementation that actually works with Claude. This article walks through what that looks like in practice.

Technical details upfront: We’ll build a complete remote MCP server supporting OAuth 2.1 authorization according to the official documentation using vanilla JavaScript (Node.js) for the Authorization Server and TypeScript with Express.js and the official TypeScript SDK for the MCP Server, implementing both Server-Sent Events (SSE) and Streamable HTTP transports.

This article walks through the complete implementation for every piece of the authorization flow from server metadata discovery to passing authentication context through to your MCP tools, with a working “Battle School Computer” example that demonstrates personalized AI responses based on authenticated user data.

The complete working implementation is available on GitHub at https://github.com/loginov-rocks/Remote-MCP-Auth, providing a solid foundation for building your own remote MCP with authorization, so good luck!

Contents

  1. Why This Matters
    1.1. Problem
    1.2. What MCP Changes
    1.3. Commoditization Angle
    1.4. Local vs Remote
    1.5. Authorization Challenge
    1.6. Documentation Gap
  2. Solution Overview
    2.1. Component View
    2.2. Interaction Flow
    2.3. Technical Stack
    2.4. Battle School Computer
  3. Authorization Server
    3.1. User Experience
    3.2. Behind the Scenes
    3.3. Endpoints Summary
    3.4. Server Metadata Discovery Endpoint
    3.5. Dynamic Client Registration Endpoint
    3.6. Authorization Endpoint
    3.7. Authorization Processing Endpoint
    3.8. Token Endpoint
    3.9. Authenticated MCP Session Establishment
  4. MCP Server
    4.1. File Structure
    4.2. Router
    4.3. OAuth Controller
    4.4. MCP Auth Middleware
    4.5. MCP SSE Controller
    4.6. MCP Streamable Controller
    4.7. Services
    4.8. MCP Server
    4.9. Testing with MCP Inspector
  5. Deployment
    5.1. Authorization Server
    5.2. MCP Server
  6. Testing
  7. Conclusion

1. Why This Matters

The MCP protocol itself is well documented, so I won’t repeat those details. Instead, I want to use this opportunity and share why remote MCP server support represents such a significant shift in how we build AI-powered applications — and why it took me down this rabbit hole of building and writing about my own implementation.

1.1. Problem

Before MCP, AI vendors like Anthropic, OpenAI, Google, and the rest had to build every integration in-house to turn chatbots into capable AI Assistants: want your AI assistant to work with Google Workspace? Someone at the AI company needs to build and maintain that connector. Office 365? Another team, another integration. And so on, you get the idea.

As a developer building AI applications, I’ve had to integrate LLMs directly with my data sources using techniques like RAG to provide users with advanced functionality. It works, but it’s a lot of custom code for every chosen AI model instead of what should be standard connectivity.

This creates the same tight coupling we learned to avoid in monolithic applications. AI vendors need knowledge of every product they integrate with. Service providers can’t easily enable AI integration without coordinating with multiple AI vendors. And users? They’re stuck with whatever integrations their chosen AI vendor decided to prioritize.

1.2. What MCP Changes

With MCP, we get true separation of concerns. AI vendors focus on reasoning and orchestration, while service providers focus on their domain expertise. Having built integrations the hard way, this feels revolutionary to me.

Here’s what this means in practice: you build one MCP server, and it immediately works with Claude, ChatGPT, and whatever AI assistant emerges next month. No more rebuilding the same connector for every AI vendor. No more waiting for AI companies to prioritize your integration.

This shift represents profound benefits both organizationally and architecturally:

  • Scalability: services built once, work everywhere. AI assistants get instant access to the entire ecosystem of MCP-enabled services without implementing each integration from scratch.
  • Interoperability: users and developers can finally mix and match AI assistants with services instead of being locked into whatever their AI vendor prioritized.
  • Maintainability: domain expertise stays where it belongs — Slack engineers writing Slack integrations, GitHub engineers handling Git workflows.
  • Modifiability: services can evolve their MCP endpoints independently, without waiting for AI vendor release cycles.
  • Security: each service implements its own authentication and data protection models rather than trusting AI vendors with everything.
  • Testability: each MCP service can be tested independently by the people who know it best.

1.3. Commoditization Angle

Here’s what I think: LLMs are becoming commoditized. Models from different vendors achieve similar reasoning capabilities, which means the competitive advantage shifts from raw model performance to data access and integration capabilities.

In this world, the value isn’t which foundation model you use — it’s what data your AI assistant can access and what actions it can perform on your behalf. MCP accelerates this shift by making integrations portable across AI vendors. You’re no longer stuck with limited integrations just because you prefer a particular AI vendor’s interface.

1.4. Local vs Remote

The difference between local and remote MCP reminds me of the shift from desktop to web applications. Desktop software requires installation, configuration, and maintenance. Web applications eliminated this friction and achieved ubiquitous adoption.

Remote MCP servers deliver the same transformation. Local MCP servers, like desktop systems, remain limited to developers willing to manage installations. Remote MCP servers eliminate this friction entirely: users authenticate with existing services and immediately gain AI integration capabilities.

1.5. Authorization Challenge

This is where things get interesting. Local MCP servers inherit user authentication automatically: they run on your machine with your credentials and access whatever you can access. This “authentication by proximity” works perfectly for local scenarios.

But when servers move to the cloud, this becomes impossible. Remote MCP servers face a fundamental challenge: they must explicitly authenticate users and securely access personal data — your emails, documents, calendar events, private repositories, internal databases.

Without authorization, remote MCP servers can only access public information, which severely limits their value. AI assistants become truly powerful when they can read your emails, understand your project context, or query your company’s data. Authorization transforms remote MCP from a demonstration toy into an enterprise-ready platform where users can safely expose their private data to AI assistants.

1.6. Documentation Gap

The authorization protocol theory is documented, but it’s not immediately clear how to implement it practically. When I tried to build a working remote MCP server with authorization that integrates with Claude, I found myself digging through the official TypeScript SDK source code to understand how the pieces fit together.

That’s exactly what this article covers: building a complete implementation from scratch that actually works with Claude. We’ll start with the Authorization Server and work our way through to a complete MCP Server that you can deploy and test yourself!

↑ Back to Contents

2. Solution Overview

I’m going to walk you through building a minimal but complete remote MCP server with authorization that works with Claude, since Claude was first to support remote MCP servers, but the implementation should work with any AI assistant that supports the MCP protocol now or shortly. We’ll keep things simple — just enough to demonstrate how authorization works in practice without getting bogged down in production concerns.

2.1. Component View

Let’s get a high-level overview of what we’re going to build: beyond AI Assistant (Claude) itself, we need two main pieces — an MCP Server and a separate Authorization Server. I’m implementing the Authorization Server as a single AWS Lambda that handles the OAuth flow for our remote MCP solution.

Press enter or click to view image in full size
Figure 1. Component View

I decided to keep the OAuth flow separate from the MCP server for a practical reason — it makes the authorization logic portable to other projects you might be building. But honestly, there’s another reason: when remote MCP support was first announced, Claude only worked with Server-Sent Events (SSE) transport, while I was laser-focused on getting authorization working. The MCP server implementation itself is quite well documented elsewhere online.

Since then, things have evolved. As of July, Anthropic added support for Streamable HTTP, which is superior to SSE in terms of both implementation and scalability. In fact, SSE is now considered deprecated, so for real projects, you should stick with Streamable HTTP.

But here’s the thing: I’m implementing both transports in this example. Partly to show how authorization works with each, but mainly because I spent some time digging through the official TypeScript SDK to figure out how to make SSE work with authorized clients. The authorization flow for both transports isn’t straightforward, and since I already did some work on understanding it, I figured I’d save you the trouble.

2.2. Interaction Flow

Here’s how the whole interaction flow looks on a high level when a user connects AI Assistant (Claude) to the MCP server:

Press enter or click to view image in full size
Figure 2. Interaction Flow

Walking through this step by step:

  1. The User adds an integration in Claude and clicks “Connect” to start the authorization process.
  2. Claude reaches out to our MCP Server to discover where the authorization endpoints are.
  3. Our MCP Server doesn’t handle auth directly — instead, it returns metadata pointing Claude to our separate Authorization Server.
  4. Claude redirects the User to the authorization page that our Authorization Server renders, where they can grant permissions.
  5. The User grants access and gets redirected back to Claude with an authorization code.
  6. Claude exchanges that code for an access token by talking directly to our Authorization Server.
  7. Finally, Claude comes back to our MCP Server with the access token and can start making authenticated requests.

One thing I want to emphasize: the Authorization Server doesn’t have to be separate from the MCP Server. I’m keeping them separate mainly because it makes the auth logic easier to reuse in your own projects. You could absolutely combine them into a single service.

When we get to the Authorization Server implementation, I’ll walk through the detailed API endpoints, requests, parameters, and responses for each step. But this gives you the big picture of how AI Assistant (Claude in this example) and our servers work together to establish that secure connection.

2.3. Technical Stack

To build the described solution, we are going to use:

Let me explain my reasoning behind these choices. For the Authorization Server, I’m using vanilla JavaScript with no dependencies in a single AWS Lambda. This might seem old-school, but it makes deployment dead simple and forces us to understand exactly what’s happening during the OAuth flow, which is the main goal of this article.

For the MCP Server, I’m using the official TypeScript SDK for MCP with Express.js. The problem is that the SDK documentation doesn’t show you how to handle authorized users properly. I spent some time figuring this out, so implementing both Streamable HTTP and SSE transports should save you that headache when building your own MCP servers.

I’m deploying the MCP Server on Google Cloud Run instead of AWS for one specific reason: SSE connections need longer timeouts than most AWS managed services provide. Google Cloud Run lets you configure timeouts up to an hour, which is plenty for our proof of concept.

Now, a quick disclaimer: this is a tutorial focused on getting authorization working, not building production systems. I’m skipping a lot of validations, security measures, and database operations you’d need in the real world. But I’ll call out the important production considerations as we go through the authorization flow.

2.4. Battle School Computer

Since our MCP Server needs to provide access to some kind of user data, I needed to pick a fun concept for the tools we’ll implement. I decided to build a “Battle School Computer” from the novel Ender’s Game by Orson Scott Card — imagine Claude working as your tactical assistant, where you can ask things like:

— Show me my current army roster and our battle statistics.
— I need to review my soldiers’ performance ratings and specialties before our next battle.
— What are the Phoenix Army’s weaknesses? We’re fighting them tomorrow.
— Pull up the file on Petra Arkanian — what are her tactical strengths?

I’m keeping the data simple — just JSON files alongside the code. But once you see how the authorization works, you’ll be able to connect this to real databases and APIs for your own projects.

The complete working implementation is available on GitHub: https://github.com/loginov-rocks/Remote-MCP-Auth

↑ Back to Contents

3. Authorization Server

Now comes the fun part — implementing the authorization flow that makes this whole thing work. This is where I had to dig deep into the official MCP authorization specification to figure out how Claude expects the OAuth dance to happen.

There’s already a newer spec from June 18, 2025, but Claude perfectly works with the March 26, 2025 version we are going to implement here. Starting with this foundation, you can easily enhance it later to support the newer features. The AI world moves fast — what can I say, one month feels like a year these days!

The MCP authorization protocol is built on top of standard OAuth specifications:

But what does this actually look like when a user connects to your MCP server? Let me walk you through what I discovered happens step by step.

3.1. User Experience

From the user’s perspective, connecting to our MCP server is straightforward:

  1. Open Claude, go to “Settings” → “Integrations”, click “Add integration”.
  2. Enter a name and the MCP server URL:
    https://remote-mcp.us-west1.run.app/mcp for Streamable HTTP
    — or https://remote-mcp.us-west1.run.app/sse for SSE.
  3. Click “Connect” — this is where our authorization kicks in.
  4. Grant permissions on the authorization page that appears.
  5. Get redirected back to Claude, which confirms the successful connection with a message: “Successfully connected to server”.

Simple enough. But behind the scenes, there’s a complex dance happening between Claude and our servers.

3.2. Behind the Scenes

When the user clicks “Connect”, here’s the detailed flow happening behind the scenes:

Press enter or click to view image in full size
Figure 3. Authorization Flow
  1. Claude asks our MCP Server for authorization metadata: GET /.well-known/oauth-authorization-server
  2. Our MCP Server fetches this metadata from our Authorization Server and passes it back to Claude.
  3. Claude registers itself as an OAuth client: POST /oauth/register
  4. Claude redirects the user to our authorization page: GET /oauth/authorize
  5. The user grants permissions, and our page submits the form: POST /oauth/authorize
  6. Our Authorization Server redirects the user back to Claude with an authorization code.
  7. Claude sometimes requests the metadata again — I noticed this during testing.
  8. Claude exchanges the code for tokens: POST /oauth/token
  9. Finally, Claude connects to our MCP Server with the access token: GET /mcp or GET /sse
  10. Our MCP Server validates the token and confirms the connection.
  11. Claude shows the user that everything worked!

3.3. Endpoints Summary

After mapping out this flow, we can see our Authorization Server needs exactly five endpoints:

  1. GET /.well-known/oauth-authorization-server — Server Metadata Discovery endpoint,
  2. POST /oauth/register — Dynamic Client Registration endpoint,
  3. GET /oauth/authorize — Authorization page endpoint,
  4. POST /oauth/authorize — Authorization Processing endpoint,
  5. POST /oauth/token — Token endpoint.

Let me show you how to implement each one. I’ll include the specific requests with parameters Claude sends and the exact responses it expects, because getting these details wrong will break the integration silently.

To help you get these details exactly right, I’ve captured real-world collection of every request and response in the GitHub repository: these JSON files contain the actual HTTP requests Claude sends during live authorization sessions, along with the precise responses that successfully complete each step of the OAuth flow — hopefully these help when implementing and debugging your own endpoints.

3.4. Server Metadata Discovery Endpoint

Endpoint: GET /.well-known/oauth-authorization-server — fixed.

This is where the authorization dance begins. When a user clicks “Connect” in Claude, the first thing that happens is Claude reaches out to ask our MCP server: “Hey, what authorization capabilities do you support, and where are your endpoints?”

The endpoint path is standardized by OAuth 2.0 Authorization Server Metadata (RFC8414), so it’s fixed. When Claude makes this request with no parameters, it expects a JSON response that provides everything needed to handle the OAuth flow.

Here’s the minimum response that does the trick:

{
"issuer": "https://abc123.execute-api.us-east-1.amazonaws.com",
"authorization_endpoint": "https://abc123.execute-api.us-east-1.amazonaws.com/oauth/authorize",
"token_endpoint": "https://abc123.execute-api.us-east-1.amazonaws.com/oauth/token",
"registration_endpoint": "https://abc123.execute-api.us-east-1.amazonaws.com/oauth/register",
"grant_types_supported": [
"authorization_code",
"client_credentials"
],
"code_challenge_methods_supported": [
"S256"
]
}

The response must include the authorization, registration, and token endpoint URLs, along with supported grant types. You’ll need both authorization_code (for the user authorization flow) and client_credentials (for Claude). The code_challenge_methods_supported: ["S256"] field indicates mandatory PKCE support.

This endpoint is public — no authentication or complex validation needed. Claude just discovers your server capabilities. While the well-known path is fixed, you can customize the actual endpoint paths however you want, as long as they match what you implement later.

Example implementation: https://github.com/loginov-rocks/Remote-MCP-Auth/blob/main/oauth-function/src/getWellKnownHandler.mjs

3.5. Dynamic Client Registration Endpoint

Endpoint: POST /oauth/register — customizable path that must match what you return in the metadata discovery.

Once Claude knows where your endpoints are, it needs to register itself as an OAuth client. This follows the OAuth 2.0 Dynamic Client Registration Protocol (RFC7591), and Claude sends a specific payload that tells us exactly what it needs to work properly.

Here’s what Claude sends in the registration request:

{
"client_name": "claudeai",
"grant_types": [
"authorization_code",
"refresh_token"
],
"response_types": [
"code"
],
"token_endpoint_auth_method": "none",
"scope": "claudeai",
"redirect_uris": [
"https://claude.ai/api/mcp/auth_callback"
]
}

Let’s break down what Claude is asking for here:

  • client_name is just a human-readable identifier — you’d see “claudeai” if you were building that into the authorization consent screen;
  • grant_types array tells us Claude wants:
    — to use the standard authorization code flow (authorization_code)
    — and be able to refresh tokens later (refresh_token) without making users re-authorize;
  • response_types: ["code"] means Claude expects to receive authorization codes (standard for the authorization code grant);
  • token_endpoint_auth_method: "none" indicates Claude is a “public client” — it can’t securely store client secrets, which makes sense for a web application;
  • scope: "claudeai" is a custom scope identifier specific to Claude;
  • redirect_uris contains Claude’s callback URL, where users get sent after authorization.

Now, in a production system, this endpoint needs to validate quite a bit. You want to confirm you support the requested grant types and scopes, validate that required fields are present and properly formatted, and ensure redirect URIs use HTTPS. You might also enforce server-specific policies like client allowlists or domain restrictions.

If everything checks out, you generate a client ID (UUID recommended) and store the registration information persistently. This client ID becomes crucial — it’s how you’ll recognize Claude in all future requests during the OAuth flow and beyond.

Here’s why that storage matters: the client ID acts like Claude’s “passport” throughout the entire OAuth lifecycle. When Claude requests authorization, exchanges codes for tokens, or makes authenticated API calls, you’ll look up this registration data to validate redirect URIs, confirm grant type permissions, and enforce scope restrictions. Without this stored registration data, you’d have no way to distinguish legitimate clients from potential attackers.

After successful registration, Claude expects this response:

{
"client_id": "98f974d2-5ab1-467d-b043-64edbb2a840b",
"client_name": "claudeai",
"grant_types": [
"authorization_code",
"refresh_token"
],
"response_types": [
"code"
],
"token_endpoint_auth_method": "none",
"scope": "claudeai",
"redirect_uris": [
"https://claude.ai/api/mcp/auth_callback"
]
}

Notice how the response includes the generated client ID and echoes back all the registration information that was successfully stored.

Example implementation: https://github.com/loginov-rocks/Remote-MCP-Auth/blob/main/oauth-function/src/postRegisterHandler.mjs

3.6. Authorization Endpoint

Endpoint: GET /oauth/authorize — customizable path that must match what you return in the metadata discovery.

After Claude receives the client ID, it redirects the user’s browser to your authorization endpoint. This is where the user grants permission for Claude to access their data. Here’s what Claude sends as URL query parameters (shown in JSON format for readability):

{
"client_id": "98f974d2-5ab1-467d-b043-64edbb2a840b",
"code_challenge": "qpxvR2wuni9VNrukgHOgVqGc1WljDfVbDFIYtwmKeos",
"code_challenge_method": "S256",
"redirect_uri": "https://claude.ai/api/mcp/auth_callback",
"response_type": "code",
"scope": "claudeai",
"state": "MAtaiBZWWmmNvjGaDFEiLuAbSv_FUJ3k91XYnWcZpts"
}

Let’s unpack these parameters:

  • client_id — the unique identifier received from the Dynamic Client Registration endpoint,
  • code_challenge and code_challenge_method — PKCE parameters (mandatory in OAuth 2.1) that prevent authorization code interception attacks,
  • redirect_uri — callback URL where the authorization response will be sent (must exactly match a registered URI),
  • response_type set to "code" for authorization code grant flow,
  • scope — requested access permissions (must be within registered scopes),
  • state — random value for CSRF protection (OAuth 2.0 standard).

In production, this endpoint needs to validate several things. You’ll want to confirm the client_id exists in your stored registration data, ensure the redirect_uri exactly matches one of the registered URIs for this client (critical for security), and verify that required OAuth parameters are present. You should also confirm the requested scope is allowed for this client based on their registration.

Here’s the key part: you need to temporarily store all these authorization request parameters because the user’s authorization decision happens as a separate step. When the user grants access (which triggers a POST to this same endpoint), you’ll need to remember the original request details: especially client_id and code_challenge — to properly complete the OAuth flow during token exchange.

The user authorization interface itself is completely up to you. Present a consent screen where users can review what application is requesting access and what permissions are needed. In our Battle School Computer example, I’m keeping things simple by asking users to enter their student ID (like "BS-2401" for Ender Wiggin) instead of implementing full authentication. In a real system, you’d securely authenticate users here: either with passwords or by delegating to another authentication system.

I’m storing the request parameters in hidden HTML form inputs, but you could equally use server-side session storage. Both approaches work fine; the choice depends on your architectural preferences and specific security requirements.

Example implementation: https://github.com/loginov-rocks/Remote-MCP-Auth/blob/main/oauth-function/src/getAuthorizeHandler.mjs

3.7. Authorization Processing Endpoint

Endpoint: POST /oauth/authorize — customizable path used by your authorization page only (not standardized).

When the user fills out the consent form from the previous endpoint and clicks submit, this is where that form data lands. The endpoint receives a POST request with all the original authorization parameters plus whatever user input you collected:

{
"student_id": "BS-2401",
"client_id": "98f974d2-5ab1-467d-b043-64edbb2a840b",
"code_challenge": "qpxvR2wuni9VNrukgHOgVqGc1WljDfVbDFIYtwmKeos",
"code_challenge_method": "S256",
"redirect_uri": "https://claude.ai/api/mcp/auth_callback",
"response_type": "code",
"scope": "claudeai",
"state": "MAtaiBZWWmmNvjGaDFEiLuAbSv_FUJ3k91XYnWcZpts"
}

The payload contains all the original authorization request parameters that Claude sent, plus the student ID the user entered.

In a production system, this endpoint handles the critical decision point. You need to determine the user’s consent decision (through form buttons, separate endpoints, or additional parameters), validate all the authorization request parameters again (client_id exists, redirect_uri matches registration, required parameters present), and confirm the user is properly authenticated and authorized to make this decision.

If the user grants access, you generate a cryptographically secure, short-lived authorization code and store it with all the associated metadata needed for token exchange. The authorization code should be cryptographically random and unguessable, short-lived (typically 10 minutes maximum), single-use only, and tied to the specific client, user, and PKCE challenge.

If the user denies access, you redirect with an error response instead of generating a code.

Since the authorization step is separated from generating access tokens, you need to store at least the generated code, the PKCE challenge Claude provided, and the user’s identity (student ID in our case) so it can all be used when Claude exchanges the code for tokens in the next step.

In my example, I’m saving this data to DynamoDB with an expiration timestamp. DynamoDB automatically cleans up expired records based on this field, which I’ve configured in the CloudFormation template — more details on that in the deployment section.

After processing the user’s decision, the server issues an authorization response by redirecting the user’s browser back to Claude’s registered callback URL. For successful authorization, you return an HTTP 302 Found redirect with this Location header:

Location: https://claude.ai/api/mcp/auth_callback?code=7fc8cde3ddaf6b0488368ffa2e4f3b31861c0e98630c36a07582ca19148338d2&state=MAtaiBZWWmmNvjGaDFEiLuAbSv_FUJ3k91XYnWcZpts

For denied authorization:

Location: https://claude.ai/api/mcp/auth_callback?error=access_denied&state=MAtaiBZWWmmNvjGaDFEiLuAbSv_FUJ3k91XYnWcZpts

The browser automatically follows this redirect, returning control to Claude with either the authorization code needed for token exchange or an error indicating the user denied access. The state parameter is always returned to enable CSRF protection validation.

Example implementation: https://github.com/loginov-rocks/Remote-MCP-Auth/blob/main/oauth-function/src/postAuthorizeHandler.mjs

3.8. Token Endpoint

Endpoint: POST /oauth/token — customizable path that must match what you return in the metadata discovery.

Now we reach the final step in the authorization dance. After the user gets redirected back to Claude with an authorization code, Claude needs to exchange that temporary code for actual access and refresh tokens. I’ve noticed that Claude sometimes makes an additional Server Metadata Discovery request at this point (probably to confirm endpoint URLs), then proceeds with the token exchange.

Here’s what Claude sends to the token endpoint:

{
"grant_type": "authorization_code",
"code": "7fc8cde3ddaf6b0488368ffa2e4f3b31861c0e98630c36a07582ca19148338d2",
"client_id": "98f974d2-5ab1-467d-b043-64edbb2a840b",
"code_verifier": "j8js9GUnRzJoeYlxicRtL4xtkm2-9TRtf1rilRyhnNU",
"redirect_uri": "https://claude.ai/api/mcp/auth_callback"
}

Let’s break down these parameters:

  • grant_type set to "authorization_code" indicating this is a code-for-token exchange,
  • code — the authorization code from the authorization callback,
  • client_id — the registered client identifier,
  • code_verifier — the original PKCE verifier used to generate the code_challenge,
  • redirect_uri must exactly match the URI used in the authorization request.

This endpoint does the heavy lifting of validating everything and generating tokens. In production, you need to validate that the authorization code exists in storage and hasn’t expired (typically 10-minute lifetime), verify the client_id matches the client associated with this authorization code, and confirm the redirect_uri exactly matches the value stored with the authorization code.

The PKCE validation is crucial here: you hash the code_verifier with SHA256 and compare it to the stored code_challenge from the original authorization request. This proves that whoever is making the token request is the same entity that initiated the authorization flow, preventing code interception attacks.

You also need to ensure the authorization code hasn’t been used before (single-use requirement), generate new access and refresh tokens, and delete or mark the authorization code as used to prevent replay attacks.

For token generation, you want to produce a short-lived access token (minutes to 1 hour) for MCP requests and a refresh token that’s long-lived (days to months) for obtaining new access tokens. Both should be cryptographically secure and unguessable.

Since Claude registered with refresh_token grant support, your successful response should include both token types:

{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJCUy0yNDAxIiwiZXhwIjoxNzUxNjYwNjg1fQ.KpSJuoqO5nP_wBwFyg0eqzPDZoNNi0wxKY2lQJJjdso",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJCUy0yNDAxIiwiZXhwIjoxNzU0MjUxNzg1fQ.btZ5VVB4EP7ko4IdV6BTiYW-7CeN1OQKLp8CF0EDcqQ",
"scope": "claudeai"
}

In my implementation, the handler looks up the code in DynamoDB, validates PKCE, removes the code from storage, and generates both tokens with the student ID in the sub claim using secrets from environment variables. I’m using JWT tokens and included token signing/validation in a separate file to avoid requiring a build step during deployment. You could absolutely use libraries like jsonwebtoken for this if you prefer.

Example implementation: https://github.com/loginov-rocks/Remote-MCP-Auth/blob/main/oauth-function/src/postTokenHandler.mjs

3.8.1. Refresh Token

Since prompting sessions with AI assistants can last longer than typical access token lifespans, we need to ensure Claude can refresh access tokens without forcing users to re-authorize. This also fulfills the refresh_token grant type that Claude requested during Dynamic Client Registration, so it’s a capability our Authorization Server needs to support.

The same Token endpoint handles token refresh, but with a different grant type. When Claude needs fresh tokens, it sends this payload:

{
"grant_type": "refresh_token",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJCUy0yNDAxIiwiZXhwIjoxNzU0MjUxNzg1fQ.btZ5VVB4EP7ko4IdV6BTiYW-7CeN1OQKLp8CF0EDcqQ",
"client_id": "98f974d2-5ab1-467d-b043-64edbb2a840b"
}

In production, this endpoint needs to validate the refresh token signature, check that it hasn’t expired or been revoked, and verify that the client_id matches the client associated with this refresh token. You should also consider implementing refresh token rotation for enhanced security: issuing a new refresh token while invalidating the old one.

In my implementation, the handler validates the refresh token JWT signature, extracts the student ID from the sub claim, and generates new tokens with updated expiration times. The response format is identical to the authorization code grant: same token structure, same fields, just with fresh expiration times.

3.9. Authenticated MCP Session Establishment

We’re almost there! After receiving the access token, Claude establishes an authenticated MCP session with our MCP Server by including the access token in the Authorization header with each request:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJCUy0yNDAxIiwiZXhwIjoxNzUxNjYwNjg1fQ.KpSJuoqO5nP_wBwFyg0eqzPDZoNNi0wxKY2lQJJjdso

This is where the authorization flow pays off: Claude can now make authenticated requests to our MCP Server, and we can validate that the user has permission to access their specific data.

In production, our MCP Server needs to validate the Bearer token on every request, verify the token hasn’t expired and is properly formatted, and confirm the token grants access to the requested MCP resources and tools.

If anything’s wrong with the token, we should reject requests with HTTP 401 Unauthorized for invalid/expired tokens or HTTP 403 Forbidden for insufficient permissions, and let the client know that re-authentication is required.

You might also want to validate that token scopes match the requested MCP operations, depending on how granular you want your permissions to be.

This establishes the authenticated MCP session that enables Claude to make authorized requests to protected MCP resources and tools. Our server maintains this authentication state throughout the session, validating the Bearer token on each request to ensure secure access to user-specific data and functionality.

With the authorization flow sorted out, let’s implement the actual MCP Server that will support these authenticated sessions. This is where the Battle School Computer comes to life!

↑ Back to Contents

4. MCP Server

Now let’s move on to the MCP Server implementation. I’m building this with Express.js and TypeScript using the official SDK. The main challenge I want to solve here is connecting MCP tools to our authorization context so they can access user-specific data — something that isn’t well documented in the current SDK examples.

4.1. File Structure

Even for a tutorial implementation, I believe good code organization pays off when you’re trying to understand how the pieces fit together. Let me walk you through the structure I’m using for the MCP Server — it’s organized around classes with a few main files:

  • app.ts — the entry point that sets up our Express.js server;
  • constants.ts — centralized configuration through environment variables:
    ACCESS_TOKEN_SECRET — secret for validating access tokens (must match your Authorization Server configuration),
    OAUTH_API_BASE_URL — URL pointing to your Authorization Server,
    PORT — port for the server to listen on;
  • container.ts — dependency injection container that wires together controllers, middleware, services, and the MCP implementation;
  • router.ts connects controllers to endpoints and applies middleware.

The rest of the implementation lives in four organized directories:

  • controllers handles HTTP request/response logic,
  • mcp contains our MCP server implementation,
  • middlewares — authentication middleware,
  • services — business logic and data access services.

This structure isn’t necessarily “the best” approach, but it makes the code much easier to follow when you’re trying to understand how authorization flows through the different layers. When you’re building your own MCP server, feel free to organize things however makes sense for your project.

4.2. Router

Let’s look at the endpoints our MCP Server needs to handle both transport types and the authorization delegation:

import { json, Request, Response, Router } from 'express';

import { mcpAuthMiddleware, mcpSseController, mcpStreamableController, oauthController } from './container';

export const router = Router();

// Health check.
router.get('/', (req: Request, res: Response) => {
res.send('OK');
});

// SSE.
router.get('/sse', mcpAuthMiddleware.requireAuth, mcpSseController.getSse);
router.post('/messages', mcpAuthMiddleware.requireAuth, mcpSseController.postMessages);

// Streamable HTTP.
router.post('/mcp', mcpAuthMiddleware.requireAuth, json(), mcpStreamableController.postMcp);
router.get('/mcp', mcpAuthMiddleware.requireAuth, json(), mcpStreamableController.getMcp);
router.delete('/mcp', mcpAuthMiddleware.requireAuth, json(), mcpStreamableController.deleteMcp);

// Auth-related.
router.get('/.well-known/oauth-authorization-server', oauthController.getWellKnown);

The structure here reflects the dual nature of what we’re building. The health check endpoint (GET /) is straightforward — just a simple way to verify the server is responding.

For SSE transport, we need two endpoints:

  • GET /sse — establishes the Server-Sent Events connection,
  • POST /messages — handles message exchanges over SSE.

For Streamable HTTP transport, we implement three endpoints:

  • POST /mcp — primary endpoint for MCP message handling,
  • GET /mcp — connection establishment and metadata,
  • DELETE /mcp — clean connection teardown.

Here’s something I have to clarify: you can’t apply express.json() middleware globally when supporting SSE. Notice how the Streamable HTTP endpoints explicitly include json() middleware, while the SSE endpoints don’t? That’s because express.json() will break SSE connections by trying to parse the streaming data as JSON. If you’re only implementing Streamable HTTP transport, you can safely use app.use(express.json()) and remove the individual middleware calls.

The authorization endpoints delegate to our separate Authorization Server, starting with the Server Metadata Discovery endpoint. All the actual MCP endpoints require authentication through mcpAuthMiddleware.requireAuth — this is where we validate the access tokens Claude sends.

Let’s start with that delegation endpoint since it’s the bridge between our two servers.

Example implementation: https://github.com/loginov-rocks/Remote-MCP-Auth/blob/main/mcp-server/src/router.ts

4.3. OAuth Controller

Since we’re keeping the Authorization Server separate, our MCP Server just needs to act as a pass-through for the Server Metadata Discovery. The controller itself is simple — it fetches the metadata from our Authorization Server and forwards it to Claude:

import { Request, Response } from 'express';

interface Options {
oauthApiBaseUrl: string;
}

export class OAuthController {
private readonly oauthApiBaseUrl: string;

constructor({ oauthApiBaseUrl }: Options) {
this.oauthApiBaseUrl = oauthApiBaseUrl;

this.getWellKnown = this.getWellKnown.bind(this);
}

public async getWellKnown(req: Request, res: Response): Promise<void> {
const url = `${this.oauthApiBaseUrl}/.well-known/oauth-authorization-server`;
const response = await fetch(url);
const json = await response.json();

res.json(json);
}
}

This delegation approach keeps our concerns properly separated. Our MCP Server focuses on handling MCP protocol logic and tool implementations, while the Authorization Server handles all the OAuth complexity. When Claude asks our MCP Server about authorization capabilities, we simply point it to the real authority.

In a production system, you’d probably want to add some caching here since the Server Metadata doesn’t change frequently. I’ve included a cached version in the repository that shows how you might implement that optimization.

Example implementation: https://github.com/loginov-rocks/Remote-MCP-Auth/blob/main/mcp-server/src/controllers/OAuthController.ts

4.4. MCP Auth Middleware

This middleware handles the authentication validation for both SSE and Streamable HTTP transports. It’s responsible for extracting and validating the Bearer token from the Authorization header that Claude sends with each request:

import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types';
import { NextFunction, Request, Response } from 'express';

import { TokenService } from '../services/TokenService';

interface Options {
tokenService: TokenService;
}

export interface McpAuthenticatedRequest extends Request {
auth?: AuthInfo;
}

export class McpAuthMiddleware {
private readonly tokenService: TokenService;

constructor({ tokenService }: Options) {
this.tokenService = tokenService;

this.requireAuth = this.requireAuth.bind(this);
}

public requireAuth(req: McpAuthenticatedRequest, res: Response, next: NextFunction): void {
if (!req.headers.authorization || !req.headers.authorization.startsWith('Bearer ')) {
res.status(401).send('Unauthorized');
return;
}

const token = req.headers.authorization.substring(7);

if (!token) {
res.status(401).send('Unauthorized');
return;
}

const clientId = this.tokenService.validateToken(token);

if (!clientId) {
res.status(401).send('Unauthorized');
return;
}

req.auth = {
clientId,
scopes: [],
token,
};

next();
}
}

Important! While the middleware implementation follows standard Express.js patterns, there’s a crucial detail here that took me a while to figure out. You must attach an auth object of the AuthInfo type to the request:

req.auth = {
clientId,
scopes: [],
token,
};

This isn’t documented anywhere in the official TypeScript SDK, but it’s absolutely required for the SDK to understand the authorization context of each request. The SDK automatically passes this auth object down to your MCP tools, which is how your tools can access user-specific data. Without this specific structure, your tools won’t receive any authorization context.

In our implementation, the clientId field contains the student ID that we encoded in the access token during the authorization flow. This becomes the key that lets our MCP tools know which user’s data to access, like "BS-2401" for Ender Wiggin’s Battle School records.

I’ll show you how to access this authorization context from within your MCP tools in the next sections.

Example implementation: https://github.com/loginov-rocks/Remote-MCP-Auth/blob/main/mcp-server/src/middlewares/McpAuthMiddleware.ts

4.5. MCP SSE Controller

The SSE controller handles the more complex transport option by managing persistent Server-Sent Events connections. It exposes two methods that work together to enable bidirectional communication:

  • getSse establishes SSE connections with clients and connects them to our MCP server,
  • postMessages handles incoming POST messages from clients for specific SSE sessions.
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { Response } from 'express';

import { McpServer } from '../mcp/McpServer';
import { McpAuthenticatedRequest } from '../middlewares/McpAuthMiddleware';

interface Options {
mcpServer: McpServer;
}

export class McpSseController {
private readonly mcpServer: McpServer;

private readonly transportsMap: Map<string, SSEServerTransport> = new Map();

constructor({ mcpServer }: Options) {
this.mcpServer = mcpServer;

this.getSse = this.getSse.bind(this);
this.postMessages = this.postMessages.bind(this);
}

public async getSse(req: McpAuthenticatedRequest, res: Response): Promise<void> {
const transport = new SSEServerTransport('/messages', res);

this.transportsMap.set(transport.sessionId, transport);

res.on('close', () => {
this.transportsMap.delete(transport.sessionId);
});

await this.mcpServer.connect(transport);
}

public async postMessages(req: McpAuthenticatedRequest, res: Response): Promise<void> {
const { sessionId } = req.query;

if (!sessionId || typeof sessionId !== 'string') {
res.status(400).send('Session ID missing');
return;
}

const transport = this.transportsMap.get(sessionId);

if (!transport) {
res.status(400).send(`Transport not found for session ID ${sessionId}`);
return;
}

await transport.handlePostMessage(req, res);
}
}

The controller essentially acts as a bridge between Express.js HTTP handling and the MCP server’s transport layer. SSE is inherently more complex than simple HTTP because you need to maintain persistent connections while handling the hybrid nature of the communication: SSE streams server-to-client messages, while client-to-server messages still come as regular HTTP POST requests.

The transportsMap tracks active sessions by their unique session IDs, and we handle cleanup automatically when clients disconnect. This session management is crucial because without it, you’d have memory leaks from abandoned transport objects.

What makes this interesting is that it enables real-time bidirectional communication using web standards — no WebSockets required. Clients receive streaming updates via SSE while sending commands through HTTP POST, which works reliably across different network configurations and proxy setups.

Example implementation: https://github.com/loginov-rocks/Remote-MCP-Auth/blob/main/mcp-server/src/controllers/McpSseController.ts

4.6. MCP Streamable Controller

The Streamable HTTP controller handles the recommended transport method — it’s cleaner than SSE and better suited for production use. This controller manages the session lifecycle through three methods:

  • postMcp handles POST requests, creating new sessions for initialization requests or processing messages for existing sessions,
  • getMcp handles GET requests for existing sessions,
  • deleteMcp handles DELETE requests for session cleanup.
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { randomUUID } from 'crypto';
import { Response } from 'express';

import { McpServer } from '../mcp/McpServer';
import { McpAuthenticatedRequest } from '../middlewares/McpAuthMiddleware';

interface Options {
mcpServer: McpServer;
}

export class McpStreamableController {
private readonly mcpServer: McpServer;

private readonly transportsMap: Map<string, StreamableHTTPServerTransport> = new Map();

constructor({ mcpServer }: Options) {
this.mcpServer = mcpServer;

this.postMcp = this.postMcp.bind(this);
this.getMcp = this.getMcp.bind(this);
this.deleteMcp = this.deleteMcp.bind(this);
}

public async postMcp(req: McpAuthenticatedRequest, res: Response): Promise<void> {
const sessionId = req.headers['mcp-session-id'];
let transport: StreamableHTTPServerTransport;

if (sessionId && this.transportsMap.has(sessionId as string)) {
transport = this.transportsMap.get(sessionId as string) as StreamableHTTPServerTransport;
} else if (!sessionId && isInitializeRequest(req.body)) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sessionId) => {
this.transportsMap.set(sessionId, transport);
},
});

transport.onclose = () => {
if (transport.sessionId) {
this.transportsMap.delete(transport.sessionId);
}
};

await this.mcpServer.connect(transport);
} else {
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: No valid session ID provided',
},
id: null,
});

return;
}

await transport.handleRequest(req, res, req.body);
}

public getMcp(req: McpAuthenticatedRequest, res: Response): Promise<void> {
return this.handleSessionRequest(req, res);
}

public deleteMcp(req: McpAuthenticatedRequest, res: Response): Promise<void> {
return this.handleSessionRequest(req, res);
}

private async handleSessionRequest(req: McpAuthenticatedRequest, res: Response): Promise<void> {
const sessionId = req.headers['mcp-session-id'];

if (!sessionId || !this.transportsMap.has(sessionId as string)) {
res.status(400).send('Invalid or missing session ID');
return;
}

const transport = this.transportsMap.get(sessionId as string) as StreamableHTTPServerTransport;

await transport.handleRequest(req, res);
};
}

What I like about Streamable HTTP compared to SSE is that the session management is much more straightforward. The controller automatically detects initialization requests and creates new sessions when no mcp-session-id header is present. For subsequent requests, it validates the session ID and routes to the appropriate transport instance.

The session lifecycle is also cleaner: each transport handles its own cleanup when connections close, and we maintain a mapping between session IDs and transport instances for efficient lookup. This approach scales better than SSE since you’re not maintaining persistent connections that can time out or be dropped by proxies.

The isInitializeRequest(req.body) helper from the SDK makes it easy to detect when the MCP client is starting a new session versus continuing an existing conversation, which is crucial for proper session management.

Example implementation: https://github.com/loginov-rocks/Remote-MCP-Auth/blob/main/mcp-server/src/controllers/McpStreamableController.ts

4.7. Services

Before we get to the main event — implementing the MCP server itself — let’s quickly cover the supporting services that handle our Battle School Computer’s business logic. I’m keeping this brief since the example implementation is in the repository.

  • Army Service manages army data and operations, letting users find their own army or look up opponent armies by name,
  • Student Service handles student data management, providing methods to retrieve student information by name,
  • Token Service provides JWT token authentication and validation to ensure authenticated users correspond to valid students.

These services represent the core business logic of our Battle School Computer. The Army and Student Services work with our JSON data files to provide the tactical information that makes the MCP server useful, while the Token Service bridges the authorization flow with the actual user data.

In a real implementation, these services would typically connect to databases, external APIs, or other data sources. But for our tutorial purposes, having them work with static JSON files keeps things simple while demonstrating how authorization context flows through to your actual business logic.

4.8. MCP Server

Now to the heart of our implementation — the MCP server itself. This class wraps the McpServer provided by the official SDK to give our tools access to the application services. Let’s look at the structure first:

import { McpServer as McpServerSdk } from '@modelcontextprotocol/sdk/server/mcp.js';
import { Transport } from '@modelcontextprotocol/sdk/shared/transport';
import { z } from 'zod';

import { ArmyService } from '../services/ArmyService';
import { StudentService } from '../services/StudentService';

interface Options {
armyService: ArmyService;
studentService: StudentService;
}

interface ToolResponse {
// Required to avoid TS error on node_modules/@modelcontextprotocol/sdk/dist/esm/types.d.ts:18703:9
[x: string]: unknown;
content: Array<{
type: 'text';
text: string;
}>;
}

export class McpServer {
private readonly armyService: ArmyService;

private readonly studentService: StudentService;

private server: McpServerSdk | null = null;

constructor({ armyService, studentService }: Options) {
this.armyService = armyService;
this.studentService = studentService;

this.init();
}

public connect(transport: Transport) {
if (!this.server) {
throw new Error('MCP server not initialized');
}

return this.server.connect(transport);
}

private init() {
this.server = new McpServerSdk({
name: 'Battle-School-Computer',
version: '1.0.0',
});

// TODO: Set up MCP resources, tools, and prompts.
}

private respondWithText(text: string): ToolResponse {
return {
content: [
{
type: 'text',
text,
},
],
};
}

private respondWithJson(json: unknown): ToolResponse {
return this.respondWithText(JSON.stringify(json));
}
}

The wrapper approach gives us dependency injection for our services, which makes the code much cleaner when implementing tools that need to access user-specific data. Instead of having tools directly manage database connections or business logic, they can focus on handling the MCP protocol while delegating the actual work to our services.

We’re going to implement three tools that bring our Battle School Computer to life:

  • get-my-army retrieves information about the authorized student’s army, including soldiers and battle record,
  • get-opponent-army gets intelligence on opponent armies by name,
  • get-student-info provides detailed information about any student by name.

These tools will demonstrate how authorization context flows from our middleware through to the actual business logic, enabling personalized responses based on who’s making the request.

Example implementation: https://github.com/loginov-rocks/Remote-MCP-Auth/blob/main/mcp-server/src/mcp/McpServer.ts

4.8.1. Get My Army

This tool demonstrates how authorization context flows through to your MCP tool implementations. It uses authInfo from the MCP context to pass the clientId to our Army Service, enabling personalized responses:

this.server.tool(
'get-my-army',
'Get information about your army including soldiers and battle record',
({ authInfo }) => {
if (!authInfo) {
return this.respondWithText('Unauthorized');
}

const myArmy = this.armyService.getMyArmy(authInfo.clientId);

return this.respondWithJson(myArmy);
}
);

Important! Here’s the crucial piece I mentioned earlier: this authInfo parameter is the “receiving end” of the auth object we attached to the request in our MCP Auth Middleware. The SDK automatically passes this authorization context down to every tool invocation, giving your MCP tools knowledge about which user is making the request.

This is another undocumented but absolutely critical feature for building real authorization into MCP servers. Without this connection, your tools would have no way to know which user’s data to access, making personalized responses impossible. When Claude calls this tool for the student "BS-2401" (Ender Wiggin), the authInfo.clientId contains exactly that identifier, allowing our Army Service to return Ender’s specific army information rather than generic data.

This is where all the OAuth complexity pays off: your tools can now provide truly personalized, secure access to user-specific data.

4.8.2. Get Opponent Army

This tool shows how MCP parameters work alongside authorization information. Even though our Army Service doesn’t need the user’s identity to look up opponent data, we still validate authorization before providing any information:

this.server.tool(
'get-opponent-army',
'Get information about an opponent army',
{
armyName: z.string().describe('Name of the opponent army to research'),
},
({ armyName }, { authInfo }) => {
if (!authInfo) {
return this.respondWithText('Unauthorized');
}

const opponentArmy = this.armyService.getOpponentArmy(armyName);

return this.respondWithJson(opponentArmy);
}
);

Notice how the tool handler receives both the tool parameters ({ armyName }) and the authorization context ({ authInfo }) as separate arguments. This pattern lets you implement tools that require both user input and authorization validation.

In this case, we’re not passing the authInfo.clientId to the Army Service since opponent army data isn’t user-specific — any authorized student can research any opponent army. But we still verify the user is authorized before providing the intelligence data. This demonstrates how you can implement different authorization patterns within the same MCP server based on your specific security requirements.

4.8.3. Get Student Info

Our final tool follows the same pattern as the opponent army lookup — it requires a parameter from the user and validates authorization before providing data:

this.server.tool(
'get-student-info',
'Get detailed information about a student',
{
studentName: z.string().describe('Name of the student to look up'),
},
({ studentName }, { authInfo }) => {
if (!authInfo) {
return this.respondWithText('Unauthorized');
}

const studentInfo = this.studentService.getStudentInfo(studentName);

return this.respondWithJson(studentInfo);
}
);

Like the opponent army tool, this doesn’t use the specific user’s identity (authInfo.clientId) for data retrieval since student information isn’t user-specific: any authorized Battle School student can look up information about their classmates. However, we still enforce that only authorized users can access this intelligence data.

These three tools demonstrate the different authorization patterns you can implement: user-specific data (my army), public data with authorization required (opponent armies and student info), and the flexibility to mix both approaches within a single MCP server based on your security model.

4.9. Testing with MCP Inspector

With our MCP Server implementation complete, we can test it locally using the MCP Inspector. I recommend using the older version (v0.13.0) since it works properly with the authorization protocol that Claude currently supports:

npx @modelcontextprotocol/inspector@0.13.0

Let’s set up the testing environment. Create an .env file by copying the .env.example, then configure the ACCESS_TOKEN_SECRET — for example, set it to ANSNoU7jycMJgZKD9KxgmjrmQ1m9OH8WLphEM1AMrMM=. You can skip OAUTH_API_BASE_URL since we’ll bypass the full authorization flow for testing and use a manually prepared token instead.

Start the MCP Server:

npm run dev

For testing, we need an access token. You can generate one using the JWT Encoder at https://jwt.io with the appropriate header and payload, or use this pre-made token (student ID = "BS-2401" with expiration set to Jan 1, 2030, and signed using the secret provided before):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJCUy0yNDAxIiwiZXhwIjoxODkzNDg0ODAwfQ.vL7wYzILu0rL-L9HWTUG5emvbUfvXcpFaagitpg3UCc

Now open the Inspector (usually at http://localhost:6274) and select one of the transport options:

  • SSE: http://localhost:3000/sse
  • Streamable HTTP: http://localhost:3000/mcp

Before connecting, you’ll need to enter the access token in the “Bearer Token” field in the “Authentication” form. Paste the token from above, then click “Connect” — if everything’s working correctly, you’ll be able to “List Tools”. Try this first, then launch the get-my-army tool. This should return information about Ender Wiggin’s army, confirming that our authorization context is flowing through properly.

Press enter or click to view image in full size
Figure 4. Testing SSE Transport with MCP Inspector
Press enter or click to view image in full size
Figure 5. Testing Streamable HTTP Transport with MCP Inspector

This local testing approach lets you validate that your MCP server is working correctly before deploying it and exposing it to Claude and other MCP clients.

↑ Back to Contents

5. Deployment

Now that our MCP Server is working locally, let’s deploy it together with the Authorization Server to the cloud so Claude can connect to them remotely. Here’s the infrastructure we’ll be setting up for our solution:

Press enter or click to view image in full size
Figure 6. Deployment View

I’m using a “multi-cloud” approach here: AWS hosts the Authorization Server with Lambda behind API Gateway, plus DynamoDB for storing authorization data between requests. The MCP Server runs on Google Cloud Run as a Docker container, which gives us a publicly accessible HTTPS endpoint.

Now, I know multi-cloud setups aren’t exactly simple to deploy and manage, but there’s a practical reason for this choice: Google Cloud Run lets you configure timeouts up to an hour and handles long-lived connections gracefully, making it perfect for our MCP server that supports both SSE and Streamable HTTP transports.

If you’re implementing Streamable HTTP transport only, you could absolutely run everything on AWS using Lambda and API Gateway. But since I wanted to show both transport options working, Google Cloud Run was the path of least resistance.

You’ll need accounts on both AWS and Google Cloud for this setup, but the infrastructure is minimal enough that it shouldn’t hurt your wallet. If you’re just running this as a proof of concept, the total cost should be under a dollar, with most services staying within free tier limits.

5.1. Authorization Server

I’ve prepared a CloudFormation template to handle the infrastructure setup for the Authorization Server with minimal configuration on your part. You can find more details about working with CloudFormation in the official AWS documentation.

The CloudFormation stack requires a few parameters to configure the OAuth tokens properly:

  • AccessTokenSecret — secret used by the OAuth Function to sign access tokens,
  • AccessTokenTtl — time to live for access tokens, defaults to 900 seconds (15 minutes),
  • AuthCodesTtl — time to live for auth codes, defaults to 600 seconds (10 minutes),
  • RefreshTokenSecret — secret to sign and validate refresh tokens,
  • RefreshTokenTtl — time to live for refresh tokens, defaults to 2,592,000 seconds (30 days).

To generate the required secrets, navigate to the /oauth-function directory and run:

npm run generate-secret

This outputs a cryptographically secure secret you can use for either token type.

After CloudFormation creates the stack, check the stack outputs tab and note the ApiUrl: something like https://abc123.execute-api.us-east-1.amazonaws.com. You’ll need this URL when configuring the MCP Server in the next step.

Here’s one thing to keep in mind: CloudFormation creates the infrastructure but doesn’t deploy the actual Lambda code. After the stack is created, go to the /oauth-function directory and run:

npm run package

This creates an oauth-function.zip file that you can manually upload through the AWS Lambda console in the code tab. It’s a simple drag-and-drop upload — CloudFormation handles everything else.

5.2. MCP Server

The MCP Server deployment is where the multi-cloud approach becomes necessary. Since SSE transport requires long-lived connections, AWS doesn’t offer a simple solution that works well. App Runner has a 120-second timeout limit, and while Lambda supports up to 15 minutes, putting it behind API Gateway restricts HTTP requests to just 30 seconds. While you could expose Lambda directly through a function URL, it would still be limited to 15 minutes, which is still less than I wish.

There are other AWS options, but none as straightforward as Google Cloud Run. With Cloud Run, I can configure timeouts up to one hour, which should be enough for our proof of concept, but also simple enough to set up.

I’m packaging the MCP Server as a Docker container since it’s the most portable approach. First, you’ll need to create a repository in Google’s Artifact Registry — the official GCP documentation covers this process. You’ll also need the gcloud CLI installed and configured for pushing to the repository.

Once that’s set up, navigate to /mcp-server and build and push your Docker image:

docker build -t mcp-server .
docker tag mcp-server us-west1-docker.pkg.dev/project/repo/mcp-server
docker push us-west1-docker.pkg.dev/project/repo/mcp-server

With the image in your repository, you can deploy it to Google Cloud Run by selecting the mcp-server image. The official GCP documentation walks through this process.

After creating the instance, edit the configuration to add the required environment variables (similar to those in .env.example):

  • ACCESS_TOKEN_SECRET must match what you configured in the AWS CloudFormation stack,
  • OAUTH_API_BASE_URL — use the URL from your AWS CloudFormation stack outputs,
  • PORT — skip this one, as Google Cloud Run sets it automatically.

Once everything’s deployed, you should be able to open your instance URL and see the “OK” response from our health check, something like: https://mcp-842306918693.us-west1.run.app

You can also verify that the Server Metadata delegation is working by visiting the well-known endpoint: https://mcp-842306918693.us-west1.run.app/.well-known/oauth-authorization-server — this should return the OAuth configuration fetched from your Authorization Server, confirming that both services are properly connected.

With both services deployed, we now have a complete remote MCP infrastructure running across AWS and Google Cloud. The Authorization Server is handling OAuth flows from its AWS Lambda, while the MCP Server is running as a containerized service on Google Cloud Run with proper timeout configuration for SSE connections.

I know the multi-cloud setup adds complexity, but it gives us a working solution that properly supports both transport types. At this point, you should have two URLs: one for your Authorization Server (from the CloudFormation outputs) and one for your MCP Server (from Google Cloud Run). Both services should be responding to health checks, and the Server Metadata delegation should be working between them.

6. Testing

Now comes the moment of truth: let’s test our complete solution with Claude itself. Head to Claude → “Settings” → “Integrations” and click “Add integration”. Enter a name for your Battle School Computer and paste the MCP Server URL for whichever transport you want to test:

  • SSE: https://mcp-842306918693.us-west1.run.app/sse
  • Streamable HTTP: https://mcp-842306918693.us-west1.run.app/mcp

After adding the integration, click “Connect” — this triggers the full authorization flow we’ve built. You’ll be redirected to our authorization page, where you can enter a student ID like BS-2401 for Ender Wiggin. Click “Authorize” and you should get redirected back to Claude with the confirmation message: “Successfully connected to server”.

If you see that success message, our entire OAuth implementation is working correctly! Claude has successfully completed the authorization dance, received access tokens, and established an authenticated MCP connection. You can verify that Claude recognizes our MCP tools by checking the integration settings:

Press enter or click to view image in full size
Figure 7. Claude — MCP Server Tools

Now let’s put our Battle School Computer through its paces. Start a new conversation and ask something tactical like: “Show me my current army roster and our battle statistics”. Claude will first ask for permission to use the external integration:

Press enter or click to view image in full size
Figure 8. Claude — Prompt 1 — Asking for Permissions
Press enter or click to view image in full size
Figure 9. Claude — Prompt 1 — Response

Watch how Claude uses the get-my-army tool here: the MCP server extracts the student ID from the access token we issued during authorization and looks up which army that student commands. The authorization context flows seamlessly from our OAuth flow through to the actual tool execution.

Let’s try something more complex: “I need to review my soldiers’ performance ratings and specialties before our next battle”. This prompt gets Claude thinking strategically, and it makes multiple tool calls within a single execution, using get-student-info for every soldier in the army to build a comprehensive battle preparation report:

Press enter or click to view image in full size
Figure 10. Claude — Prompt 2
Press enter or click to view image in full size
Figure 11. Claude — Prompt 2, Continuation

The authorization system enables Claude to provide personalized responses based on the authenticated user’s specific data. “Ready for your next battle, Commander Wiggin!” — Claude knows exactly who it’s talking to ;)

Similar behavior happens when asking about opponent intelligence: “What are Phoenix Army’s weaknesses? We’re fighting them tomorrow”. Claude combines get-opponent-army with individual get-student-info calls to compose a detailed tactical analysis:

Press enter or click to view image in full size
Figure 12. Claude — Prompt 3
Press enter or click to view image in full size
Figure 13. Claude — Prompt 3, Continuation

Here’s something interesting I discovered during testing. When I asked: “Pull up the file on Petra Arkhanian — what are her tactical strengths?”, our MCP server doesn’t have data for this student:

Press enter or click to view image in full size
Figure 14. Claude — Prompt 4

Since Claude doesn’t get information about Petra from our MCP server, it falls back on its internal knowledge to compose a response. This demonstrates how AI assistants can seamlessly blend MCP-provided data with their training data: when your tools don’t have specific information, Claude can still provide helpful responses based on what it knows internally.

This mixed approach is quite powerful. Your MCP server handles user-specific, private, or real-time data, while Claude’s built-in knowledge fills in the gaps with general information. The result feels like a unified, knowledgeable assistant that has access to both your specific systems and broad world knowledge.

What we’ve demonstrated here goes beyond just a working MCP server: we’ve built a complete authorized integration that Claude can discover, connect to, and use seamlessly. The authorization context flows from our OAuth implementation all the way through to personalized tool responses, exactly as it should in a production system.

The fact that Claude can mix our Battle School data with its own knowledge when needed shows the real power of the MCP approach. Your servers provide the specific, private, or real-time data that only you have access to, while Claude fills in the broader context with its training knowledge. Users get the best of both worlds without having to think about where the information is coming from!

↑ Back to Contents

7. Conclusion

If you’ve made it this far, congratulations! At this point, we have a fully functional remote MCP server with proper authorization that actually works with Claude. I know it’s a lot to digest in one sitting.

What we’ve accomplished here fills the documentation gap that was frustrating developers trying to build secure MCP integrations. Remote MCP authorization went from “theoretically described” to “here’s exactly how to implement it, test it, and deploy it.” The Battle School Computer might be fictional, but the authorization patterns are ready for your real data sources.

The foundation is solid: you can extend this implementation to connect to real databases, APIs, or any other data sources your users need access to. The complete implementation is available at https://github.com/loginov-rocks/Remote-MCP-Auth — take it, extend it, and build something amazing with it.

That’s all Folks!

↑ Back to Contents

Brain dump successfully compiled with assistance from Claude 4 Sonnet.

--

--

Danila Loginov
Danila Loginov

Written by Danila Loginov

🛠️ Solution Architect ⛽️ Petrolhead 🛰️ IoT hobbyist https://loginov.rocks

Responses (4)