Skip to content

How to MCP: The Complete Guide to Understanding the Protocol and Building Remote Servers

Blog cover

Introduction

The Model Context Protocol (MCP) promises an improved and standardized way of connecting LLM applications like Claude and ChatGPT with external tools and services via their APIs. It's a great effort, but if you've ever tried implementing an MCP server you'll quickly find yourself lost amid changing specifications, a lack of real-world examples, and terse documentation.

"The worst documented technology I have ever encountered," as one Hacker News commenter described it.

After spending a week combing through specs, SDK code, and GitHub issues, to build our own remote MCP server, this guide distills everything we've learned about MCP into a practical resource to help you:

  1. Understand MCP fundamentals and how data flows
  2. Build a spec-compliant remote MCP server that's ready for clients like Claude
  3. Implement authentication correctly using OAuth 2.1 from the get-go
  4. Avoid common pitfalls that can cost you hours of debugging

Whether you're building an MCP server to connect your app to LLMs, or a personal one to add AI to your workflows, you'll find the exact steps required to create a working implementation that balances simplicity with full protocol compliance.


Table of contents




Why MCP is frustrating today

The MCP spec is still a work in progress, meaning finding consistent up-to-date code examples and documentation isn't so easy. Here's a quick rundown of common frustrations:

  • Multiple protocol versions: MCP supports two transport standards with different requirements:
    • HTTP+SSE (2024-11-05) - The legacy protocol
    • Streamable HTTP (2025-03-26) - The modern protocol
  • Different communication patterns: Each transport uses distinct methods:
    • HTTP+SSE requires maintaining two separate endpoints (GET /mcp for SSE and POST /messages for requests)
    • Streamable HTTP uses a single endpoint (POST /mcp) but involves complex request/response patterns
  • Local and Remote servers: "server" can refer to either the local or remote implementation of the protocol which can cause confusion about which setup is being discussed
  • Sparse Documentation: The official MCP website recommends vibe-coding your way to an MCP server, at the cost of practical examples
  • Unclear errors and evolving specs: Vague error messages ("Claude was unable to connect") and frequent spec changes add friction to implementation

It’s a new protocol, so much of that can be forgiven, but it’s a big source of confusion. The sections below will provide clarity on all of the above and walk you through building a complete solution.


In the rest of this guide, we'll focus on:

  • Explaining MCP: What it is, how it works and why it helps
  • Building for compatibility: Create a server that handles both modern and legacy protocols
  • OAuth implementation: Set up proper authentication with Firebase and MCP's required endpoints
  • State management: Handle session state across different transport types
  • Production deployment: Deploy your server to cloud platforms with proper monitoring
  • Troubleshooting: Diagnose and fix the most common integration issues

Let's get started!


Part 1: Understanding MCP Fundamentals


What MCP Actually Is (In Plain Language)

The Model Context Protocol is a standardized way for LLM applications (such as Claude, ChatGPT, and other AI tools) to communicate with external APIs and services. It's a bridge between text-based AI models and your code-based API endpoints.

LLMs can't directly run code - but they can be taught to call functions that do. With MCP, you define each of your API capabilities as a "tool" that allow LLMs to:

  1. Discover what tools (your API capabilities) are available
  2. Learn how to use those tools (parameters, formats, etc.)
  3. Choose the most appropriate tool based on the user's prompt
  4. Call those tools to execute your underlying API code
  5. Receive structured responses they can understand

Think of MCP as "OpenAPI for LLMs" - a standard interface that makes it easier for AI models to interact with your services.


How Tools in MCP Connect LLMs to Your Existing API Endpoints

How exactly does an LLM application like Claude know which API endpoint to call and what parameters to send when, for example, a user asks "Show me all my Twitter scrape recipes"?

In our case, Simplescraper has a REST API endpoint /recipes that accepts parameters like host and sort and returns a list of recipe. But LLM applications have no idea this endpoint exists or what parameters it accepts, unless we tell them about it.

To solve this, MCP allows us to create a tool definition that:

  1. Names the tool in a way that describes its purpose
  2. Provides a description of what the tool does
  3. Defines what parameters it accepts
  4. Connects the tool to the actual API endpoint it represents via a handler function

Now when a user asks the Simplescraper MCP about their Twitter recipes, Claude can look at all available tools, understand their purposes through these descriptions, and select the appropriate one to handle the request.


Tool Definition Structure

A tool definition consists of these key elements:

ElementPurposeExample
NameUnique identifier the LLM uses to select the tool"list_recipes"
Parameter SchemaDefines what arguments the tool accepts{ host: z.string() }
DescriptionHelps the LLM understand when to use this tool"Returns a list of user's scrape recipes with optional filters and sorting."
Handler FunctionThe code that calls your API, with access to authentication info (authInfo)async (params, { authInfo }) => { const response = await fetch(${API_BASE_URL}/recipes?host=${params.host}, { headers: { 'Authorization': Bearer ${authInfo.token} }}); return await response.json(); }

The Flow of a Prompt Using Tools

When a user asks a question, several steps happen behind the scenes to connect their natural language request to your API:

First, the LLM application analyzes the user's request to determine intent. Then it selects the appropriate tool from those you've defined. In this example, when it detects a request about Twitter recipes, it chooses the list_recipes tool and automatically adds the parameter host: "twitter.com". Your MCP server then executes the actual API call, receives the data, and returns it in a format the LLM application can present to the user.


Example Tool Definition

Here's how you would define a tool in your MCP server:

javascript
// defining a tool
server.tool(
  "list_recipes",  // Name that LLM application will use
  {
    // parameters schema (what LLM can pass)
    host: z.string().optional().describe("Filter by website host") 
  },
  {
    // metadata (helps LLM decide when to use this tool)
    description: "Returns a list of your scrape recipes with filters"
  },
  // actual function that calls your API
  async (params, { authInfo }) => {
    try {
      // get user's API key
      const apiKey = await getApiKeyFromToken(authInfo.token);
      
      // make the actual API call
      const result = await fetchRecipes(apiKey, params);
      
      // return formatted response to LLM
      return {
        content: [{ type: "text", text: JSON.stringify(result) }]
      };
    } catch (error) {
      return handleError(error);
    }
  }
);

The LLM application never directly executes code or calls your API. It simply identifies the right tool and parameters, then your MCP server handles the actual API communication and returns formatted results.


Tool Authorization and User-Specific Access

An important feature of MCP tool handlers is that they receive an authInfo object containing authentication details about the requesting user.

The AuthInfo object includes:

  • token: The OAuth access token
  • clientId: Identifier for the authenticated user
  • scopes: Array of permission scopes granted to the user
  • expiresAt: Optional expiration timestamp

Which allows you to implement per-user authorization for your tools:

javascript
// toolhandler receives both params and authInfo
async (params, { authInfo }) => {
  try {
    // authInfo contains token, clientId, and scopes
    console.log("tool called with authInfo:", authInfo);
    
    // you can use the token to get user-specific credentials
    let apiKey;
    if (authInfo?.token) {
      // look up the user's API key from their MCP token
      apiKey = await getApiKeyFromToken(authInfo.token);
    }
    
    // you can also check scopes to implement fine-grained access control
    if (!authInfo?.scopes || !authInfo.scopes.includes("list_recipes")) {
      return {
        content: [
          {
            type: "text",
            text: "Unauthorized: User lacks permission to list recipes",
          }
        ]
      };
    }
    
    // make API call with user's credentials
    const result = await fetchRecipes(apiKey, params);
    return { content: [{ type: "text", text: JSON.stringify(result) }] };
  } catch (error) {
    return handleError(error);
  }
}

This enables you to implement powerful authorization patterns:

  1. User-specific API keys: Map MCP tokens to user-specific API keys in your database
  2. Scope-based access control: Restrict tool access based on assigned scopes
  3. Resource-level permissions: Check if a user has access to specific resources
  4. Usage quotas and rate limiting: Implement per-user limits on tool usage


Key Components of an MCP Server

An MCP server consists of several essential components:

  1. Tools: Functions that AI models can call, each with defined parameters and return values
  2. Transport Layer: The communication mechanism between the AI model and your server
  3. Session Management: How your server tracks conversation state across multiple interactions
  4. Authentication: How you verify and authorize access to your tools

The MCP protocol also defines a structured format for messages, based on JSON-RPC 2.0, which handles things like request/response correlation, error reporting, and tool invocation.


MCP Capabilities Table

While tool calling is the core feature that most MCP servers implement, the protocol supports additional capabilities that can enhance functionality and user experience based on your goals.

CapabilityDescriptionStatus
Tool CallingAllow models to invoke your functions with parametersCore Feature
StreamingSend incremental partial results back to clientsOptional
AuthenticationSecure access with OAuth 2.1 with PKCERequired for Production
Session ManagementTrack conversation state across multiple interactionsCore Feature
SamplingEnable models to run prompts through your serverOptional
Dynamic Tool DiscoveryLet models discover available tools at runtimeOptional
Error HandlingReturn structured JSON-RPC errorsCore Feature
Event NotificationsSend server-initiated messages to clientsOptional

Local vs. Remote Servers: Understanding the Options

MCP servers can be implemented in several ways, from local implementations to fully remote services. The MCP ecosystem is still evolving, with local servers currently being the most common due to their ease of implementation, but remote servers likely represent the future direction as the ecosystem matures. Each approach offers different features and use cases.


Local MCP Servers:

Currently, many MCP implementations involve users or companies downloading server code from GitHub repositories and running it locally.

How it works:

  • User downloads MCP server code from GitHub
  • The AI application launches this server as a subprocess on the user's computer
  • Communication happens via STDIO (Standard Input/Output)
  • Configured through a local file like claude_desktop_config.json
json
// claude_desktop_config.json for a local server
{
  "mcpServers": {
    "simplescraper-local": {
      "command": "node",
      "args": [
        "C:/Users/Me/code/mcp/simplescraper-mcp-server.js",
        "--stdio"
      ],
      "env": {
        "SIMPLESCRAPER_API_KEY": "your-api-key-here"
      }
    }
  }
}

Features :

  • Update Process: When server code changes, users must manually download and install updates
  • Configuration Requirements: Users edit configuration files and manage dependencies
  • Updates: Difficult to roll out updates to your server across a user base.
  • Desktop-Bound: Primarily limited to desktop applications that can launch local processes.
  • Credential Management: API keys or other credentials might be stored in local configuration files or environment variables on the user's machine, which might not be ideal.
  • Direct Resource Access: Well-suited for tools that need direct access to local files or system resources (e.g., a server that reads from /Users/username/Documents).

Bridged Remote Servers: An Interim Approach

As MCP implementations evolve, a transitional pattern has emerged: remote servers with a local bridge connector. This approach exists because most MCP clients currently only support local servers via stdio transport, but don't yet support remote servers with OAuth authentication.

How it works:

  • Server runs on the internet, with users running a local bridge tool (like mcp-remote)
  • The bridge forwards requests from the local AI application to the remote server
  • Still involves the claude_desktop_config.json file, but the command points to the bridge tool.
js
// claude_desktop_config.json using mcp-remote to bridge to a remote server
{
  "mcpServers": {
    "simplescraper-remote": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://mcp.simplescraper.io/mcp",
        "--header",
        "Authorization:${AUTH_HEADER}",
        "--transport",
        "http-only"
      ],
      "env": {
        "AUTH_HEADER": "Bearer your-token-here"
      }
    }
  }
}

Features:

  • Update Process: Server logic can be updated centrally, but bridge tool needs local updates
  • Configuration Requirements: Still requires local configuration files
  • Credential Storage: Authentication tokens typically stored in local configuration
  • Use Cases: Good for transitioning from local to remote while supporting existing clients


Remote Servers: Scalable, secure (and the focus of this guide)

The most recent evolution in the MCP specification supports fully remote MCP servers that function like standard web services.

How it works:

  • MCP server runs as a web service accessible via HTTPS
  • AI clients connect directly without local configuration or proxies
  • Authentication happens through standard OAuth 2.1 web flows

image-20250522074207242

Image showing Claude's Integration UI


Features:

  • Update Process: Changes immediately available to all users
  • Configuration Requirements: No local setup or configuration files needed - configuration through LLM application settings UI rather than JSON files
  • Portability: Works on any device that can access the web
  • Credential Storage: Credentials managed through OAuth tokens, not local files
  • Discovery: Central directories of available MCP servers
  • Use Cases: Well-suited for SaaS products and broader integrations

While local and bridged setups have their place, building a remote MCP server offers the most robust, scalable, and user-friendly approach for integrating services with the broader AI ecosystem - these are the servers AI agents will interact with.


Transport Protocols: Streamable HTTP vs. HTTP+SSE

The MCP specification defines multiple transport mechanisms that have evolved over time:


Transport Protocols Comparison Table

FeatureStreamable HTTP (Modern)HTTP+SSE (Legacy)
Protocol Version2025-03-262024-11-05
SDK Transport ClassStreamableHTTPServerTransportSSEServerTransport
EndpointsSingle /mcp for all operationsDual endpoints: GET /mcp + POST /messages
Client-to-ServerPOST /mcpPOST /messages?sessionId=xxx
Server-to-ClientSame POST response (streamed)GET /mcp (SSE stream)
Session IdentificationVia mcp-session-id headerVia query parameters in URL
Session TerminationDELETE /mcpConnection close
Implementation ComplexityLowerHigher
Connection ManagementConnection terminates naturallyRequires explicit management
Primary BenefitSimpler, single connectionCompatible with older clients

The challenge is that, as of mid-2025, we're in a transition period where different clients support different protocols. A production-ready MCP server should support both transport types to ensure compatibility with the widest range of clients.


Part 2: Session management in your MCP Server


What Is Session Management and Why Do You Need It?

In MCP, "session management" simply means keeping track of a conversation between your LLM application, like Claude, and your server across multiple requests. Without sessions, the app would need to start from scratch with every request, making tools that require state (like a shopping cart or building a report through several data-gathering steps) impossible.

When the app first connects to your MCP server, it makes an "initialize" request. Your server needs to:

  1. Create a unique session ID
  2. Set up a transport instance for this conversation
  3. Remember this session for future requests

A "transport" is just MCP's term for the communication handler between your server and the LLM app. Think of it as a phone line - each session gets its own dedicated line so messages don't get mixed up. The transport handles the actual sending and receiving of MCP messages for that specific conversation.


How Sessions Are Passed Back and Forth

The MCP specification defines a specific way to handle session IDs:

  1. When the app first connects (initialization), your server generates a session ID
  2. Your server returns this ID in the Mcp-Session-Id HTTP header
  3. The app must include this same header in all subsequent requests
  4. If the session expires, your server returns a 404, and the app starts over

Implementing Session Management (The Simple Version)

The simplest and most effective approach for most implementations is to use an in-memory JavaScript Map or object to store your sessions:

javascript
const transports = {};  // JavaScript object stores transport instances by session ID

That's it! This object will store all active transport instances, with session IDs as keys.


Code Example: Simple Session Management

Here's a practical example from our MCP server implementation:

javascript
// store all active transports by session ID
const transports = {};

// handle when a client initializes a connection
app.post('/mcp', async (req, res) => {
  // check if this is an initial connection request
  const isInitRequest = req.body && req.body.method === 'initialize';

  if (isInitRequest) {
    // for new sessions, generate a unique ID
    const sessionId = uuidv4();
    
    // create a transport for this session
    const transport = new StreamableHTTPServerTransport();
    transport.sessionId = sessionId;
    
    // store it for future requests 
    transports[sessionId] = transport;
    
    // tell LLM application the session ID
    res.setHeader('mcp-session-id', sessionId);
    
    // handle the initialize request
    await transport.handleRequest(req, res, req.body);
  } 
  else {
    // for existing sessions, get the ID from the header
    const sessionId = req.headers['mcp-session-id'];
    
    // look up the transport for this session
    const transport = transports[sessionId];
    
    if (!transport) {
      return res.status(404).json({
        error: 'Session not found'
      });
    }
    
    // handle the request using the existing transport
    await transport.handleRequest(req, res, req.body);
  }
});

// don't forget cleanup when sessions end
app.delete('/mcp', async (req, res) => {
  const sessionId = req.headers['mcp-session-id'];
  
  if (transports[sessionId]) {
    // Clean up the session
    delete transports[sessionId];
    res.status(204).end();
  } else {
    res.status(404).json({ error: 'Session not found' });
  }
});

That's really all there is to it for most implementations. The MCP SDK handles the complex parts of maintaining conversation state within the transport instance - you just need to make sure you keep track of which transport belongs to which session.

NOTE

For production servers, you might want to add a simple timeout mechanism to clean up abandoned sessions, but the basic in-memory approach works well for most use cases.


When to Consider More Complex Options

For most implementations, the simple in-memory approach works. You might consider more complex options like Redis or database storage if:

  • You're running multiple server instances behind a load balancer
  • Your server needs to survive restarts without losing session state
  • You have very high traffic volumes (thousands of concurrent sessions)

With session management in place to maintain conversation state, next we'll take a look at authentication, which ensures only authorized users can access your MCP tools.


Part 3: Securing Access with Authentication and OAuth

With session management in place to track conversation state, the next step is securing access to your MCP tools. This involves two related but distinct processes:

User Authentication: Verifying who your actual human user is (via Firebase, Auth0, etc.)

AI Client Authorization: Granting an LLM application permission to access your APIs on behalf of that authenticated user (via OAuth 2.1)

Don't let "OAuth 2.1" intimidate you - it's simply a standardized way for AI clients to request access to your tools, and does not require external service or libraries (although many good ones exist to make things easier). In practice, you'll implement a few simple endpoints in your Express server:

javascript
// express endpoints you'll build to be OAuth 2.1 compliant
app.get('/.well-known/oauth-protected-resource', (req, res) => {...})
app.get('/.well-known/oauth-authorization-server', (req, res) => {...})
app.get('/authorize', (req, res) => {...})
app.get('/callback', (req, res) => {...})
app.post('/token', (req, res) => {...})

If you have an existing app with users, you may use an auth system like Firebase or Clerk which already handles the hard parts (secure login, password management), meaning you only need to focus on the endpoints that connect authenticated users to AI clients.

This dual security model ensures that:

  • Only real users with accounts can access your services
  • AI clients can only take actions the user has specifically authorized
  • You maintain control over which tools are accessible

Understanding the Tokens and Codes

It's helpful to understand the different tokens and codes used in the MCP OAuth flow, as this is a common source of confusion:

Token/Code NameWhat It IsComes FromLifespanStoragePurpose
Identity TokenSigned token proving user identityFirebase/Auth0 (after user login)~1 hourNot stored - used immediatelyVerify user is who they claim to be
Authorization CodeTemporary exchange codeYour MCP server creates it~10 minutesStored temporarily in databaseLLM application exchanges this for access token
Access TokenLong-lived API access credentialYour MCP server creates itDays/weeksStored permanently in databaseLLM application sends this with every API request
Bearer TokenSame as Access TokenSame as Access TokenSame as Access TokenSame as Access TokenThe way to send access token in headers

Understanding the Complete OAuth Flow in MCP

When a user first asks an LLM application to use your MCP server, here's what happens:

  1. Initial Tool Request: The LLM application attempts to access your server
  2. Authentication Challenge: Your server responds with "401 Unauthorized"
  3. OAuth Discovery: The LLM application obtains authorization server information
  4. User Login Redirect: The LLM application directs the user to your login page
  5. Authentication: User logs in with your auth provider (Firebase, Auth0, etc.) and receives an identity token
  6. Authorization Code Grant: Your server creates a temporary authorization code
  7. Token Exchange: The LLM application exchanges this authorization code for an access token
  8. Authorized Tool Access: The LLM application can now make authorized requests to your tools

This flow combines standard user authentication with the OAuth 2.1 protocol that governs how AI clients get authorized access. Here's how that looks visualized:



And here are the specific endpoints required to be implemented for this flow :

Step #NameEndpointPurpose
1Initial RequestYour remote mcp server (eg https://mcp.simplescraper.io/)Client attempts to use a tool
2Protected Resource Discovery/.well-known/oauth-protected-resourceDiscovers auth server details
3Authorization Server Discovery/.well-known/oauth-authorization-serverProvides detailed auth server metadata
4Authentication/authorizeShows login UI to the user
5Authorization Code/callbackReceives result after user login
6Token Exchange/tokenExchanges code for token
7Authenticated Access/mcp with Authorization headerUses token for all tool calls

We'll implement all of these endpoints below, including PKCE (a security feature that prevents certain types of attacks).


Implementing OAuth 2.1 endpoints

javascript
// mcp-server.js
// protected Resource Metadata endpoint
app.get('/.well-known/oauth-protected-resource', (req, res) => {
  const baseUrl = getBaseUrl(req);
  
  const metadata = {
    authorization_servers: [
      {
        issuer: baseUrl,
        authorization_endpoint: `${baseUrl}/authorize`,
      }
    ]
  };
  
  res.json(metadata);
});

// authorization Server Metadata endpoint
app.get('/.well-known/oauth-authorization-server', (req, res) => {
  const baseUrl = getBaseUrl(req);
  
  res.json({
    issuer: baseUrl,
    authorization_endpoint: `${baseUrl}/authorize`,
    token_endpoint: `${baseUrl}/token`,
    registration_endpoint: `${baseUrl}/register`,
    token_endpoint_auth_methods_supported: ["none"],
    scopes_supported: ["scrape"],
    response_types_supported: ["code"],
    response_modes_supported: ["query"],
    grant_types_supported: ["authorization_code"],
    code_challenge_methods_supported: ["S256"]
  });
});

These endpoints provide information about our authentication flow to MCP clients.


User Authentication Options for Your MCP Server

You have several options for implementing user authentication:

Your Existing Auth System

  • If your application already has authentication, use it!
  • You'll just need to add the OAuth endpoints required by MCP
  • Avoids introducing another auth system
  • Works with any authentication system that can issue tokens

Firebase Auth (What we'll use in this guide)

  • Easy to set up with minimal code
  • Free for most use cases
  • Handles login UI and user management
  • Integrates with broader Google Cloud services

Supabase Auth

  • Open source alternative
  • Directly integrates with PostgreSQL database
  • Great if you're already using Supabase

Clerk

  • Developer-friendly with beautiful UI components
  • Pre-built React components make integration easy
  • Great user management features

Auth0

  • More comprehensive with advanced features
  • Handles both authentication AND authorization rules
  • Provides ready-made login pages and social logins
  • Great compliance features for regulated industries
  • Note: You still need all the same MCP OAuth endpoints even with Auth0


Creating the User Login Experience

While auth providers like Firebase have their own login UI, you may want a custom login page to match your application's branding. You'll create an /authorize endpoint that serves an HTML page for user login (e.g., Google or email sign-in) and then redirects to the /callback endpoint with OAuth parameters.


The /authorize Endpoint

javascript
// mcp-server.js
app.get('/authorize', (req, res) => {
  res.sendFile(path.join(__dirname, 'public', 'authorize.html'));
});

Frontend Login Logic

Your login page needs to:

  1. Authenticate the user with your auth provider (Firebase, Auth0, etc.)
  2. Get an identity token proving who they are
  3. Redirect to /callback with all the OAuth parameters
js
// in your authorize.html file
function sendToken() {
  firebase.auth().currentUser.getIdToken()
    .then(idToken => {
      const urlParams = new URLSearchParams(window.location.search);
      const callbackUrl = new URL('/callback', window.location.origin);
      
      // add the identity token from Firebase
      callbackUrl.searchParams.set('idToken', idToken);
      
      // forward OAuth parameters from the original request
      ['code_challenge', 'code_challenge_method', 'state', 'redirect_uri', 'client_id']
        .forEach(param => {
          if (urlParams.get(param)) {
            callbackUrl.searchParams.set(param, urlParams.get(param));
          }
        });
      callbackUrl.searchParams.set('response_type', 'code');

      // redirect to callback with all parameters
      window.location.href = callbackUrl.toString();
    })
    .catch(err => {
      console.error('Login failed:', err);
      document.getElementById('statusMessage').innerText = `❌ Login failed: ${err.message}`;
    });
}

This handles the user login process and redirects to /callback with both the identity token and OAuth parameters required for the next step.

NOTE

💡Other auth providers like Auth0, Clerk, or Supabase would replace firebase.auth().currentUser.getIdToken() with their own token retrieval methods, but the overall pattern remains the same.


Implementing Access Token Verification

With the user login flow in place, we now need to handle the access tokens that the LLM application sends in the request Authorization Bearer header with each request. This verification process ensures that only valid tokens from authenticated users can access our tools.

We can create a helper function that validates these tokens and provides user context to our tool handlers:

javascript
// mcp-server.js
// authentication helper function
async function authenticateToken(req, res, rpcId = null) {
  const authHeader = req.headers['authorization'] || '';
  const token = authHeader.replace(/^Bearer\s+/i, '').trim();
  const baseUrl = getBaseUrl(req);

  if (!token) {
    const wwwAuthHeader = `Bearer realm="MCP Server", resource_metadata_uri="${baseUrl}/.well-known/oauth-protected-resource"`;
    
    return {
      success: false,
      response: res.status(401)
        .header('WWW-Authenticate', wwwAuthHeader)
        .json({
          jsonrpc: '2.0',
          error: { code: -32000, message: 'Missing Bearer token' },
          id: rpcId
        })
    };
  }

  try {
    // verify token in Firestore
    const doc = await db.collection('mcp-tokens').doc(token).get();
    if (!doc.exists) {
      return {
        success: false,
        response: res.status(403).json({
          jsonrpc: '2.0',
          error: { code: -32001, message: 'Invalid or expired token' },
          id: rpcId
        })
      };
    }

    const tokenData = doc.data();

    // create auth object for MCP server
    const authObject = {
      token: token,
      clientId: String(tokenData.user_uid),
      scopes: Array.isArray(tokenData.scopes) ? tokenData.scopes.map(String) : []
    };

    return {
      success: true,
      tokenData,
      authObject
    };
  } catch (dbError) {
    return {
      success: false,
      response: res.status(500).json({
        jsonrpc: '2.0',
        error: { code: -32002, message: 'Database error during authentication' },
        id: rpcId
      })
    };
  }
}

The returned authObject contains everything our tool handlers need to know about who's making the request and what they're allowed to do.


Part 4: Building the Core MCP Server & Transport Handlers


Core Server Setup with Express

Let's start by setting up the basic structure of our MCP server using Express. This forms the foundation for our implementation:

javascript
// mcp-server.js
import express from 'express';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { v4 as uuidv4 } from 'uuid';
import authRoutes from './auth-routes.js';
import { debugLog } from './utils.js';
import { db } from './firebaseConfig.js';

// Create Express app
const app = express();
const port = process.env.PORT || 3000;

// Configure middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Add CORS middleware
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE');
  res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, MCP-Session-ID');
  res.header('Access-Control-Expose-Headers', 'MCP-Session-ID, WWW-Authenticate');
  
  if (req.method === 'OPTIONS') {
    return res.status(200).end();
  }
  
  next();
});

// Mount authentication routes
app.use(authRoutes);

// Create MCP server with tools registration
const mcpServer = new McpServer({
  name: "My MCP Server",
  version: "1.0.0",
  instructions: `Instructions for using these tools...`
});

// Register your tools here
registerTools(mcpServer);

// Transport maps
const transports = {};
const pendingTransports = {};

// Start server
app.listen(port, () => {
  console.log(`MCP server running on port ${port}`);
  console.log(`MCP endpoint available at http://localhost:${port}/mcp`);
});

Next, we'll implement the transport handling.


Transport Implementation Details

This function encapsulates the creation and connection of a transport instance. By mapping each session to a dedicated transport, we maintain state across multiple requests.

javascript
// mcp-server.js
// Helper function to create and connect a transport
async function createAndConnectTransport(sessionId, mcpServer, transports) {
  if (pendingTransports[sessionId] || transports[sessionId]) {
    return pendingTransports[sessionId] || transports[sessionId];
  }

  const transport = new StreamableHTTPServerTransport({
    enableJsonResponse: true,
    eventSourceEnabled: true,
    onsessioninitialized: (actualId) => {
      delete pendingTransports[actualId];
    }
  });

  // Manually assign session ID
  transport.sessionId = sessionId;

  // Set cleanup handler
  transport.onclose = () => {
    if (transports[sessionId]) {
      delete transports[sessionId];
    }
  };

  // Track pending transport and store immediately
  pendingTransports[sessionId] = transport;
  transports[sessionId] = transport;

  // Connect to MCP server
  try {
    await mcpServer.connect(transport);
  } catch (error) {
    delete pendingTransports[sessionId];
    delete transports[sessionId];
    throw error;
  }

  return transport;
}

Session Management Implementation

Finally, let's implement the main MCP endpoint that handles session management:

javascript
// mcp-server.js
// POST handler for /mcp endpoint
app.post('/mcp', async (req, res) => {
  const body = req.body;
  const rpcId = (body && body.id !== undefined) ? body.id : null;

  // authenticate the token
  const authResult = await authenticateToken(req, res, rpcId);
  if (!authResult.success) {
    return authResult.response;
  }

  // assign auth object to request
  req.auth = authResult.authObject;

  // extract session ID from header
  const clientSessionIdHeader = req.headers['mcp-session-id'];
  const actualClientSessionId = Array.isArray(clientSessionIdHeader) 
    ? clientSessionIdHeader[0] 
    : clientSessionIdHeader;

  let transport;
  let effectiveSessionId;

  // check if this is an initialize request
  const isInitRequest = body && body.method === 'initialize';

  if (isInitRequest) {
    // for initialize requests, create a new session
    effectiveSessionId = uuidv4();
    transport = await createAndConnectTransport(effectiveSessionId, mcpServer, transports);
    
    // set session ID in response header
    res.setHeader('mcp-session-id', effectiveSessionId);
  } else if (actualClientSessionId && transports[actualClientSessionId]) {
    // for existing sessions, use the existing transport
    transport = transports[actualClientSessionId];
    effectiveSessionId = actualClientSessionId;
  } else {
    // invalid session ID for non-initialize request
    return res.status(400).json({
      jsonrpc: '2.0',
      error: { code: -32003, message: 'Bad Request: No valid session ID for non-initialize request.' },
      id: rpcId
    });
  }

  // set session ID in request headers for the transport
  req.headers['mcp-session-id'] = effectiveSessionId;
  
  // always set session ID in response headers
  res.setHeader('mcp-session-id', effectiveSessionId);

  // handle the request using the transport
  try {
    await transport.handleRequest(req, res, body);
  } catch (handleError) {
    if (!res.headersSent) {
      res.status(500).json({ 
        jsonrpc: '2.0', 
        error: { code: -32603, message: 'Internal server error during MCP request handling' }, 
        id: rpcId 
      });
    }
  }
});

This endpoint is used to create a new session for initial Streamable HTTP request and using existing sessions for subsequent requests.


Supporting Both Modern and Legacy Clients

A key challenge in building a production-ready MCP server is supporting both modern clients (using Streamable HTTP) and legacy clients (using HTTP+SSE). Rather than creating separate implementations, we'll use a unified approach:

  1. Single MCP Server Instance: Register all tools with one central MCP server
  2. Multiple Transport Types: Support different transport implementations for different clients
  3. Protocol Detection: Route requests to the appropriate transport handler based on HTTP method and path
  4. Shared Authentication: Apply the same authentication logic regardless of transport type

This approach provides maximum compatibility while minimizing code duplication and maintenance overhead.

                     Client Request


            ┌─────────────────────────┐
            │      Request Router     │
            └─────────────┬───────────┘

                ┌─────────┴──────────┐
                │                    │
                ▼                    ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│   Modern Transport      │ │   Legacy Transport      │
│  (StreamableHTTP)       │ │  (HTTP+SSE)             │
└─────────────┬───────────┘ └─────────────┬───────────┘
              │                           │
              ▼                           ▼
┌─────────────────────────────────────────────────────┐
│                    MCP Server                       │
└─────────────────────────────────────────────────────┘


               ┌─────────────────────┐
               │   Tools Registry    │
               └─────────────────────┘

Modern Clients (Streamable HTTP)

Modern clients use the Streamable HTTP transport with these key characteristics:

  1. Single /mcp Endpoint: All requests go through one endpoint
  2. Session ID via Header: The mcp-session-id header tracks session state
  3. JSON or SSE Responses: The same endpoint can return either format based on the client's needs
  4. DELETE for Cleanup: Clients can explicitly terminate sessions

┌────────────────┐
│Client POST /mcp│
└───────┬────────┘


┌────────────────┐
│   Validate     │
│  Bearer Token  │
└───────┬────────┘


┌────────────────┐
│Extract/Generate│
│   Session ID   │
└───────┬────────┘


┌────────────────┐
│ Get/Create     │
│   Transport    │
└───────┬────────┘


┌────────────────┐
│transport.handle│
│    Request     │
└───────┬────────┘


┌────────────────┐
│   Response     │
│  (streamed)    │
└────────────────┘

Modern Client Implementation

js
// POST handler for /mcp (modern Streamable HTTP clients)
app.post('/mcp', async (req, res) => {
  const body = req.body;
  const rpcId = (body && body.id !== undefined) ? body.id : null;

  // authenticate the token and assign it to req.auth
  const authResult = await authenticateToken(req, res, rpcId);
  if (!authResult.success) {
    return authResult.response;
  }

  req.auth = authResult.authObject;

  // session and transport handling
  const clientSessionIdHeader = req.headers['mcp-session-id'];
  const actualClientSessionId = Array.isArray(clientSessionIdHeader) 
    ? clientSessionIdHeader[0] 
    : clientSessionIdHeader;

  let transport;
  let effectiveSessionId;

  // check if this is an initialize request
  const isInitRequest = body && body.method === 'initialize';

  if (isInitRequest) {
    // create new session for initialize requests
    effectiveSessionId = uuidv4();
    transport = await createAndConnectTransport(
      effectiveSessionId, 
      mcpServer, 
      transports, 
      'Initialize: '
    );

    // set the session ID in the response header for initialize requests
    res.setHeader('mcp-session-id', effectiveSessionId);

  } else if (actualClientSessionId && pendingTransports[actualClientSessionId]) {
    // use pending transport for remote LLM application sessions
    transport = await pendingTransports[actualClientSessionId];
    effectiveSessionId = actualClientSessionId;

  } else if (actualClientSessionId && transports[actualClientSessionId]) {
    // use existing transport for known sessions
    transport = transports[actualClientSessionId];
    effectiveSessionId = actualClientSessionId;

  } else if (actualClientSessionId) {
    // create new transport for unknown session ID
    effectiveSessionId = actualClientSessionId;
    transport = await createAndConnectTransport(
      effectiveSessionId, 
      mcpServer, 
      transports, 
      'Unknown Session: '
    );

  } else {
    // error: non-initialize request without session ID
    return res.status(400).json({
      jsonrpc: '2.0',
      error: { 
        code: -32003, 
        message: 'Bad Request: No session ID provided for non-initialize request.' 
      },
      id: rpcId
    });
  }

  // ensure session ID is consistent across request/response
  req.headers['mcp-session-id'] = effectiveSessionId;
  res.setHeader('mcp-session-id', effectiveSessionId);

  // handle request using MCP transport
  try {
    // pass the original Express req, res, and parsed body
    await transport.handleRequest(req, res, body);
    return;
  } catch (handleError) {
    console.error(`MCP POST handleRequest error (session ${effectiveSessionId}):`, handleError);
    if (!res.headersSent) {
      res.status(500).json({ 
        jsonrpc: '2.0', 
        error: { 
          code: -32603, 
          message: 'Internal server error during MCP request handling' 
        }, 
        id: rpcId 
      });
    }
  }
});

Legacy Clients (HTTP+SSE)

Legacy clients use the HTTP+SSE transport, which requires a different approach with two separate endpoints:

  • Dual Endpoints: GET /mcp for SSE stream and POST /messages for requests
  • Session ID in URL: Query parameters used for session tracking
  • Connection Management: Long-lived SSE connections must be properly maintained
┌────────────────┐      ┌────────────────┐
│ Client GET /mcp│      │Client POST     │
│  (SSE stream)  │      │/messages?id=xxx│
└───────┬────────┘      └───────┬────────┘
        │                       │
        ▼                       ▼
┌────────────────┐      ┌────────────────┐
│   Validate     │      │   Validate     │
│  Bearer Token  │      │  Bearer Token  │
└───────┬────────┘      └───────┬────────┘
        │                       │
        ▼                       ▼
┌────────────────┐      ┌────────────────┐
│Create SSEServer│      │ Extract Session│
│   Transport    │      │ID from URL Param│
└───────┬────────┘      └───────┬────────┘
        │                       │
        ▼                       ▼
┌────────────────┐      ┌────────────────┐
│mcpServer.connect│     │ Lookup Transport│
│  (transport)    │     │ By Session ID  │
└───────┬────────┘      └───────┬────────┘
        │                       │
        │                       ▼
        │              ┌────────────────┐
        │              │transport.handle│
        │              │  PostMessage   │
        │              └───────┬────────┘
        │                      │
        ▼                      ▼
┌────────────────┐     ┌────────────────┐
│ Keep-Alive SSE │     │JSON Response to│
│Connection (resp)│     │  POST Request  │
└────────────────┘     └────────────────┘

Legacy Client Implementation

javascript
// GET handler for /mcp (legacy SSE stream)
app.get('/mcp', async (req, res) => {
  // authenticate the token
  const authResult = await authenticateToken(req, res, null);
  if (!authResult.success) {
    return authResult.response;
  }
  
  req.auth = authResult.authObject;
  
  // create SSE transport
  const transport = new SSEServerTransport('/messages', res);
  
  // store transport for future messages
  transports[transport.sessionId] = transport;
  
  // set SSE headers
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no');
  res.setHeader('MCP-Session-ID', transport.sessionId);
  
  // connect to MCP server
  try {
    await mcpServer.connect(transport, { auth: req.auth });
  } catch (error) {
    if (!res.headersSent) {
      res.status(500).send('Internal server error during SSE setup.');
    } else {
      res.end();
    }
    
    // clean up transport
    if (transports[transport.sessionId]) {
      delete transports[transport.sessionId];
    }
  }
});

// POST /messages handler for legacy clients
app.post('/messages', express.json(), async (req, res) => {
  const sessionId = req.query.sessionId;
  const body = req.body;
  const rpcId = (body && body.id !== undefined) ? body.id : null;
  
  // authenticate the token
  const authResult = await authenticateToken(req, res, rpcId);
  if (!authResult.success) {
    return authResult.response;
  }
  
  req.auth = authResult.authObject;
  
  if (!sessionId) {
    return res.status(400).json({
      jsonrpc: '2.0',
      error: { code: -32000, message: 'Missing sessionId in query parameters' },
      id: rpcId
    });
  }
  
  const transport = transports[sessionId];
  
  if (!transport || !(transport instanceof SSEServerTransport)) {
    return res.status(404).json({
      jsonrpc: '2.0',
      error: { code: -32001, message: 'Session not found or not an SSE session' },
      id: rpcId
    });
  }
  
  try {
    await transport.handlePostMessage(req, res, body);
  } catch (error) {
    if (!res.headersSent) {
      res.status(500).json({
        jsonrpc: '2.0',
        error: { code: -32603, message: 'Internal server error handling message' },
        id: rpcId
      });
    }
  }
});

Managing Session State Across Transport Types

One challenge with supporting multiple transport types is ensuring consistent session management. Our approach uses a single transports map to store all transport instances, regardless of type.

This allows us to:

  1. Track All Sessions: Maintain a consistent view of active sessions
  2. Reuse Authentication Logic: Apply the same authentication regardless of transport type
  3. Clean Up Resources: Properly terminate sessions when they're no longer needed

The key is to make sure that each transport's sessionId is unique, even across different transport types.


Error Handling and Graceful Fallbacks

Proper error handling is crucial for a reliable MCP server. Our implementation includes several error-handling strategies:

  1. JSON-RPC Error Responses: Return structured error objects that follow the JSON-RPC 2.0 specification
  2. Transport-Specific Error Handling: Different handling for HTTP responses vs. SSE streams
  3. Authentication Errors: Specific error codes for missing, invalid, or expired tokens
  4. Session Errors: Clear error messages for session-related issues
  5. Transport Errors: Graceful handling of transport-level exceptions

Common MCP Error Codes

CodeDescriptionCommon CauseHow to Handle
-32000Authentication ErrorMissing or invalid tokenReturn WWW-Authenticate header
-32001Invalid SessionSession ID not foundClient should reinitialize
-32002Method Not FoundClient called unknown methodCheck method name
-32003Invalid ParametersMissing or invalid parametersValidate parameters
-32004Internal ErrorServer-side exceptionLog details for debugging
-32005Parse ErrorInvalid JSONValidate request format

Implementing these error-handling strategies ensure that clients receive meaningful feedback when issues occur.


Part 5: Deployment and Production Considerations

Once your MCP server is working locally, you'll need to deploy it to make it accessible to LLM applications like Claude. The choice of deployment platform depends on your transport implementation and scaling requirements.


Cloud Deployment Options

1. Google Cloud Run

  • Serverless and scales automatically
  • Simple docker deployment with gcloud run deploy
  • Works well with Firebase Auth
bash
gcloud run deploy mcp-server --source . --platform managed --region us-central1 --allow-unauthenticated

2. Vercel

  • Zero-configuration deployments
  • Excellent for Node.js applications
  • Built-in edge network for global performance
  • Seamless GitHub integration

3. Railway

  • Automatic deployments from GitHub
  • Built-in database options

4. Digital Ocean App Platform

  • Simple deployment from Git repositories
  • Managed SSL certificates
  • Good choice for small to medium applications

NOTE

⚠️ Legacy HTTP+SSE transport (2024-11-05 spec) requires persistent connections, preventing serverless platforms like Google Cloud Run from scaling to zero when idle. This reduces cost efficiency compared to Streamable HTTP transport (2025-03-26 spec), which enables proper serverless scaling through stateless requests. This limitation applies to most serverless providers.


Azure App Service, AWS Elastiv Beanstalk, and Render are also alternatives. However, any platform that supports Node.js applications can host your MCP server with proper configuration.


Part 6: Troubleshooting and Lessons Learned


Authentication Challenges and Solutions

Authentication was one of the most challenging aspects of implementing our MCP server. A few key lessons:

  1. OAuth Discovery Flow: Many clients failed silently without proper discovery endpoints. Always implement /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server.

  2. PKCE Support: The PKCE flow is required by the MCP spec. Our solution validates the code_verifier against the stored code_challenge:

javascript
if (tokenData.code_challenge) {
  if (!code_verifier) {
    return res.status(400).json({ error: 'code_verifier required for PKCE flow' });
  }
  
  let calculatedChallenge;
  if (tokenData.code_challenge_method === 'S256') {
    const hash = crypto.createHash('sha256').update(code_verifier).digest('base64url');
    calculatedChallenge = hash;
  } else {
    calculatedChallenge = code_verifier;
  }
  
  if (calculatedChallenge !== tokenData.code_challenge) {
    return res.status(401).json({ error: 'Invalid code_verifier' });
  }
}

  1. WWW-Authenticate Header: When a token is missing, clients expect a proper WWW-Authenticate header to start the OAuth flow. Our implementation follows the standard format:
javascript
const wwwAuthHeader = `Bearer realm="MCP Server", resource_metadata_uri="${baseUrl}/.well-known/oauth-protected-resource"`;
res.header('WWW-Authenticate', wwwAuthHeader);

  1. Client Registration: Some clients require dynamic client registration. Implement a /register endpoint to support these clients:
javascript
router.post('/register', express.json(), async (req, res) => {
  const { client_name, redirect_uris = [] } = req.body;
  const client_id = uuidv4();
  
  await db.collection('mcp-clients').doc(client_id).set({
    client_name,
    redirect_uris,
    createdAt: Date.now()
  });
  
  res.status(201).json({
    client_id,
    token_endpoint_auth_method: 'none',
    redirect_uris
  });
});

Debugging Transport-Specific Issues

Each transport type comes with its own set of challenges:

Streamable HTTP Issues:

  1. Session ID Propagation: Make sure to set the mcp-session-id header in both directions:

    • Extract it from request headers for incoming requests
    • Set it in response headers, especially for initialize requests
  2. Content-Type Handling: Different clients expect different content types:

    • Some expect application/json for regular responses
    • Others expect text/event-stream for streamed responses
    • Configure StreamableHTTPServerTransport with enableJsonResponse: true and eventSourceEnabled: true to support both

HTTP+SSE Issues:

  1. SSE Connection Timeouts: SSE connections can timeout unexpectedly:

    • Set appropriate headers to prevent proxy servers from closing the connection
    • Consider implementing a heartbeat mechanism to keep connections alive
  2. Session Management Complexity: The dual-endpoint nature of HTTP+SSE makes session management more complex:

    • Ensure that POST /messages can find the transport created by GET /mcp
    • Clean up resources properly when SSE connections close
  3. Headers Already Sent: A common error with SSE is trying to send headers after the stream has started:

    • Check res.headersSent before attempting to send status codes or headers
    • Handle errors differently for SSE vs. regular HTTP responses

Session Management Gotchas

Several session management issues caused subtle bugs in our implementation:

  1. Race Conditions: When a client sends multiple requests in quick succession, race conditions can occur:

    • Use a pendingTransports map to track transports being created
    • Check both transports and pendingTransports before creating a new transport
  2. Session Cleanup: Proper session cleanup is essential to prevent memory leaks:

    • Set an onclose handler for each transport
    • Implement explicit cleanup in the DELETE handler
    • Consider adding a session timeout mechanism for abandoned sessions
  3. Manual Session ID Assignment: The SDK doesn't always set the sessionId property automatically:

    • Explicitly assign transport.sessionId = sessionId after creation
    • Use this same ID in the transports map for consistent lookup

Working Around MCP Spec Limitations

The MCP specification has several limitations that required workarounds:

  1. Authentication Context: The spec doesn't provide a standard way to pass authentication context to tool handlers:

    • We used req.auth to pass authentication information through the request object
    • Future versions of the SDK may support a better approach
  2. Inconsistent SSE Implementation: Different clients implement SSE differently:

    • Some expect event IDs, others don't
    • Some require specific event types, others use default events
    • Test with multiple clients to ensure compatibility
  3. Limited Error Handling: The spec doesn't fully define error handling:

    • We implemented a consistent approach using JSON-RPC error codes
    • Always include the original id in error responses for proper correlation

Testing Your Implementation

Once you've built your MCP server, you'll want to test it to ensure it works with different clients. One current (and temporary) hurdle is that for now accessing the Claude's remote MCP settings requires a Claude Max subscription. Fortunately, there are other approaches to test your implementation.


Using mcp-remote for Testing

The mcp-remote tool provides a convenient way to test your remote MCP server. This approach uses Claude Desktop (or any other LLM application supporting MCP) with a local bridge to your remote server.

Add the following configuration to your claude_desktop_config.json file:

json
{
  "mcpServers": {
    "test-remote-mcp": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://your-server-url.com/mcp",
        "--header",
        "Authorization:${AUTH_HEADER}",
        "--transport",
        "http-only"
      ],
      "env": {
        "AUTH_HEADER": "Bearer your-token-here"
      }
    }
  }
}

Cloudflare's AI playground

Cloudflare provide an AI playground that allows you to enter the URL of your remote MCP server and initiate the authorization process. It comes with a debug log that is helpful for indentifying any issues that occur during the process.


Conclusion

Building a reliable MCP server today feels a little rough around the edges, mainly because of sparse documentation and a rapidly evolving specification. However, as this guide shows, once you have the steps in front of you, building a full functionality server is relatively simple.

With your server running, your app can seamlessly talk to LLMs and you can enjoy being an early adopter to a protocol that may turn out to be as foundational to AI as HTTP became for the web.

And having gotten a better understanding of how the protocol works, you're well-positioned to quickly adapt to whatever changes come next as MCP adoption grows.


Additional Resources

For those looking to learn more about MCP implementation, here are some helpful resources:

Scrape Data with Simplescraper

Turn websites into structured data in seconds.