diff --git a/demos/mcp-delegated-clerk-oauth/.gitignore b/demos/mcp-delegated-clerk-oauth/.gitignore new file mode 100644 index 00000000..ffb705c3 --- /dev/null +++ b/demos/mcp-delegated-clerk-oauth/.gitignore @@ -0,0 +1,174 @@ +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +\*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +\*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +\*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +\*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.cache +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +.cache/ + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp +.cache + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.\* + +# wrangler project + +.dev.vars +.wrangler/ + +worker-configuration.d.ts diff --git a/demos/mcp-delegated-clerk-oauth/README.md b/demos/mcp-delegated-clerk-oauth/README.md new file mode 100644 index 00000000..6a96312e --- /dev/null +++ b/demos/mcp-delegated-clerk-oauth/README.md @@ -0,0 +1,15 @@ +# Model Context Protocol (MCP) Server + Delegated Clerk OAuth + +WIP + +Instead of acting as an OAuth client to Clerk and hosting our own OAuth provider inside the MCP / MCP auth service, we delegate OAuth completely to Clerk and utilize it as the OAuth provider. + +Supports dynamic client registration on Clerk by only supporting the registration endpoint on our separate auth service hosted on the same worker. I say separate because I think it is confusing to say the MCP server itself manages the auth - I would say it is separate. + +To be clear - the MCP server is a resource that simply checks against an access token, the Auth service provides the access token. + +Code does not work without making changes to both the MCP TypeScript SDK and `mcp-remote` from Cloudflare. + +Required secrets: +* `CLERK_BACKEND_URL`: Clerk Backend URL +* `CLERK_SECRET_KEY`: Clerk Instance Secret Key \ No newline at end of file diff --git a/demos/mcp-delegated-clerk-oauth/biome.json b/demos/mcp-delegated-clerk-oauth/biome.json new file mode 100644 index 00000000..f9c94e1f --- /dev/null +++ b/demos/mcp-delegated-clerk-oauth/biome.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.6.2/schema.json", + "organizeImports": { + "enabled": true + }, + "files": { + "ignore": ["node_modules/**/*", "dist/**/*"], + "include": ["src/**/*.ts"] + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "off", + "noDebugger": "off", + "noConsoleLog": "off", + "noConfusingVoidType": "off" + }, + "style": { + "noNonNullAssertion": "off" + } + } + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "indentWidth": 4, + "lineWidth": 100 + } +} diff --git a/demos/mcp-delegated-clerk-oauth/package.json b/demos/mcp-delegated-clerk-oauth/package.json new file mode 100644 index 00000000..030b48b6 --- /dev/null +++ b/demos/mcp-delegated-clerk-oauth/package.json @@ -0,0 +1,25 @@ +{ + "name": "mcp-delegated-clerk-oauth", + "type": "module", + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev", + "start": "wrangler dev", + "cf-typegen": "wrangler types", + "format": "biome format --write", + "lint": "biome lint --error-on-warnings", + "lint:fix": "biome lint --fix" + }, + "devDependencies": { + "typescript": "^5.5.2", + "wrangler": "^4.2.0", + "@biomejs/biome": "^1.8.2" + }, + "dependencies": { + "@cloudflare/workers-oauth-provider": "^0.0.2", + "@modelcontextprotocol/sdk": "^1.7.0", + "hono": "^4.7.4", + "agents": "^0.0.46", + "zod": "^3.24.2" + } +} diff --git a/demos/mcp-delegated-clerk-oauth/src/auth-server.ts b/demos/mcp-delegated-clerk-oauth/src/auth-server.ts new file mode 100644 index 00000000..9ceda0da --- /dev/null +++ b/demos/mcp-delegated-clerk-oauth/src/auth-server.ts @@ -0,0 +1,246 @@ +import { Hono } from "hono"; +import { cors } from "hono/cors"; + +// Define the OAuthConfig interface +interface AuthServerConfig { + // Full endpoints, does not support paths for now. + authorizeEndpoint: string; + tokenEndpoint: string; + registrationEndpoint?: string; + + scopesSupported?: string[]; + + disallowPublicClientRegistration?: boolean; +} + +interface ClerkCreateClient { + object: string; + id: string; + instance_id: string; + name: string; + client_id: string; + public: boolean; + scopes: string; + redirect_uris: string[]; + callback_url: string; + authorize_url: string; + token_fetch_url: string; + user_info_url: string; + discovery_url: string; + token_introspection_url: string; + created_at: number; + updated_at: number; + client_secret?: string; +} + +// Basic type validation functions +const validateStringField = (field: any): string | undefined => { + if (field === undefined) { + return undefined; + } + if (typeof field !== "string") { + throw new Error("Field must be a string"); + } + return field; +}; + +const validateStringArray = (arr: any): string[] | undefined => { + if (arr === undefined) { + return undefined; + } + if (!Array.isArray(arr)) { + throw new Error("Field must be an array"); + } + + // Validate all elements are strings + for (const item of arr) { + if (typeof item !== "string") { + throw new Error("All array elements must be strings"); + } + } + + return arr; +}; + +// Create a function that returns a configured Hono app +// Create a Hono app +export function creatClerkAuthServer(authServerConfig: AuthServerConfig) { + const app = new Hono<{ Bindings: Env }>(); + + // Configure CORS + app.use( + "*", + cors({ + origin: "*", + allowHeaders: ["Content-Type", "Authorization"], + maxAge: 86400, + }), + ); + + // OAuth metadata discovery endpoint + app.get("/.well-known/oauth-authorization-server", (c) => { + return c.json({ + issuer: new URL(authServerConfig.tokenEndpoint).origin, + + authorization_endpoint: authServerConfig.authorizeEndpoint, + token_endpoint: authServerConfig.tokenEndpoint, + registration_endpoint: authServerConfig.registrationEndpoint, + // Reusues token endpoint for revocation for now. + revocation_endpoint: authServerConfig.tokenEndpoint, + + scopes_supported: authServerConfig.scopesSupported ?? [], + + response_types_supported: ["code"], + response_modes_supported: ["query"], + + grant_types_supported: ["authorization_code", "refresh_token"], + token_endpoint_auth_methods_supported: [ + "client_secret_basic", + "client_secret_post", + "none", + ], + + // PKCE Support + code_challenge_methods_supported: ["S256", "plain"], + }); + }); + + // Client registration endpoint + app.post("/oauth/register", async (c) => { + // Check content length to ensure it's not too large (1 MiB limit) + const contentLength = Number.parseInt(c.req.header("Content-Length") || "0", 10); + if (contentLength > 1048576) { + // 1 MiB = 1048576 bytes + return c.json( + { + error: "invalid_request", + error_description: "Request payload too large, must be under 1 MiB", + }, + 413, + ); + } + + // Parse client metadata with a size limitation + let clientMetadata: { + redirect_uris: string[]; + client_name?: string; + scopesSupported?: string[]; + token_endpoint_auth_method?: string; + }; + try { + const text = await c.req.text(); + if (text.length > 1048576) { + // Double-check text length + return c.json( + { + error: "invalid_request", + error_description: "Request payload too large, must be under 1 MiB", + }, + 413, + ); + } + clientMetadata = JSON.parse(text); + } catch (error) { + return c.json( + { + error: "invalid_request", + error_description: "Invalid JSON payload", + }, + 400, + ); + } + + // Get token endpoint auth method, default to client_secret_basic + const authMethod = + validateStringField(clientMetadata.token_endpoint_auth_method) || "client_secret_basic"; + const isPublicClient = authMethod === "none"; + + // Check if public client registrations are disallowed + if (isPublicClient && authServerConfig.disallowPublicClientRegistration) { + return c.json( + { + error: "invalid_client_metadata", + error_description: "Public client registration is not allowed", + }, + 400, + ); + } + let clientBody: { + redirect_uris: string[]; + name?: string; + scopes: string; + public: boolean; + }; + try { + // Validate redirect URIs - must exist and have at least one entry + const redirectUris = validateStringArray(clientMetadata.redirect_uris); + + if (!redirectUris || redirectUris.length === 0) { + throw new Error("At least one redirect URI is required"); + } + + clientBody = { + redirect_uris: redirectUris, + name: validateStringField(clientMetadata.client_name), + scopes: clientMetadata.scopesSupported?.join(" ") ?? "", + public: isPublicClient, + }; + } catch (error) { + return c.json( + { + error: "invalid_client_metadata", + error_description: + error instanceof Error ? error.message : "Invalid client metadata", + }, + 400, + ); + } + + // Create client on Clerk + const createClientResp = await fetch(`${c.env.CLERK_BACKEND_URL}/oauth_applications`, { + method: "POST", + body: JSON.stringify(clientBody), + headers: { + Authorization: `Bearer ${c.env.CLERK_SECRET_KEY}`, + "Content-Type": "application/json", + }, + }); + + if (!createClientResp.ok) { + return c.json( + { + error: "invalid_client_metadata", + }, + 400, + ); + } + + const createClientBody = (await createClientResp.json()) as ClerkCreateClient; + + // Return client information with the original unhashed secret + const response: Record = { + client_id: createClientBody.client_id, + redirect_uris: createClientBody.redirect_uris, + client_name: createClientBody.name, + logo_uri: "", + client_uri: "", + policy_uri: "", + tos_uri: "", + jwks_uri: "", + contacts: [], + grant_types: [], + response_types: [], + token_endpoint_auth_method: authMethod, + registration_client_uri: `${authServerConfig.registrationEndpoint}/${createClientBody.client_id}`, + client_id_issued_at: createClientBody.created_at, + }; + + if (!isPublicClient && !createClientBody.public && !!createClientBody.client_secret) { + response.client_secret = createClientBody.client_secret; + } + + return c.json(response, 201); + }); + + return app; +} diff --git a/demos/mcp-delegated-clerk-oauth/src/index.ts b/demos/mcp-delegated-clerk-oauth/src/index.ts new file mode 100644 index 00000000..eb42bbd6 --- /dev/null +++ b/demos/mcp-delegated-clerk-oauth/src/index.ts @@ -0,0 +1,161 @@ +import { creatClerkAuthServer } from "./auth-server"; +import { McpAgent } from "agents/mcp"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { Props } from "./types"; + +// @fixme Insert your Clerk Instance URL here +const CLERK_INSTANCE_URL = "CLERK_INSTANCE_URL"; + +export class MyMCP extends McpAgent { + server = new McpServer({ + name: "Clerk OAuth Proxy Demo", + version: "1.0.0", + }); + + async init() { + // Hello, world! + this.server.tool( + "add", + "Add two numbers the way only MCP can", + { a: z.number(), b: z.number() }, + async ({ a, b }) => ({ + content: [{ type: "text", text: String(a + b) }], + }) + ); + + // Gets the currently signed in user's info on your Clerk app. + this.server.tool("get_user", "Get the users info", {}, async () => { + const accessToken = this.props.accessToken; + + const user = await fetch(`${CLERK_INSTANCE_URL}/oauth/userinfo`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }).then((r) => r.json()); + + return { + content: [{ type: "text", text: JSON.stringify(user) }], + }; + }); + } +} + +const clerkAuthServer = creatClerkAuthServer({ + // Utilize Clerk directly for authorization and token endpoints + authorizeEndpoint: `${CLERK_INSTANCE_URL}/oauth/authorize`, + tokenEndpoint: `${CLERK_INSTANCE_URL}/oauth/token`, + + // Utilize our own auth registration endpoint hosted on the same worker in our auth service. + // @fixme Points to localhost, but can be changed to your remote instance. + registrationEndpoint: "http://localhost:8788/oauth/register", + + // Scopes supported for Clerk. + scopesSupported: ["profile", "email"], +}); + +const mcpServerFetch = MyMCP.mount("/sse"); + +// Middleware to check if the user is authenticated +// Checks the token itself against Clerk's introspection endpoint. +// @fixme Introspection endpoint is not working for Clerk. Default to using the user info endpoint. +clerkAuthServer.use("/sse/*", async (c, next) => { + const authHeader = c.req.header("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return c.json( + { + error: "Unauthorized", + }, + 401 + ); + } + + // Extract token + const accessToken = authHeader.slice(7); + + const userResponse = await fetch(`${CLERK_INSTANCE_URL}/oauth/userinfo`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!userResponse.ok) { + return c.json( + { + error: "Unauthorized", + }, + 401 + ); + } + + const user = (await userResponse.json()) as { + user_id: string; + }; + + if (!user.user_id) { + return c.json( + { + error: "Unauthorized", + }, + 401 + ); + } + + // @fixme Introspection endpoint is not working for Clerk. Default to using the user info endpoint. + // // Create form data for token introspection + // const formData = new URLSearchParams(); + // formData.append("token_type_hint", "access_token"); + + // If you need to check specific scopes + // formData.append('scope', 'profile email'); + + // Call token introspection endpoint + // const introspectionResponse = await fetch( + // `${CLERK_INSTANCE_URL}/oauth/token_info`, + // { + // method: "POST", + // headers: { + // "Content-Type": "application/x-www-form-urlencoded", + // Authorization: `Bearer ${accessToken}`, + // }, + // body: formData, + // } + // ); + + // if (!introspectionResponse.ok) { + // return c.json( + // { + // error: "Unauthorized", + // }, + // 401 + // ); + // } + + // const introspectionResult = (await introspectionResponse.json()) as { + // active: boolean; + // }; + + // // Token introspection returns an "active" boolean indicating if the token is valid + // if (!introspectionResult.active) { + // return c.json( + // { + // error: "Unauthorized", + // }, + // 401 + // ); + // } + + // @ts-ignore + c.executionCtx.props = { + accessToken, + }; + + return next(); +}); + +clerkAuthServer.all("/sse/*", async (c) => { + // @ts-ignore + return mcpServerFetch.fetch(c.req.raw, c.env, c.executionCtx); +}); + +export default clerkAuthServer; diff --git a/demos/mcp-delegated-clerk-oauth/src/types.ts b/demos/mcp-delegated-clerk-oauth/src/types.ts new file mode 100644 index 00000000..9ad087ad --- /dev/null +++ b/demos/mcp-delegated-clerk-oauth/src/types.ts @@ -0,0 +1,5 @@ +// Context from the auth process, encrypted & stored in the auth token +// and provided to the McpAgent as this.props +export type Props = { + accessToken: string; +}; diff --git a/demos/mcp-delegated-clerk-oauth/tsconfig.json b/demos/mcp-delegated-clerk-oauth/tsconfig.json new file mode 100644 index 00000000..ed0b1938 --- /dev/null +++ b/demos/mcp-delegated-clerk-oauth/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2021", + "lib": ["es2021"], + "jsx": "react-jsx", + "module": "es2022", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["worker-configuration.d.ts", "src/**/*.ts"] +} diff --git a/demos/mcp-delegated-clerk-oauth/wrangler.jsonc b/demos/mcp-delegated-clerk-oauth/wrangler.jsonc new file mode 100644 index 00000000..be593373 --- /dev/null +++ b/demos/mcp-delegated-clerk-oauth/wrangler.jsonc @@ -0,0 +1,37 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "mcp-clerk-oauth", + "main": "src/index.ts", + "compatibility_date": "2025-03-10", + "vars": { + "CLERK_BACKEND_URL": "https://api.clerk.com/v1" + }, + "migrations": [ + { + "new_sqlite_classes": [ + "MyMCP" + ], + "tag": "v1" + } + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyMCP", + "name": "MCP_OBJECT" + } + ] + }, + "ai": { + "binding": "AI" + }, + "observability": { + "enabled": true + }, + "dev": { + "port": 8788 + }, + "compatibility_flags": [ + "nodejs_als" + ] +} diff --git a/package-lock.json b/package-lock.json index e9d36c37..8bd05917 100644 --- a/package-lock.json +++ b/package-lock.json @@ -176,6 +176,83 @@ "wrangler": "^4.2.0" } }, + "demos/mcp-delegated-clerk-oauth": { + "dependencies": { + "@cloudflare/workers-oauth-provider": "^0.0.2", + "@modelcontextprotocol/sdk": "^1.7.0", + "agents": "^0.0.46", + "hono": "^4.7.4", + "zod": "^3.24.2" + }, + "devDependencies": { + "@biomejs/biome": "^1.8.2", + "typescript": "^5.5.2", + "wrangler": "^4.2.0" + } + }, + "demos/mcp-delegated-clerk-oauth/node_modules/agents": { + "version": "0.0.46", + "resolved": "https://registry.npmjs.org/agents/-/agents-0.0.46.tgz", + "integrity": "sha512-YC49h/KCP1Og7Pd0XVWD22f5Xg6MevUL0qtIJUjK3xP7Zb2F9lGzGqcvgkRmqalLEwZByZqDknvMb9nuDPnVXg==", + "license": "MIT", + "dependencies": { + "cron-schedule": "^5.0.4", + "nanoid": "^5.1.5", + "partyserver": "^0.0.66", + "partysocket": "1.1.2" + } + }, + "demos/mcp-delegated-clerk-oauth/node_modules/event-target-shim": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-6.0.2.tgz", + "integrity": "sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "demos/mcp-delegated-clerk-oauth/node_modules/nanoid": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz", + "integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "demos/mcp-delegated-clerk-oauth/node_modules/partyserver": { + "version": "0.0.66", + "resolved": "https://registry.npmjs.org/partyserver/-/partyserver-0.0.66.tgz", + "integrity": "sha512-GyC1uy4dvC4zPkwdzHqCkQ1J1CMiI0swIJQ0qqsJh16WNkEo5QHuU3l3ikLO8t+Yq0cRr0qO8++xbr11h+107w==", + "license": "ISC", + "dependencies": { + "nanoid": "^5.1.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20240729.0" + } + }, + "demos/mcp-delegated-clerk-oauth/node_modules/partysocket": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/partysocket/-/partysocket-1.1.2.tgz", + "integrity": "sha512-vWyxg0dYJewBsT6BaYkq+DEavYw+N/8J0jUXYSuZRRJdnmMJY+rkCg19t/8c3pTSyg8FjJH8VzIaHBhFMQRDIw==", + "license": "ISC", + "dependencies": { + "event-target-shim": "^6.0.2" + } + }, "demos/mcp-server-bearer-auth": { "name": "remote-mcp-server-bearer-auth", "version": "0.0.0", @@ -10009,6 +10086,10 @@ "node": ">= 0.4" } }, + "node_modules/mcp-delegated-clerk-oauth": { + "resolved": "demos/mcp-delegated-clerk-oauth", + "link": true + }, "node_modules/mcp-github-oauth": { "resolved": "demos/remote-mcp-github-oauth", "link": true