Production-ready MCP server 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.

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.

If you are interested, we have a longer blog post here, that covers everything here and includes what’s going on under the hood. For this post, however, we’ll focus on getting things up and running quickly.

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 for security purposes.

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 SettingsConnectorsAdd 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 (which Claude Desktop calls a Connector), 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:

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.

The first thing we need is an authorization server that supports the MCP spec.

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:

While testing, we recommend only enabling it in the Test and/or Staging environments.

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.

Next, you’ll want to choose which AI clients you allow your users to use. We provide configurations for popular clients like Cursor, Claude Desktop, ChatGPT, Gemini, etc or you can add your own custom clients. This helps to protect against open redirect attacks by ensuring your users can only be redirected to approved locations:

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. To do this, we need to create an endpoint called the Protected Resource Metadata endpoint which contains some basic metadata:

// 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>;

And finally, we need to make it so that when the user presents missing or invalid credentials, we return a 401 error that tells the client where our Protected Resource Metadata endpoint is:

function unauthorizedResponse(url: URL) {
    const protectedResourceMetadataUrl = `https://${url.hostname}/.well-known/oauth-protected-resource`;
    return new Response('Unauthorized', {
        status: 401,
        headers: {
            'WWW-Authenticate': `Bearer resource_metadata="${protectedResourceMetadataUrl}"`,
        },
    });
}

The full error case here is then:

If we go back to Claude Desktop and re-add our MCP server, a browser will now open prompting us to log in (this step is skipped if you were already logged in).

After we log in, we’ll be prompted to consent to the scopes we included in our PRM endpoint.

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 call to our tool, including its 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. For example:

In general, for any product that has APIs, an MCP server is a convenient way to allow those APIs to be called by an AI agent/client.