remote_mcp_auth

losvedir/remote_mcp_auth

3.1

If you are the rightful owner of remote_mcp_auth and would like to certify it and/or have it hosted online, please leave a comment on the right or send an email to henry@mcphub.com.

This document provides a comprehensive guide on setting up and running a Model Context Protocol (MCP) server, specifically focusing on integration with the Claude web app client.

Tools
1
Resources
0
Prompts
0

Up and running

To run this locally:

  • Get the Cloudflare testing tunnel going: cloudflared tunnel --url http://localhost:4000
  • Copy the tunnel host to @tunnel in McpController.
  • mix phx.server

That should get everything ready. Then go to https://claude.ai "Manage Connectors" and add a custom one. Use the provided Cloudflare URL appended with "/mcp" and name it whatever you want. After that click "Connect" and it should be available. You can get it to use the tool with a prompt like "Who is the best person whose name starts with 'blah'".

Screenshot of working Claude integration

Remote MCP Server Auth in Practice

You work for a SaaS and senior leadership says that you have to wrap your API in this new MCP thing that everyone is talking about. You've read the almost surely Claude-barfed spec and aren't really sure what to make of it. What does it mean? There's a lot of SHOULDs and terms and such, but concretely, you just want to actually add your SaaS's tools to the popular clients out there. Further the auth spec has a lot of SHOULDs, but in practice it doesn't seem like Claude actually follows it.

This document details how to do the Authentication/Authorization piece of a Remote MCP server, specifically with the Claude web app client.

I will accept PRs from anyone wanting to detail how other clients (in particular, ChatGPT!) implement the client part of the spec in practice, too.

This repo is also an Elixir app that speaks this protocol. Feel free to deploy it or run it locally with ngrok or cloudflare tunnel pointing to it.

Claude

Basically, let's see what's involved in making this work:

Screenshot of adding an integration to Claude

Suppose you put your integration there, with a path of /mcp. The first thing that happens, when you add the integration, is Anthropic will test initializing a connection to it:

POST /mcp HTTP/1.1
Accept: application/json, text/event-stream
Accept-Encoding: gzip
Content-Type: application/json

{
  "method": "initialize", 
  "params": {
    "protocolVersion": "2024-11-05", 
    "capabilities": {}, 
    "clientInfo": {
      "name": "claude-ai", 
      "version": "0.1.0"
    }
  }, 
  "jsonrpc": "2.0",
  "id": 0
}

Since the request isn't authenticated yet, we can respond with a 401 and per the spec a particular header which tells Claude where to find the oauth-protected-resource metadata.

HTTP/1.1 401 Unauthorized
Www-Authenticate: Bearer resource_metadata=https://garbage-actress-light-legislature.trycloudflare.com/.well-known/oauth-protected-resource
GET /mcp
Accept: text/event-stream

to which we can respond with the same 401.

At this point Claude is satisfied and won't issue any more requests, until you try to connect:

Screenshot of connecting to an integration to Claude

Per this diagram

it's supposed to then make a call to that oauth-protected-resource endpoint returned in the 401 header, and look at an authorization_servers field to find the /.well-known/oauth-authorization-server host, per spec. However, I was unable to make it do so, and instead it always just called the path on the host that my integration lived at.

GET /.well-known/oauth-authorization-server HTTP/1.1

It expects a response with these three pieces of data:

{
  registration_endpoint: "https://some_host.com/register_oauth_client",
  authorization_endpoint: "https://some_host.com/authorize_oauth_client",
  token_endpoint: "https://some_host.com/oauth_token"
}

It will then POST to that endpoint to register the client via OAuth DCR.

POST /register_oauth_client HTTP/1.1

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

This should be responded to per RFC 7591. Note that the request specifies token_endpoint_auth_method: "none", implying it won't send a client_secret on the later /token call. I tried various responses, like incluindg a client_secret and a value of token_endpoint_auth_method of client_secret_post, but could not get Claude to include the secret later. As such, we should treat this as a public client, and not a confidential client. The later requests do use PKCE.

I also tried returning different scopes, but they were ignored. Claude insists on using its own claudeai scope.

So we simply need to respond with a client_id.

HTTP/1.1 201 Created

{
  "client_id": "public-client-id-for-claude"
}

Note that at this point Claude saves this client for the given integration domain. If you use the web interface to remove the client, and then try adding it again, it will not make the register call again.

At this point, it's a standard 3-legged authorization code grant OAuth flow, for public clients with PKCE.

Claude redirects the user's browser to the authorize endpoint, with a state param, PKCE, and the client_id returned earlier:

GET /authorize_oauth_client?....

These are the query params included (broken out here for easier reading):

"client_id": "claude-ai-oauth-client"
"code_challenge": "<PKCE challenge>"
"code_challenge_method": "S256"
"redirect_uri": "https://claude.ai/api/mcp/auth_callback"
"response_type": "code"
"scope": "claudeai"
"state": "<state value>"

We do our authorization and redirect back to Claude's:

HTTP/1.1 302 Found
Location: https://claude.ai/api/mcp/auth_callback?state=<state>&code=code-for-claude

and finally Claude gets a token:

POST /oauth_token HTTP/1.1

{  
  "client_id": "claude-ai-oauth-client", 
  "code": "code-for-claude", 
  "code_verifier": "PKCE Stuff",
  "grant_type": "authorization_code", 
  "redirect_uri": "https://claude.ai/api/mcp/auth_callback"
}

and we return the OAuth tokens:

HTTP/1.1 201 Created

{
  "access_token": "access-token-for-claude",
  "refresh_token": "refresh-token-for-claude",
  "expires_in": 6000
}

and then finally Claude tries again, with its POST to the integration path to initialize, but this time including its token!

POST /mcp
Authorization: Bearer access-token-for-claude

{
  "method": "initialize", 
  "params": {
    "protocolVersion": "2024-11-05", 
    "capabilities": {}, 
    "clientInfo": {
      "name": "claude-ai", 
      "version": "0.1.0"
    }
  }, 
  "jsonrpc": "2.0",
  "id": 0
}

It seems everyone is all about "tools" these days, so we'll probably want to respond with something about that. Just saying "tools": {} indicates a tools capability. The specific tools will come from a follow-up request. The capabilities we can respond with are defined here

HTTP 200 OK
Mcp-Session-Id: "some-session-id"

{ 
  "jsonrpc": "2.0", 
  "id": 0, 
  "result": {
    "protocolVersion": "2025-03-26", 
    "capabilities": { 
      "tools": {} 
    }, 
    "serverInfo": { 
      "name": "My Test Server", 
      "version": "1.0.0" 
    }
  }
}

Note: the id has to match the request, and the protocolVersion has to match (and note that Claude web app uses an older version of the protocol).

While the spec says you "MAY" establish a Mcp-Session-Id, the Claude app requires it. If you don't provide one, it won't go on to request tools and other capabilities.

Also, regardless of what capabilities you respond with here, it seems the Claude app always requests tools/list, prompts/list, and resources/list.

And with that Claude is happy and initialized and sends:

POST /mcp

{
  "jsonrpc": "2.0",
  "method": "notifications/initialized"
}

Next comes the request to get the tools.

POST /mcp

{
  "id": 1, 
  "jsonrpc": "2.0", 
  "method": "tools/list", 
  "params": {}
}
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "tools": [
      {
        "name": "Best Person Looker-upper",
        "description": "Helps the agent identify who the best person is.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "startsWith": {"type": "string"}
          },
          "required": ["startsWith"]
        }
      }
    ]
  }
}

The tool schema is here.

Note that Claude seems to save these tools to the given client (MCP host / address maybe?). If you change them later, when you re-connect, it won't refetch them.