Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ This is a pnpm monorepo with the following packages:

- `packages/oauth` (`@keycardai/oauth`) - Pure OAuth 2.0 primitives (no MCP dependency)
- `packages/mcp` (`@keycardai/mcp`) - MCP-specific OAuth integration
- `packages/cloudflare` (`@keycardai/cloudflare`) - Keycard auth for Cloudflare Workers (depends on oauth, no Express)
- `packages/sdk` (`@keycardai/sdk`) - Aggregate package re-exporting from oauth + mcp

## Git Commits
Expand All @@ -14,9 +15,9 @@ Follow conventional commits: `type(scope): description`

Types: `docs`, `feat`, `fix`, `refactor`, `test`, `chore`

Scopes: `oauth`, `mcp`, `sdk`, `deps`, `docs`
Scopes: `oauth`, `mcp`, `cloudflare`, `sdk`, `deps`, `docs`

## Build Order

`@keycardai/oauth` must build before `@keycardai/mcp` (dependency).
`@keycardai/oauth` must build before `@keycardai/mcp` and `@keycardai/cloudflare` (dependency).
Use `pnpm -r run build` to build in dependency order.
129 changes: 129 additions & 0 deletions examples/cloudflare-worker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Cloudflare Worker MCP Server

A Cloudflare Worker protected by Keycard OAuth authentication with delegated access to external APIs via token exchange. Demonstrates `@keycardai/cloudflare` — the Workers-native equivalent of `@keycardai/mcp`.

## What This Example Shows

- Using `createKeycardWorker()` for automatic OAuth metadata + bearer auth
- Registering MCP tools on a Worker
- Isolate-safe token exchange with `IsolateSafeTokenCache`
- Both credential modes: `ClientSecret` and `WebIdentity` (private key JWT)

## Prerequisites

- **Node.js 18+** and **wrangler CLI** (`npm install -g wrangler`)
- **Cloudflare account** — sign up at [dash.cloudflare.com](https://dash.cloudflare.com)
- **Keycard account** — sign up at [console.keycard.ai](https://console.keycard.ai)
- **Configured zone** with an identity provider (Okta, Auth0, Google, etc.)
- **Cursor IDE** or another MCP-compatible client for testing

## Keycard Console Setup

### 1. Register GitHub as a Provider

1. Create a GitHub OAuth App at [github.com/settings/developers](https://github.com/settings/developers)
2. In Keycard Console, navigate to **Providers** → **Add Provider**
3. Configure:
- **Provider Name:** `GitHub OAuth`
- **Identifier:** `https://github.com`
- **Client ID / Secret:** from your GitHub OAuth App

### 2. Create a GitHub API Resource

1. Navigate to **Resources** → **Create Resource**
2. Configure:
- **Resource Name:** `GitHub API`
- **Resource Identifier:** `https://api.github.com`
- **Credential Provider:** Select `GitHub OAuth`
- **Scopes:** `user:email`, `read:user`

### 3. Register This Worker as a Resource

1. Navigate to **Resources** → **Create Resource**
2. Configure:
- **Resource Name:** `Cloudflare Worker MCP Server`
- **Resource Identifier:** `https://your-worker.your-subdomain.workers.dev`
- **Credential Provider:** `Keycard STS`
- **Scopes:** `mcp:tools`
3. Go to the resource details → **Dependencies** tab
4. Click **Connect Resource** and select `GitHub API`

### 4. Create Application Credentials

1. Navigate to **Applications** → **Create Application**
2. Note the **Client ID** and **Client Secret**

## Install and Deploy

```bash
cd examples/cloudflare-worker
npm install
```

Edit `wrangler.jsonc` and set `KEYCARD_ISSUER` to your zone URL, `KEYCARD_RESOURCE_URL` to `https://api.github.com`.

### Option A: Client Credentials

```bash
wrangler secret put KEYCARD_CLIENT_ID
wrangler secret put KEYCARD_CLIENT_SECRET
```

### Option B: Web Identity (no client secret)

Generate a private key and store it as a Worker secret:

```bash
openssl genrsa 2048 | wrangler secret put KEYCARD_PRIVATE_KEY
```

The Worker automatically serves its public key at `/.well-known/jwks.json`. Register this URL in Keycard Console as the application's JWKS endpoint.

### Deploy

```bash
wrangler deploy
```

## Test with Cursor IDE

Add to your Cursor MCP settings (`~/.cursor/mcp_settings.json`):

```json
{
"mcpServers": {
"cloudflare-worker": {
"url": "https://your-worker.your-subdomain.workers.dev/mcp"
}
}
}
```

Restart Cursor, connect the MCP server, complete the OAuth flow, then try:
- "Run the whoami tool"
- "Get my GitHub user info"

## Local Development

```bash
wrangler dev
```

Then test against `http://localhost:8787`.

## Environment Variables

| Variable | Description | Set via |
|---|---|---|
| `KEYCARD_ISSUER` | Keycard zone URL | `wrangler.jsonc` vars |
| `KEYCARD_RESOURCE_URL` | Upstream API URL for token exchange | `wrangler.jsonc` vars |
| `KEYCARD_CLIENT_ID` | Application client ID (Option A) | `wrangler secret put` |
| `KEYCARD_CLIENT_SECRET` | Application client secret (Option A) | `wrangler secret put` |
| `KEYCARD_PRIVATE_KEY` | PEM-encoded RSA private key (Option B) | `wrangler secret put` |

## Related

- [Hello World example](../hello-world-server/) — Express-based equivalent
- [Delegated Access example](../delegated-access/) — Express-based token exchange
- [`@keycardai/cloudflare` docs](https://docs.keycard.ai/sdk/cloudflare/) — full API reference
- [`@keycardai/mcp` docs](https://docs.keycard.ai/sdk/mcp/) — Express-based auth (same primitives, different runtime)
22 changes: 22 additions & 0 deletions examples/cloudflare-worker/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "cloudflare-worker",
"version": "0.1.0",
"private": true,
"description": "Keycard-protected MCP server on Cloudflare Workers with bearer auth and token exchange",
"type": "module",
"scripts": {
"build": "tsc",
"dev": "wrangler dev",
"deploy": "wrangler deploy"
},
"dependencies": {
"@keycardai/cloudflare": "^0.1.0",
"@keycardai/oauth": "^0.2.0",
"@modelcontextprotocol/sdk": "^1.15.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250327.0",
"typescript": "^5.8.3",
"wrangler": "^4.14.0"
}
}
111 changes: 111 additions & 0 deletions examples/cloudflare-worker/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* Cloudflare Worker MCP Server with Keycard Auth
*
* A minimal Worker protected by Keycard bearer auth with delegated
* access to an external API via token exchange.
*
* Prerequisites:
* 1. A Keycard zone with an identity provider configured
* 2. A resource registered in Keycard Console for this Worker
* 3. Application credentials (client ID + secret) or a private key
*
* See README.md for full setup instructions.
*/

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import {
createKeycardWorker,
IsolateSafeTokenCache,
resolveCredential,
} from "@keycardai/cloudflare";
import { TokenExchangeClient } from "@keycardai/oauth/tokenExchange";

interface Env {
KEYCARD_ISSUER: string;
KEYCARD_CLIENT_ID?: string;
KEYCARD_CLIENT_SECRET?: string;
KEYCARD_PRIVATE_KEY?: string;
KEYCARD_RESOURCE_URL: string;
}

// Token cache is module-level but keyed by user identity — safe for isolate reuse
let tokenCache: IsolateSafeTokenCache | undefined;

function getTokenCache(env: Env): IsolateSafeTokenCache {
if (!tokenCache) {
const credential = resolveCredential(env);
const client = new TokenExchangeClient(env.KEYCARD_ISSUER, credential.getAuth() ?? undefined);
tokenCache = new IsolateSafeTokenCache(client, { credential });
}
return tokenCache;
}

export default createKeycardWorker<Env>({
resourceName: "Cloudflare Worker Example",
scopesSupported: ["mcp:tools"],
requiredScopes: ["mcp:tools"],

async fetch(request, env, ctx, auth) {
const url = new URL(request.url);

// Health check
if (url.pathname === "/health") {
return new Response(JSON.stringify({ status: "ok" }), {
headers: { "Content-Type": "application/json" },
});
}

// MCP server on /mcp
if (url.pathname === "/mcp") {
const server = new McpServer({ name: "Cloudflare Worker Example", version: "0.1.0" });

// Simple tool: return authenticated user info
server.tool("whoami", "Returns information about the authenticated user", {}, async () => {
return {
content: [{
type: "text",
text: JSON.stringify({
subject: auth.subject,
clientId: auth.clientId,
scopes: auth.scopes,
}, null, 2),
}],
};
});

// Delegated access tool: fetch from upstream API using token exchange
server.tool("github_user", "Fetches the authenticated user's GitHub profile", {}, async () => {
const cache = getTokenCache(env);
const token = await cache.getToken(auth.subject!, auth.token, env.KEYCARD_RESOURCE_URL);

const response = await fetch("https://api.github.com/user", {
headers: {
Authorization: `Bearer ${token.accessToken}`,
Accept: "application/vnd.github+json",
"User-Agent": "keycard-worker-example",
},
});

if (!response.ok) {
const body = await response.text();
return { content: [{ type: "text", text: `GitHub API error (${response.status}): ${body}` }] };
}

const user = await response.json() as Record<string, unknown>;
return {
content: [{
type: "text",
text: JSON.stringify({ login: user.login, name: user.name, email: user.email }, null, 2),
}],
};
});

const transport = new StreamableHTTPServerTransport("/mcp");
await server.connect(transport);
return transport.handleRequest(request);
}

return new Response("Not found", { status: 404 });
},
});
15 changes: 15 additions & 0 deletions examples/cloudflare-worker/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "ES2022",
"moduleResolution": "bundler",
"declaration": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"types": ["@cloudflare/workers-types"]
},
"include": ["src"]
}
21 changes: 21 additions & 0 deletions examples/cloudflare-worker/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "keycard-mcp-worker",
"main": "src/index.ts",
"compatibility_date": "2025-04-01",
"compatibility_flags": ["nodejs_compat"],

// Non-secret configuration — baked into the Worker
"vars": {
"KEYCARD_ISSUER": "https://your-zone-id.keycard.cloud",
"KEYCARD_RESOURCE_URL": "https://api.github.com"
}

// Secrets — set via `wrangler secret put`:
//
// Option A: Client credentials
// wrangler secret put KEYCARD_CLIENT_ID
// wrangler secret put KEYCARD_CLIENT_SECRET
//
// Option B: Web identity (private key JWT — no client secret needed)
// wrangler secret put KEYCARD_PRIVATE_KEY
}
Loading
Loading