Deep dive into OAuth 2.1 and MCP using Cloudflare Workers | PropelAuth

Announcing MCP Authentication: secure your MCP servers with PropelAuth

Model Context Protocol (MCP) servers are a convenient way to allow AI applications (Claude, Gemini, ChatGPT, etc.) to connect to your product. We have a deep dive on MCP that will cover what it is and how it works.

In this blog post, we’re going to set up our own MCP server, add authentication to it, and deploy it as a Cloudflare Worker.

Along the way, we’re also going to spend some extra time really understanding how all these different pieces work. We have another version of this blog post here which will just cover how to do the integration without the deep dives.

Project Setup

We’re going to start with a (mostly) empty Cloudflare Worker.

pnpm create cloudflare@latest

We’ll choose the Hello World example in TypeScript.

After you complete the CLI, you should see the following in src/index.ts

export default {
    async fetch(request, env, ctx): Promise<Response> {
        return new Response('Hello World!');
    },
} satisfies ExportedHandler<Env>;

We’ll ultimately want to turn this Cloudflare Worker into a production-ready MCP server, which means implementing the relevant parts of the spec.

Luckily for us, we can pull in a few dependencies that’ll do most of the heavy lifting:

Let’s install those and we’re ready to build our MCP server.

pnpm i zod agents @modelcontextprotocol/sdk

Creating a basic MCP server in our Cloudflare Worker

Now that we have our dependencies set up, we’ll make a simple MCP server for testing. An MCP server has “tools,” which you can think of very similarly to API endpoints.

A tool has a:

For our example, we’ll make a simple calculator tool, which can add, subtract, multiply or divide two provided numbers:

import { createMcpHandler } from 'agents/mcp';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';

function createServer() {
    const server = new McpServer({ name: 'Calculator', version: '1.0.0' });

server.registerTool(
        'calculator',
        {
            description: 'Perform basic math operations',
            inputSchema: {
                operation: z.enum(['add', 'subtract', 'multiply', 'divide']),
                a: z.number(),
                b: z.number(),
            },
        },
        async ({ operation, a, b }) => {
            let result: number;

if (operation === 'add') result = a + b;
            else if (operation === 'subtract') result = a - b;
            else if (operation === 'multiply') result = a * b;
            else if (b === 0) return { content: [{ type: 'text', text: 'Error: Division by zero' }] };
            else result = a / b;

return { content: [{ type: 'text', text: String(result) }] };
        },
    );

return server;
}

We then need to hook this MCP server up to our Worker’s fetch call so we can test it out. Note that we must create a new McpServer for each request.

export default {
    async fetch(request, env, ctx): Promise<Response> {
        const server = createServer();
        const handler = createMcpHandler(server);
        return handler(request, env, ctx);
    },
} satisfies ExportedHandler<Env>;

And that’s actually it! We have a fully functional MCP server that allows clients like Claude, Gemini, and ChatGPT to make function calls to us. Let’s test it out.

Testing our MCP Server with Claude Desktop

While there are tools like the MCP Inspector that allow us to test and debug MCP servers, we’re going to jump straight to seeing what this will look like for real users and connect Claude Desktop to our server.

To set up an MCP server in Claude Desktop, go to Settings ⇒ Connectors ⇒ Add custom connector.

Enter Calculator for the Name and your MCP URL will be the location where your MCP Server is running with the path /mcp.

You might run into one small problem here…

…not every AI client will work with locally running servers. We have two options to fix this:

Whichever you choose, you’ll now be able to add your MCP server, and we are ready to use it!

Open up a new chat and ask Claude to use our calculator. You can expand the tool call to see the request that it’s going to make.

Once you approve it, you should see both the MCP server’s response as well as Claude’s commentary on it:

Success! The full workflow that just happened was:

  1. I asked Claude to add 5 and 8
  2. Claude recognized that it has a calculator tool available to it
  3. Claude called that calculator tool with the right arguments
  4. Our MCP server handled the request and responded with 13
  5. Claude took that response and presented it back to me

What’s happening under the hood?

MCP uses JSON-RPC for communication between the client and server. You can inspect the requests that the client makes to your server to get a better sense for how it works.

One example request you’ll see is tools/list which the client uses to figure out what tools are available to it:

{
    "method": "tools/list",
    "jsonrpc": "2.0"
}

When our server responds to this request, you’ll notice that it returns most of what we specified in the tool (name, description, schema).

{
   "result": {
      "tools": [{
            "name": "calculator",
            "description": "Perform basic math operations",
            "inputSchema": {
               "$schema": "<http://json-schema.org/draft-07/schema#>",
               "type": "object",
               "properties": {
                  "operation": {
                     "type": "string",
                     "enum": ["add", "subtract", "multiply", "divide"]
                  },
                  "a": { "type":"number" },
                  "b": { "type":"number" }
               },
               "required": ["operation", "a", "b"]
            }
      }]
   }
}

You can also see the tools/call request which is how the client calls the tools on the server:

{
    "method": "tools/call",
    "params": {
        "name": "calculator",
        "arguments": {
            "a": 5,
            "b": 8,
            "operation": "add"
        }
    },
    "jsonrpc": "2.0"
}

Adding authentication and authorization to our MCP server

At this point, anyone can connect to our MCP server and use our calculator. For some MCP servers, this is totally reasonable, like:

But, for some MCP servers, you’ll need to know both who the user is and if they have permission to make a specific tool call. In this section, we’ll see how we can add both of those to our calculator.

Understanding how MCP clients deal with 401 errors

To start, let’s just see what happens if we return a 401 error, with no other information:

async fetch(request, env, ctx): Promise<Response> {
    return new Response('Unauthorized', { status: 401 });
}

When we try to connect Claude Desktop to this MCP server, we see that it starts making a few requests to some other endpoints:

[wrangler:info] POST /mcp 401 Unauthorized (3ms)
[wrangler:info] GET /.well-known/oauth-protected-resource/mcp 401 Unauthorized (3ms)
[wrangler:info] GET /.well-known/oauth-protected-resource 401 Unauthorized (9ms)
[wrangler:info] GET /.well-known/oauth-authorization-server 401 Unauthorized (4ms)
[wrangler:info] POST /register 401 Unauthorized (5ms)

So, what’s going on here? Well, put yourself in Claude’s shoes for a second. There’s really only one endpoint (/mcp) and unfortunately that endpoint just returned a 401. Now what do you do? You need to somehow obtain valid credentials and retry the request.

Fortunately, the MCP spec covers this, and what we need is an authorization server that implements OAuth 2.1.

Using PropelAuth for our MCP Authorization Server

PropelAuth has built in support for MCP authentication/authorization that you can enable in a few clicks. To start, we’ll want to enable MCP support, which we can do in the dashboard:

We also need to enable our users to create OAuth clients. We can either let them do this manually (meaning they’ll go to a hosted UI PropelAuth provides and generate a Client ID and Secret) or we can enable dynamic client registration (meaning Claude itself will generate the Client ID and Secret). We’ll enable both but will use DCR for simplicity.

Finally, we’ll want to create our scopes. You can think of a scope as a permission to perform certain actions. For example purposes, we’ll take the overly verbose route of making a scope for each calculator operation (op:add, op:sub, op:mult, and op:div)

Pointing our MCP server at our Authorization Server

Now that we have an authorization server, we just need to tell our MCP server where to find it.

We mentioned previously that the OAuth 2.0 Protected Resource Metadata spec covers how a client can determine where the authorization server is. Now that we have an authorization server, we can set up this endpoint:

// We'll use Env variables to store the auth URL

type Env = {
    PROPELAUTH_AUTH_URL: string;
};

function protectedResourceMetadataResponse(env: Env, url: URL) {
    return Response.json({
        resource: `https://${url.hostname}/mcp`,
        authorization_servers: [`${env.PROPELAUTH_AUTH_URL}/oauth/2.1`],
        scopes_supported: ['op:add', 'op:sub', 'op:mult', 'op:div'],
    });
}

export default {
    async fetch(request, env, ctx): Promise<Response> {
        const url = new URL(request.url);

if (url.pathname === '/.well-known/oauth-protected-resource') {
            return protectedResourceMetadataResponse(env, url);
        }

return unauthorizedResponse(url)
    },
} satisfies ExportedHandler<Env>;

Now, we are finished! We can verify that our MCP server still works by connecting Claude to it and asking it add or subtract some numbers.

Putting it all together

Now that we have all the pieces in place, we can see the full authenticated workflow:

  1. A user tries to connect Claude Desktop to our Authenticated Calculator MCP server
  2. Claude makes requests to /mcp to understand what tools are available
  3. Claude gets back a 401 error, along with the location of the Protected Resource Metadata (PRM) endpoint
  4. Claude calls that PRM endpoint which will tell Claude where our MCP server’s authorization server is
  5. Claude will reach out to the authorization server to both understand it and register itself
  6. Claude will open a browser and ask our user to log in to their account with the Authenticated Calculator service
  7. The user is asked to consent to the scopes the MCP server requests
  8. The user is redirected back to Claude, where it now has valid credentials to call our /mcp endpoint
  9. The user asks Claude a question like Hey can you add 5 and 8?
  10. Claude recognizes that it can use our calculator tool for this
  11. Claude makes a JSON-RPC request to call our tool, including it’s credentials
  12. Our MCP server validates the credentials AND that we consented to the appropriate scopes for the tool call being made
  13. Our MCP server returns a response
  14. Claude presents the user with the response

And yes, this is obviously the most roundabout way to add two numbers together. However, there are a number of real world use cases that you can use instead.