# Directory Structure ``` ├── .editorconfig ├── .gitignore ├── .prettierrc ├── .vscode │ └── settings.json ├── package-lock.json ├── package.json ├── README.md ├── seq-diagram.png ├── src │ └── index.ts ├── test │ ├── index.spec.ts │ └── tsconfig.json ├── tsconfig.json ├── vitest.config.mts ├── worker-configuration.d.ts └── wrangler.jsonc ``` # Files -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- ``` 1 | { 2 | "printWidth": 140, 3 | "singleQuote": true, 4 | "semi": true, 5 | "useTabs": true 6 | } 7 | ``` -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- ``` 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yml] 12 | indent_style = space 13 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Logs 2 | 3 | logs 4 | _.log 5 | npm-debug.log_ 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | 13 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 14 | 15 | # Runtime data 16 | 17 | pids 18 | _.pid 19 | _.seed 20 | \*.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | 28 | coverage 29 | \*.lcov 30 | 31 | # nyc test coverage 32 | 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | 41 | bower_components 42 | 43 | # node-waf configuration 44 | 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | 49 | build/Release 50 | 51 | # Dependency directories 52 | 53 | node_modules/ 54 | jspm_packages/ 55 | 56 | # Snowpack dependency directory (https://snowpack.dev/) 57 | 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | 62 | \*.tsbuildinfo 63 | 64 | # Optional npm cache directory 65 | 66 | .npm 67 | 68 | # Optional eslint cache 69 | 70 | .eslintcache 71 | 72 | # Optional stylelint cache 73 | 74 | .stylelintcache 75 | 76 | # Microbundle cache 77 | 78 | .rpt2_cache/ 79 | .rts2_cache_cjs/ 80 | .rts2_cache_es/ 81 | .rts2_cache_umd/ 82 | 83 | # Optional REPL history 84 | 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | 89 | \*.tgz 90 | 91 | # Yarn Integrity file 92 | 93 | .yarn-integrity 94 | 95 | # dotenv environment variable files 96 | 97 | .env 98 | .env.development.local 99 | .env.test.local 100 | .env.production.local 101 | .env.local 102 | 103 | # parcel-bundler cache (https://parceljs.org/) 104 | 105 | .cache 106 | .parcel-cache 107 | 108 | # Next.js build output 109 | 110 | .next 111 | out 112 | 113 | # Nuxt.js build / generate output 114 | 115 | .nuxt 116 | dist 117 | 118 | # Gatsby files 119 | 120 | .cache/ 121 | 122 | # Comment in the public line in if your project uses Gatsby and not Next.js 123 | 124 | # https://nextjs.org/blog/next-9-1#public-directory-support 125 | 126 | # public 127 | 128 | # vuepress build output 129 | 130 | .vuepress/dist 131 | 132 | # vuepress v2.x temp and cache directory 133 | 134 | .temp 135 | .cache 136 | 137 | # Docusaurus cache and generated files 138 | 139 | .docusaurus 140 | 141 | # Serverless directories 142 | 143 | .serverless/ 144 | 145 | # FuseBox cache 146 | 147 | .fusebox/ 148 | 149 | # DynamoDB Local files 150 | 151 | .dynamodb/ 152 | 153 | # TernJS port file 154 | 155 | .tern-port 156 | 157 | # Stores VSCode versions used for testing VSCode extensions 158 | 159 | .vscode-test 160 | 161 | # yarn v2 162 | 163 | .yarn/cache 164 | .yarn/unplugged 165 | .yarn/build-state.yml 166 | .yarn/install-state.gz 167 | .pnp.\* 168 | 169 | # wrangler project 170 | 171 | .dev.vars 172 | .wrangler/ 173 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # workers-mcp-clerk 2 | 3 | Talk to a Cloudflare Worker from Claude Desktop proxying Clerk protected API Routes or server actions. 4 | 5 | ## How it works 6 | 7 | For every application that uses Clerk for authentication, you can create a Cloudflare Worker that acts as an MPC Server and impersonates a Clerk user. 8 | 9 |  10 | 11 | ## How to use 12 | 13 | 1. Deploy and install the Cloudflare MCP server to Claude Desktop using the instructions [here](https://github.com/cloudflare/workers-mcp). 14 | 2. Open Claude Desktop and type "Say hello to [email protected]" 15 | 3. For demo purposes, the Cloudflare Worker will impersonate the Clerk user, return with a greeting and a user JWT. In a real scenario, the JWT will be used to request Clerk protected API Route or server actions. 16 | ``` -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "files.associations": { 3 | "wrangler.json": "jsonc" 4 | } 5 | } ``` -------------------------------------------------------------------------------- /worker-configuration.d.ts: -------------------------------------------------------------------------------- ```typescript 1 | // Generated by Wrangler 2 | // After adding bindings to `wrangler.jsonc`, regenerate this interface via `npm run cf-typegen` 3 | interface Env { 4 | } 5 | ``` -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["@cloudflare/workers-types/experimental", "@cloudflare/vitest-pool-workers"] 5 | }, 6 | "include": ["./**/*.ts", "../worker-configuration.d.ts"], 7 | "exclude": [] 8 | } 9 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "my-mcp-worker", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "deploy": "workers-mcp docgen src/index.ts && wrangler deploy", 7 | "dev": "workers-mcp docgen src/index.ts && wrangler dev", 8 | "start": "wrangler dev", 9 | "test": "vitest", 10 | "cf-typegen": "wrangler types" 11 | }, 12 | "devDependencies": { 13 | "@cloudflare/vitest-pool-workers": "^0.6.4", 14 | "@cloudflare/workers-types": "^4.20250214.0", 15 | "typescript": "^5.5.2", 16 | "vitest": "~2.1.9", 17 | "wrangler": "^3.109.1" 18 | }, 19 | "dependencies": { 20 | "@clerk/backend": "^1.24.2", 21 | "workers-mcp": "^0.0.13" 22 | } 23 | } 24 | ``` -------------------------------------------------------------------------------- /test/index.spec.ts: -------------------------------------------------------------------------------- ```typescript 1 | // test/index.spec.ts 2 | import { env, createExecutionContext, waitOnExecutionContext, SELF } from 'cloudflare:test'; 3 | import { describe, it, expect } from 'vitest'; 4 | import worker from '../src/index'; 5 | 6 | // For now, you'll need to do something like this to get a correctly-typed 7 | // `Request` to pass to `worker.fetch()`. 8 | const IncomingRequest = Request<unknown, IncomingRequestCfProperties>; 9 | 10 | describe('Hello World worker', () => { 11 | it('responds with Hello World! (unit style)', async () => { 12 | const request = new IncomingRequest('http://example.com'); 13 | // Create an empty context to pass to `worker.fetch()`. 14 | const ctx = createExecutionContext(); 15 | const response = await worker.fetch(request, env, ctx); 16 | // Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions 17 | await waitOnExecutionContext(ctx); 18 | expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`); 19 | }); 20 | 21 | it('responds with Hello World! (integration style)', async () => { 22 | const response = await SELF.fetch('https://example.com'); 23 | expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`); 24 | }); 25 | }); 26 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 6 | "target": "es2021", 7 | /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 8 | "lib": ["es2021"], 9 | /* Specify what JSX code is generated. */ 10 | "jsx": "react-jsx", 11 | 12 | /* Specify what module code is generated. */ 13 | "module": "es2022", 14 | /* Specify how TypeScript looks up a file from a given module specifier. */ 15 | "moduleResolution": "Bundler", 16 | /* Specify type package names to be included without being referenced in a source file. */ 17 | "types": [ 18 | "@cloudflare/workers-types/2023-07-01" 19 | ], 20 | /* Enable importing .json files */ 21 | "resolveJsonModule": true, 22 | 23 | /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 24 | "allowJs": true, 25 | /* Enable error reporting in type-checked JavaScript files. */ 26 | "checkJs": false, 27 | 28 | /* Disable emitting files from a compilation. */ 29 | "noEmit": true, 30 | 31 | /* Ensure that each file can be safely transpiled without relying on other imports. */ 32 | "isolatedModules": true, 33 | /* Allow 'import x from y' when a module doesn't have a default export. */ 34 | "allowSyntheticDefaultImports": true, 35 | /* Ensure that casing is correct in imports. */ 36 | "forceConsistentCasingInFileNames": true, 37 | 38 | /* Enable all strict type-checking options. */ 39 | "strict": true, 40 | 41 | /* Skip type checking all .d.ts files. */ 42 | "skipLibCheck": true 43 | }, 44 | "exclude": ["test"], 45 | "include": ["worker-configuration.d.ts", "src/**/*.ts"] 46 | } 47 | ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WorkerEntrypoint } from 'cloudflare:workers'; 2 | import { ProxyToSelf } from 'workers-mcp'; 3 | import { createClerkClient, type User } from "@clerk/backend"; 4 | 5 | const BASIC_EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 6 | 7 | interface Env { 8 | CLERK_SECRET_KEY: string; 9 | } 10 | 11 | export default class MyWorker extends WorkerEntrypoint<Env> { 12 | /** 13 | * A warm, friendly greeting from your new MCP server. 14 | * @param emailOrUserId {string} The email or userId of the person to greet. 15 | * @return {string} The greeting message. 16 | */ 17 | async sayHello(email: string) { 18 | const jwt = await this.impersonate(email); 19 | 20 | // Use this JWT to make API calls to a Clerk protected API route. 21 | // For Demo purposes, we'll just log the JWT in the greeting message. 22 | return `Hello from an MCP Worker, ${email}! Your JWT is ${jwt}`; 23 | } 24 | 25 | /** 26 | * @ignore 27 | */ 28 | async fetch(request: Request): Promise<Response> { 29 | // ProxyToSelf handles MCP protocol compliance. 30 | return new ProxyToSelf(this).fetch(request); 31 | } 32 | 33 | /** 34 | * @ignore 35 | */ 36 | private async impersonate(emailOrUserId: string) { 37 | const user = await this.getUser(emailOrUserId); 38 | 39 | if (!user) { 40 | throw new Error(`User ${emailOrUserId} not found`); 41 | } 42 | 43 | // TODO: Open a PR to add this to @clerk/backend 44 | const actorTokenResponse = await fetch('https://api.clerk.com/v1/actor_tokens', { 45 | method: 'POST', 46 | headers: { 47 | Authorization: `Bearer ${this.getClerkSecretKey()}`, 48 | 'Content-Type': 'application/json', 49 | }, 50 | body: JSON.stringify({ 51 | user_id: user.id, 52 | expires_in_seconds: 600, 53 | actor: { 54 | // TODO: Make this configurable 55 | sub: 'My Cloudflare MCP Server', 56 | }, 57 | }), 58 | }); 59 | 60 | if (!actorTokenResponse.ok) { 61 | throw new Error(`Failed to create actor token: ${actorTokenResponse.statusText}`); 62 | } 63 | 64 | const { token: ticket, url } = (await actorTokenResponse.json()) as { token: string; url: string }; 65 | 66 | const clerkFrontendAPI = new URL(url).origin; 67 | 68 | // TODO: Open a PR to add this to @clerk/backend 69 | const signInResponse = await fetch(`${clerkFrontendAPI}/v1/client/sign_ins?__clerk_api_version=2024-10-01`, { 70 | method: 'POST', 71 | headers: { 72 | 'content-type': 'application/x-www-form-urlencoded', 73 | }, 74 | body: new URLSearchParams({ 75 | strategy: 'ticket', 76 | ticket, 77 | }).toString(), 78 | }); 79 | 80 | if (!signInResponse.ok) { 81 | throw new Error(`Failed to sign in: ${signInResponse.statusText}`); 82 | } 83 | 84 | const { client } = (await signInResponse.json()) as { 85 | client: { last_active_session_id: string; sessions: { id: string; last_active_token: { jwt: string } }[] }; 86 | }; 87 | 88 | const jwt = client.sessions.find((s) => s.id === client.last_active_session_id)?.last_active_token.jwt; 89 | 90 | if (!jwt) { 91 | throw new Error('Failed to get JWT'); 92 | } 93 | 94 | return jwt; 95 | } 96 | 97 | /** 98 | * @ignore 99 | */ 100 | private async getUser(emailOrUserId: string): Promise<User> { 101 | console.log("Getting user: ", emailOrUserId, this.getClerkSecretKey()); 102 | const clerk = createClerkClient({ 103 | secretKey: this.getClerkSecretKey() 104 | }); 105 | 106 | try { 107 | if ((emailOrUserId || '').startsWith('user_')) { 108 | return await clerk.users.getUser(emailOrUserId); 109 | } 110 | 111 | if (BASIC_EMAIL_REGEX.test(emailOrUserId || '')) { 112 | const users = await clerk.users.getUserList({ 113 | emailAddress: [emailOrUserId], 114 | orderBy: '-last_sign_in_at', 115 | }); 116 | 117 | return users.data[0]; 118 | } 119 | } catch (error) { 120 | console.error("Error getting user: ", error); 121 | } 122 | 123 | throw new Error(`Invalid user ID or email: ${emailOrUserId}. Please provider a user ID or email address.`); 124 | } 125 | 126 | /** 127 | * @ignore 128 | */ 129 | private getClerkSecretKey(): string { 130 | if (!this.env.CLERK_SECRET_KEY) { 131 | throw new Error('CLERK_SECRET_KEY is not set'); 132 | } 133 | return this.env.CLERK_SECRET_KEY; 134 | } 135 | 136 | } ```