Skip to content

Chapter 2: Protect the MCP server

Right now anyone who can reach localhost:8000 can call every tool, because the server has no way to identify the caller. You’re going to put a Keycard bearer-token check in front of /mcp. After this, a request without a valid token gets a 401: Unauthorized error, and the only way to get a valid token is to authenticate with Keycard.

Verifying a JSON Web Token (JWT) properly means fetching the issuer’s signing keys, checking the signature, the issuer, the audience, the expiry, and getting everything right. The @keycardai/mcp SDK does all of this, so this chapter teaches you to implement it correctly and understand what it’s doing.

You’ll implement two pieces of middleware from the Keycard MCP SDK:

  • mcpAuthMetadataRouter advertises how to authenticate. It serves a small metadata document at a well-known URL that tells any agent “here’s the issuer to get a token from.” It simply provides information; it does not enforce.
  • requireBearerAuth is the enforcement layer. This middleware runs before your tool handler and rejects the request unless it carries a valid token from Keycard, minted for this resource.

The metadata is what makes enforcement usable: when the server rejects an anonymous request, its 401 points back at the metadata, so an agent can discover where to log in and try again.

From inside support-escalation/ (the same directory you’ve been working in since Chapter 0):

Command line
npm install @keycardai/mcp@0.10.0

Open .env and add two values from Chapter 1:

.env
# The Keycard URL that issues and verifies tokens from Keycard > Settings.
KEYCARD_URL=<your issuer URL>
# This server's resource URI, exactly as you registered it in Keycard.
MCP_RESOURCE_URL=http://localhost:8000/mcp

KEYCARD_URL is the issuer your MCP server trusts (no trailing slash). MCP_RESOURCE_URL is the audience your MCP server requires. The next section shows why each is important.

Open src/server.ts. We need to read those two new env vars, mount the metadata router, and add requireBearerAuth middleware to the /mcp route.

Prompt your coding agent

In src/server.ts, add Keycard bearer-token auth. Import mcpAuthMetadataRouter from “@keycardai/mcp/server/auth/router” and requireBearerAuth from “@keycardai/mcp/server/auth/middleware/bearerAuth”. Read KEYCARD_URL and MCP_RESOURCE_URL from the environment, failing clearly if either is missing. Mount mcpAuthMetadataRouter with the issuer set to KEYCARD_URL and scopesSupported of read and issues:create. Add requireBearerAuth as middleware on the POST /mcp route, with issuers set to KEYCARD_URL and audiences set to MCP_RESOURCE_URL, before the existing handler. Don’t change the handler itself.

This is what your coding agent should build. First, it will add the imports and a small helper to read required env vars (you’ll notice linear.ts has the same little helper; each file keeps its own copy so it reads standalone, and so this chapter’s change stays entirely inside server.ts):

import { requireBearerAuth } from "@keycardai/mcp/server/auth/middleware/bearerAuth";
import { mcpAuthMetadataRouter } from "@keycardai/mcp/server/auth/router";
/** Read a required env var, failing with a useful message instead of a cryptic error later. */
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing ${name} — copy .env.example to .env and fill in the workshop values.`);
}
return value;
}
const KEYCARD_URL = requireEnv("KEYCARD_URL");
const MCP_RESOURCE_URL = requireEnv("MCP_RESOURCE_URL");

Then it should mount the metadata router so the server advertises how to authenticate:

app.use(
mcpAuthMetadataRouter({
oauthMetadata: { issuer: KEYCARD_URL },
scopesSupported: ["read", "issues:create"],
resourceName: "Support Escalation MCP",
}),
);

The read and issues:create scopes are the ones you gave to the Linear API resource in Keycard in Chapter 1. You advertise them in metadata because when an agent logs in, Keycard passes the requested scopes through your app’s dependency chain to each resource. You advertise what your tools will actually need downstream, including the Linear scopes you configured. An agent then reads this metadata and is supposed to only ask for the specified scopes.

Next, the agent should add bearer auth middleware in front of /mcp:

app.post(
"/mcp",
requireBearerAuth({ issuers: KEYCARD_URL, audiences: MCP_RESOURCE_URL }),
async (req, res) => {
// ...the existing handler is unchanged...
},
);

When done, the agent will prompt you to run the following install in in support-escalation/ again to download the new Keycard SDK dependency:

Command line
npm install @keycardai/mcp@0.10.0

Do so before testing the server.

Inspect the requireBearerAuth() middleware in support-escalation/src/server.ts. This verifies two important things:

  • issuers rejects any token not signed by Keycard. Without it, the middleware would accept any signed JWT from anywhere. (The SDK throws if you don’t provide a verifier or issuers, precisely so you can’t ship that mistake.)
  • audiences binds the token to this resource. Keycard mints tokens for more than one audience: you saw the prepopulated Events API and OpenID Connect UserInfo resources in Chapter 1, and a busier configuration might hold several MCP servers. A token minted for a different resource is signed, valid, and passes the issuer check, even if it should not call your tools. The audience check confirms the token’s aud claim matches http://localhost:8000/mcp so a token meant for one resource can’t be replayed against another.

The key takeaway is to only trust your Keycard, and only accept tokens actually meant for your MCP server.

Restart the server if it isn’t already running (npm run dev), then use curl to make the same kind of anonymous call your agent made in Chapter 0:

Command line
curl -i -X POST http://localhost:8000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

You get a 401, and among the response headers (alongside the usual Date, Content-Type, and friends) is the one that shows the fruits of our labor:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="http://localhost:8000/.well-known/oauth-protected-resource/mcp"

The WWW-Authenticate header is the metadata doing its job. It’s not just a rejection; it tells the caller exactly where to look to find out how to authenticate. Open that metadata URL in your browser and you’ll see your issuer listed. That’s the breadcrumb an agent follows to start an OIDC login, which is what we’ll do in Chapter 3.

If you still have your agent connected from Chapter 0, try a tool call now:

Prompt your coding agent

Use support-escalation to list open tickets.

It will either fail, or the agent will helpfully inform you that you need an OAuth token to access the MCP server. The agent has no token, and the server now requires one. The endpoint that accepted anyone now accepts no one, until they can prove who they are.

A valid token would get a 200 (you’ll see that for yourself next chapter, when your agent logs in and reconnects).