├── .vscode └── settings.json ├── .prettierrc ├── package.json ├── wrangler.jsonc ├── tsconfig.json ├── README.md ├── .gitignore └── src ├── utils.ts ├── github-handler.ts ├── index.ts └── workers-oauth-utils.ts /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "wrangler.json": "jsonc" 4 | } 5 | } -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "singleQuote": false, 4 | "semi": true, 5 | "useTabs": false, 6 | "overrides": [ 7 | { 8 | "files": ["*.jsonc"], 9 | "options": { 10 | "trailingComma": "none" 11 | } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remote-mcp-github-oauth", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "deploy": "wrangler deploy", 7 | "dev": "wrangler dev", 8 | "start": "wrangler dev", 9 | "cf-typegen": "wrangler types", 10 | "type-check": "tsc --noEmit" 11 | }, 12 | "devDependencies": { 13 | "@cloudflare/workers-oauth-provider": "^0.0.5", 14 | "@modelcontextprotocol/sdk": "^1.11.4", 15 | "@types/node": "^22.15.19", 16 | "agents": "^0.0.88", 17 | "hono": "^4.7.10", 18 | "just-pick": "^4.2.0", 19 | "octokit": "^5.0.0", 20 | "prettier": "^3.5.3", 21 | "typescript": "^5.8.3", 22 | "workers-mcp": "^0.0.13", 23 | "wrangler": "^4.16.1", 24 | "zod": "^3.25.7" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /wrangler.jsonc: -------------------------------------------------------------------------------- 1 | /** 2 | * For more details on how to configure Wrangler, refer to: 3 | * https://developers.cloudflare.com/workers/wrangler/configuration/ 4 | */ 5 | { 6 | "$schema": "node_modules/wrangler/config-schema.json", 7 | "name": "bestreads", 8 | "main": "src/index.ts", 9 | "compatibility_date": "2025-03-10", 10 | "compatibility_flags": [ 11 | "nodejs_compat" 12 | ], 13 | "vars": { 14 | "GITHUB_CLIENT_ID": "", 15 | "GITHUB_CLIENT_SECRET": "", 16 | "COOKIE_ENCRYPTION_KEY": "" 17 | }, 18 | "migrations": [ 19 | { 20 | "new_sqlite_classes": [ 21 | "MyMCP", 22 | "UserBookPreferences" 23 | ], 24 | "tag": "v1" 25 | } 26 | ], 27 | "durable_objects": { 28 | "bindings": [ 29 | { 30 | "class_name": "MyMCP", 31 | "name": "MCP_OBJECT" 32 | }, 33 | { 34 | "class_name": "UserBookPreferences", 35 | "name": "USER_BOOK_PREFERENCES" 36 | } 37 | ] 38 | }, 39 | "kv_namespaces": [ 40 | { 41 | "binding": "OAUTH_KV", 42 | "id": "" 43 | } 44 | ], 45 | "ai": { 46 | "binding": "AI" 47 | }, 48 | "observability": { 49 | "enabled": true 50 | }, 51 | "dev": { 52 | "port": 8788 53 | } 54 | } -------------------------------------------------------------------------------- /tsconfig.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 | "./worker-configuration.d.ts", 19 | "node" 20 | ], 21 | /* Enable importing .json files */ 22 | "resolveJsonModule": true, 23 | 24 | /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 25 | "allowJs": true, 26 | /* Enable error reporting in type-checked JavaScript files. */ 27 | "checkJs": false, 28 | 29 | /* Disable emitting files from a compilation. */ 30 | "noEmit": true, 31 | 32 | /* Ensure that each file can be safely transpiled without relying on other imports. */ 33 | "isolatedModules": true, 34 | /* Allow 'import x from y' when a module doesn't have a default export. */ 35 | "allowSyntheticDefaultImports": true, 36 | /* Ensure that casing is correct in imports. */ 37 | "forceConsistentCasingInFileNames": true, 38 | 39 | /* Enable all strict type-checking options. */ 40 | "strict": true, 41 | 42 | /* Skip type checking all .d.ts files. */ 43 | "skipLibCheck": true 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BestReads MCP Server 📚🤔 2 | 3 | This is a remote MCP server, built on [Cloudflare Workers](https://workers.cloudflare.com/), that provides personalized book recommendation. 4 | 5 | This was built using Cloudflare's [guide](https://developers.cloudflare.com/agents/guides/remote-mcp-server/) on deploying remote MCP servers. It uses the [Agents SDK](https://developers.cloudflare.com/agents/) to build the MCP server, [Durable Objects](https://developers.cloudflare.com/durable-objects/) to persist the user's book preferences, [Workers AI](https://developers.cloudflare.com/workers-ai/) to generate book recommendations, and Cloudflare's [OAuth Provider library](https://github.com/cloudflare/workers-oauth-provider) to add GitHub as an authentication provider. The MCP server supports Server-Sent Events (/sse) and Streamable HTTP (/mcp) [transport methods](https://developers.cloudflare.com/agents/model-context-protocol/transport/). 6 | 7 | ### Get Started 8 | To try it out, connect to `https://bestreads.dinas.workers.dev/sse` or `https://bestreads.dinas.workers.dev/mcp`, if your MCP client supports Streamable HTTP. Or, deploy it yourself using the Deploy to Cloudflare button + instructions below. 9 | 10 | [![Deploy to Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/dinasaur404/BestReads-MCP-Server) 11 | 12 | ## Available Tools 13 | 14 | - getProfile - View your reading history and preferences 15 | - addGenre - Add favorite book genres 16 | - addFavoriteAuthor - Add authors you enjoy 17 | - addBookRead - Track books you've read 18 | - addDislikedBook - Mark books you didn't enjoy 19 | - addDislikedAuthor - Authors to avoid in recommendations 20 | - clearPreferences - Reset all preferences 21 | - getBookRecommendations - Get AI-powered personalized book suggestions 22 | 23 | ## Deploy the MCP server 24 | 25 | ### Setup 26 | 27 | 1. Clone the repository 28 | ```bash 29 | git clone 30 | cd bestreads-mcp-server 31 | npm install 32 | ``` 33 | 34 | 2. Create a [GitHub OAuth App](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) 35 | 36 | - Once you create teh OAuth App, set the Authorization callback URL to https://your-worker-domain.workers.dev/callback 37 | - Note the ClientID and Client Secret. You will add those to your Wrangler file. 38 | - (Optional) Generate Cookie Encryption Key 39 | 40 | 3. Upgrade your `wrangler.toml` file 41 | ``` 42 | [vars] 43 | GITHUB_CLIENT_ID = "your_github_client_id" 44 | GITHUB_CLIENT_SECRET = "your_github_client_secret" 45 | COOKIE_ENCRYPTION_KEY = "your_32_byte_hex_key" 46 | 47 | [[kv_namespaces]] 48 | binding = "OAUTH_KV" 49 | id = "your_kv_namespace_id" 50 | 51 | [[durable_objects.bindings]] 52 | name = "MCP_OBJECT" 53 | class_name = "MyMCP" 54 | 55 | [[durable_objects.bindings]] 56 | name = "USER_BOOK_PREFERENCES" 57 | class_name = "UserBookPreferences" 58 | ``` 59 | 60 | 4. Deploy to Cloudflare Workers 61 | `wrangler deploy` 62 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Constructs an authorization URL for an upstream service. 3 | * 4 | * @param {Object} options 5 | * @param {string} options.upstream_url - The base URL of the upstream service. 6 | * @param {string} options.client_id - The client ID of the application. 7 | * @param {string} options.redirect_uri - The redirect URI of the application. 8 | * @param {string} [options.state] - The state parameter. 9 | * 10 | * @returns {string} The authorization URL. 11 | */ 12 | export function getUpstreamAuthorizeUrl({ 13 | upstream_url, 14 | client_id, 15 | scope, 16 | redirect_uri, 17 | state, 18 | }: { 19 | upstream_url: string; 20 | client_id: string; 21 | scope: string; 22 | redirect_uri: string; 23 | state?: string; 24 | }) { 25 | const upstream = new URL(upstream_url); 26 | upstream.searchParams.set("client_id", client_id); 27 | upstream.searchParams.set("redirect_uri", redirect_uri); 28 | upstream.searchParams.set("scope", scope); 29 | if (state) upstream.searchParams.set("state", state); 30 | upstream.searchParams.set("response_type", "code"); 31 | return upstream.href; 32 | } 33 | 34 | /** 35 | * Fetches an authorization token from an upstream service. 36 | * 37 | * @param {Object} options 38 | * @param {string} options.client_id - The client ID of the application. 39 | * @param {string} options.client_secret - The client secret of the application. 40 | * @param {string} options.code - The authorization code. 41 | * @param {string} options.redirect_uri - The redirect URI of the application. 42 | * @param {string} options.upstream_url - The token endpoint URL of the upstream service. 43 | * 44 | * @returns {Promise<[string, null] | [null, Response]>} A promise that resolves to an array containing the access token or an error response. 45 | */ 46 | export async function fetchUpstreamAuthToken({ 47 | client_id, 48 | client_secret, 49 | code, 50 | redirect_uri, 51 | upstream_url, 52 | }: { 53 | code: string | undefined; 54 | upstream_url: string; 55 | client_secret: string; 56 | redirect_uri: string; 57 | client_id: string; 58 | }): Promise<[string, null] | [null, Response]> { 59 | if (!code) { 60 | return [null, new Response("Missing code", { status: 400 })]; 61 | } 62 | 63 | const resp = await fetch(upstream_url, { 64 | method: "POST", 65 | headers: { 66 | "Content-Type": "application/x-www-form-urlencoded", 67 | }, 68 | body: new URLSearchParams({ client_id, client_secret, code, redirect_uri }).toString(), 69 | }); 70 | if (!resp.ok) { 71 | console.log(await resp.text()); 72 | return [null, new Response("Failed to fetch access token", { status: 500 })]; 73 | } 74 | const body = await resp.formData(); 75 | const accessToken = body.get("access_token") as string; 76 | if (!accessToken) { 77 | return [null, new Response("Missing access token", { status: 400 })]; 78 | } 79 | return [accessToken, null]; 80 | } 81 | 82 | // Context from the auth process, encrypted & stored in the auth token 83 | // and provided to the DurableMCP as this.props 84 | export type Props = { 85 | login: string; 86 | name: string; 87 | email: string; 88 | accessToken: string; 89 | }; 90 | -------------------------------------------------------------------------------- /src/github-handler.ts: -------------------------------------------------------------------------------- 1 | import type { AuthRequest, OAuthHelpers } from "@cloudflare/workers-oauth-provider"; 2 | import { Hono } from "hono"; 3 | import { cors } from "hono/cors"; 4 | import { Octokit } from "octokit"; 5 | import { clientIdAlreadyApproved, parseRedirectApproval, renderApprovalDialog } from "./workers-oauth-utils"; 6 | 7 | interface Env { 8 | GITHUB_CLIENT_ID: string; 9 | GITHUB_CLIENT_SECRET: string; 10 | COOKIE_ENCRYPTION_KEY: string; 11 | OAUTH_KV: KVNamespace; 12 | OAUTH_PROVIDER: OAuthHelpers; 13 | AI: any; 14 | } 15 | 16 | // Context from the auth flow, encrypted & stored in the auth token 17 | // Provided to the DurableMCP as this.props 18 | export type Props = { 19 | login: string; 20 | name: string; 21 | email: string; 22 | accessToken: string; 23 | githubId: string; 24 | }; 25 | 26 | const app = new Hono<{ Bindings: Env }>(); 27 | 28 | // CORS 29 | app.use('*', cors({ 30 | origin: '*', 31 | allowMethods: ['GET', 'POST', 'OPTIONS'], 32 | allowHeaders: ['Content-Type', 'Authorization'], 33 | maxAge: 86400 34 | })); 35 | 36 | app.options('*', (c) => c.text('', 204)); 37 | 38 | // Authorization endpoint - show approval dialog or proceed with OAuth 39 | app.get("/authorize", async (c) => { 40 | try { 41 | console.log('Authorization request received:', c.req.url); 42 | 43 | const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw); 44 | console.log('Parsed OAuth request:', { 45 | clientId: oauthReqInfo.clientId, 46 | scope: oauthReqInfo.scope, 47 | redirectUri: oauthReqInfo.redirectUri 48 | }); 49 | 50 | const { clientId } = oauthReqInfo; 51 | if (!clientId) { 52 | console.error('No clientId in OAuth request'); 53 | return c.text("Invalid request: missing client_id", 400); 54 | } 55 | 56 | // Check if this client was already approved 57 | const alreadyApproved = await clientIdAlreadyApproved( 58 | c.req.raw, 59 | oauthReqInfo.clientId, 60 | c.env.COOKIE_ENCRYPTION_KEY 61 | ); 62 | console.log('Client already approved:', alreadyApproved); 63 | 64 | if (alreadyApproved) { 65 | console.log('Client pre-approved, redirecting to GitHub'); 66 | return redirectToGithub(c.req.raw, oauthReqInfo, c.env); 67 | } 68 | 69 | // Show approval dialog 70 | const clientInfo = await c.env.OAUTH_PROVIDER.lookupClient(clientId); 71 | console.log('Client info:', clientInfo); 72 | 73 | return renderApprovalDialog(c.req.raw, { 74 | client: clientInfo, 75 | server: { 76 | name: "BestReads MCP Server", 77 | logo: "https://avatars.githubusercontent.com/u/314135?s=200&v=4", 78 | description: "The best book recommendations, for you!", 79 | }, 80 | state: { oauthReqInfo }, 81 | }); 82 | 83 | } catch (error) { 84 | console.error('Authorization error:', error); 85 | return c.text(`Authorization error: ${error instanceof Error ? error.message : String(error)}`, 500); 86 | } 87 | }); 88 | 89 | // Handle approval form submission 90 | app.post("/authorize", async (c) => { 91 | try { 92 | console.log('Approval form submitted'); 93 | 94 | const { state, headers } = await parseRedirectApproval(c.req.raw, c.env.COOKIE_ENCRYPTION_KEY); 95 | if (!state.oauthReqInfo) { 96 | console.error('Invalid state in approval form'); 97 | return c.text("Invalid request: missing state", 400); 98 | } 99 | 100 | console.log('Approval parsed, redirecting to GitHub'); 101 | return redirectToGithub(c.req.raw, state.oauthReqInfo, c.env, headers); 102 | 103 | } catch (error) { 104 | console.error('Approval error:', error); 105 | return c.text(`Approval error: ${error instanceof Error ? error.message : String(error)}`, 500); 106 | } 107 | }); 108 | 109 | // OAuth callback from GitHub 110 | app.get("/callback", async (c) => { 111 | try { 112 | console.log('OAuth callback received:', c.req.url); 113 | 114 | const stateParam = c.req.query("state"); 115 | const code = c.req.query("code"); 116 | const error = c.req.query("error"); 117 | 118 | if (error) { 119 | console.error('OAuth error from GitHub:', error); 120 | return c.text(`OAuth error: ${error}`, 400); 121 | } 122 | 123 | if (!stateParam) { 124 | console.error('Missing state parameter in callback'); 125 | return c.text("Missing state parameter", 400); 126 | } 127 | 128 | if (!code) { 129 | console.error('Missing code parameter in callback'); 130 | return c.text("Missing authorization code", 400); 131 | } 132 | 133 | let oauthReqInfo: AuthRequest; 134 | try { 135 | oauthReqInfo = JSON.parse(atob(stateParam)) as AuthRequest; 136 | } catch (e) { 137 | console.error('Error parsing state parameter:', e); 138 | return c.text("Invalid state parameter", 400); 139 | } 140 | 141 | if (!oauthReqInfo.clientId) { 142 | console.error('Invalid state: missing clientId'); 143 | return c.text("Invalid state", 400); 144 | } 145 | 146 | console.log('Exchanging code for access token'); 147 | 148 | // Exchange the code for an access token 149 | const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { 150 | method: 'POST', 151 | headers: { 152 | 'Content-Type': 'application/x-www-form-urlencoded', 153 | 'Accept': 'application/json', 154 | }, 155 | body: new URLSearchParams({ 156 | client_id: c.env.GITHUB_CLIENT_ID, 157 | client_secret: c.env.GITHUB_CLIENT_SECRET, 158 | code: code, 159 | redirect_uri: new URL("/callback", c.req.url).href, 160 | }), 161 | }); 162 | 163 | if (!tokenResponse.ok) { 164 | const errorText = await tokenResponse.text(); 165 | console.error('Failed to fetch access token:', tokenResponse.status, errorText); 166 | return c.text(`Failed to fetch access token: ${tokenResponse.status}`, 500); 167 | } 168 | 169 | const tokenData = await tokenResponse.json(); 170 | const accessToken = tokenData.access_token; 171 | 172 | if (!accessToken) { 173 | console.error('No access token in response'); 174 | return c.text("Missing access token in response", 400); 175 | } 176 | 177 | console.log('Access token obtained, fetching user info'); 178 | 179 | // Fetch the user info from GitHub 180 | const octokit = new Octokit({ auth: accessToken }); 181 | const user = await octokit.rest.users.getAuthenticated(); 182 | const { login, name, email, id: githubId } = user.data; 183 | 184 | console.log('User authenticated:', { login, name, githubId }); 185 | 186 | // Complete the OAuth authorization using the OAuth provider 187 | const normalizedLogin = login.toLowerCase().trim(); 188 | const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({ 189 | request: oauthReqInfo, 190 | userId: normalizedLogin, 191 | metadata: { 192 | label: name || login, 193 | }, 194 | scope: oauthReqInfo.scope || ['read:user', 'user:email'], 195 | // This will be available as this.props inside MyMCP 196 | props: { 197 | login: normalizedLogin, 198 | name: name || login, 199 | email: email || '', 200 | accessToken, 201 | githubId: String(githubId), 202 | } as Props, 203 | }); 204 | 205 | console.log('OAuth flow completed, redirecting to:', redirectTo); 206 | return Response.redirect(redirectTo); 207 | 208 | } catch (error) { 209 | console.error("Callback error:", error); 210 | return c.text(`Callback error: ${error instanceof Error ? error.message : String(error)}`, 500); 211 | } 212 | }); 213 | 214 | // Health check endpoint 215 | app.get("/health", (c) => { 216 | return c.json({ 217 | status: "ok", 218 | timestamp: new Date().toISOString(), 219 | service: "BestReads GitHub OAuth Handler" 220 | }); 221 | }); 222 | 223 | // Debug endpoint 224 | app.get("/debug", async (c) => { 225 | const url = new URL(c.req.url); 226 | 227 | return c.json({ 228 | url: c.req.url, 229 | pathname: url.pathname, 230 | method: c.req.method, 231 | headers: Object.fromEntries(c.req.raw.headers.entries()), 232 | timestamp: new Date().toISOString(), 233 | env: { 234 | hasGitHubClientId: !!c.env.GITHUB_CLIENT_ID, 235 | hasGitHubClientSecret: !!c.env.GITHUB_CLIENT_SECRET, 236 | hasCookieKey: !!c.env.COOKIE_ENCRYPTION_KEY, 237 | hasOAuthProvider: !!c.env.OAUTH_PROVIDER, 238 | }, 239 | }); 240 | }); 241 | 242 | // Token info endpoint for debugging 243 | app.get("/token-info", async (c) => { 244 | try { 245 | const authHeader = c.req.header('Authorization'); 246 | if (!authHeader?.startsWith('Bearer ')) { 247 | return c.text('Unauthorized', 401); 248 | } 249 | 250 | const token = authHeader.slice(7); 251 | 252 | // Validate token with OAuth provider 253 | const tokenInfo = await c.env.OAUTH_PROVIDER?.validateAccessToken?.(token); 254 | if (!tokenInfo) { 255 | return c.text('Invalid token', 401); 256 | } 257 | 258 | return c.json({ 259 | token: token, 260 | user: tokenInfo.props, 261 | instructions: { 262 | mcp_url: `${new URL(c.req.url).origin}/sse`, 263 | usage: "Use this token in your MCP client's Authorization header as 'Bearer '" 264 | } 265 | }); 266 | 267 | } catch (error) { 268 | console.error('Token info error:', error); 269 | return c.text('Error retrieving token info', 500); 270 | } 271 | }); 272 | 273 | // SSE test endpoint for debugging 274 | app.get("/sse-test", (c) => { 275 | return new Response("data: Hello from SSE test\n\n", { 276 | headers: { 277 | 'Content-Type': 'text/event-stream', 278 | 'Cache-Control': 'no-cache', 279 | 'Connection': 'keep-alive', 280 | 'Access-Control-Allow-Origin': '*', 281 | 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 282 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization, Cache-Control, Last-Event-ID', 283 | } 284 | }); 285 | }); 286 | 287 | // Test endpoint to check if MCP endpoints are accessible 288 | app.get("/test-mcp", async (c) => { 289 | try { 290 | const mcpResponse = await fetch(new URL("/mcp", c.req.url), { 291 | headers: { 292 | 'Authorization': c.req.header('Authorization') || '', 293 | } 294 | }); 295 | 296 | return c.json({ 297 | status: "MCP endpoint test", 298 | mcpStatus: mcpResponse.status, 299 | mcpHeaders: Object.fromEntries(mcpResponse.headers.entries()), 300 | timestamp: new Date().toISOString() 301 | }); 302 | } catch (error) { 303 | return c.json({ 304 | status: "MCP endpoint test failed", 305 | error: error instanceof Error ? error.message : String(error), 306 | timestamp: new Date().toISOString() 307 | }); 308 | } 309 | }); 310 | 311 | export { app as GitHubHandler }; 312 | 313 | 314 | //Redirect to GitHub OAuth authorization 315 | async function redirectToGithub( 316 | request: Request, 317 | oauthReqInfo: AuthRequest, 318 | env: Env, 319 | headers: Record = {} 320 | ) { 321 | const githubAuthUrl = getUpstreamAuthorizeUrl({ 322 | upstream_url: "https://github.com/login/oauth/authorize", 323 | scope: "read:user user:email", 324 | client_id: env.GITHUB_CLIENT_ID, 325 | redirect_uri: new URL("/callback", request.url).href, 326 | state: btoa(JSON.stringify(oauthReqInfo)), 327 | }); 328 | 329 | console.log('Redirecting to GitHub:', githubAuthUrl); 330 | 331 | return new Response(null, { 332 | status: 302, 333 | headers: { 334 | ...headers, 335 | location: githubAuthUrl, 336 | }, 337 | }); 338 | } 339 | 340 | //Constructs an authorization URL 341 | export function getUpstreamAuthorizeUrl({ 342 | upstream_url, 343 | client_id, 344 | scope, 345 | redirect_uri, 346 | state, 347 | }: { 348 | upstream_url: string; 349 | client_id: string; 350 | scope: string; 351 | redirect_uri: string; 352 | state?: string; 353 | }) { 354 | const upstream = new URL(upstream_url); 355 | upstream.searchParams.set("client_id", client_id); 356 | upstream.searchParams.set("redirect_uri", redirect_uri); 357 | upstream.searchParams.set("scope", scope); 358 | if (state) upstream.searchParams.set("state", state); 359 | upstream.searchParams.set("response_type", "code"); 360 | return upstream.href; 361 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import OAuthProvider from "@cloudflare/workers-oauth-provider"; 2 | import { McpAgent } from "agents/mcp"; 3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 4 | import { z } from "zod"; 5 | import { GitHubHandler } from "./github-handler"; 6 | import { DurableObject } from "cloudflare:workers"; 7 | 8 | interface Env { 9 | GITHUB_CLIENT_ID: string; 10 | GITHUB_CLIENT_SECRET: string; 11 | COOKIE_ENCRYPTION_KEY: string; 12 | OAUTH_KV: KVNamespace; 13 | MCP_OBJECT: DurableObjectNamespace; 14 | USER_BOOK_PREFERENCES: DurableObjectNamespace; 15 | AI: any; 16 | } 17 | 18 | // User authentication context that will be passed to MCP agent 19 | export type Props = { 20 | login: string; 21 | name: string; 22 | email: string; 23 | accessToken: string; 24 | githubId: string; 25 | }; 26 | 27 | // Book preferences state stored per user 28 | interface BookPreferences { 29 | userName: string; 30 | favoriteGenres: string[]; 31 | favoriteAuthors: string[]; 32 | booksRead: Array<{ 33 | title: string; 34 | author: string; 35 | dateAdded: string; 36 | }>; 37 | dislikedBooks: Array<{ 38 | title: string; 39 | author: string; 40 | dateAdded: string; 41 | }>; 42 | dislikedAuthors: string[]; 43 | } 44 | 45 | // Durable Object class for storing user book preferences 46 | export class UserBookPreferences extends DurableObject { 47 | private preferences: BookPreferences | null = null; 48 | 49 | constructor(state: DurableObjectState, env: Env) { 50 | super(state, env); 51 | } 52 | 53 | async getPreferences(): Promise { 54 | if (!this.preferences) { 55 | this.preferences = await this.ctx.storage.get("preferences"); 56 | 57 | if (!this.preferences) { 58 | this.preferences = { 59 | userName: "", 60 | favoriteGenres: [], 61 | favoriteAuthors: [], 62 | booksRead: [], 63 | dislikedBooks: [], 64 | dislikedAuthors: [], 65 | }; 66 | await this.ctx.storage.put("preferences", this.preferences); 67 | } 68 | } 69 | return this.preferences; 70 | } 71 | 72 | async updatePreferences(newPreferences: BookPreferences): Promise { 73 | this.preferences = newPreferences; 74 | await this.ctx.storage.put("preferences", this.preferences); 75 | } 76 | } 77 | 78 | export class MyMCP extends McpAgent { 79 | private _server: McpServer | undefined; 80 | 81 | set server(server: McpServer) { 82 | this._server = server; 83 | } 84 | 85 | get server(): McpServer { 86 | if (!this._server) { 87 | throw new Error('Tried to access server before it was initialized'); 88 | } 89 | return this._server; 90 | } 91 | 92 | constructor(state: DurableObjectState, env: Env) { 93 | super(state, env); 94 | console.log(`MyMCP initialized: 95 | - Durable Object ID: ${state.id.toString()} 96 | - DO ID name: ${state.id.name || 'no name'}`); 97 | } 98 | 99 | // Get the user's book preferences DO 100 | get userPreferences(): DurableObjectStub { 101 | const userId = this.props?.login || 'anonymous'; 102 | const userPreferencesId = this.env.USER_BOOK_PREFERENCES.idFromName(userId); 103 | return this.env.USER_BOOK_PREFERENCES.get(userPreferencesId); 104 | } 105 | 106 | private async getUserPreferences(): Promise { 107 | try { 108 | return await this.userPreferences.getPreferences(); 109 | } catch (error) { 110 | console.error("Error getting user preferences:", error); 111 | return { 112 | userName: "", 113 | favoriteGenres: [], 114 | favoriteAuthors: [], 115 | booksRead: [], 116 | dislikedBooks: [], 117 | dislikedAuthors: [], 118 | }; 119 | } 120 | } 121 | 122 | private async updateUserPreferences(preferences: BookPreferences): Promise { 123 | await this.userPreferences.updatePreferences(preferences); 124 | } 125 | 126 | async init() { 127 | console.log(`MyMCP init called - Props available: 128 | - login: ${this.props?.login} 129 | - name: ${this.props?.name} 130 | - githubId: ${this.props?.githubId}`); 131 | 132 | // Initialize MCP server 133 | this.server = new McpServer({ 134 | name: "BestReads Book Recommendations", 135 | version: "1.0.0", 136 | }); 137 | 138 | // Initialize username from authentication context 139 | const userName = this.props?.name || this.props?.login || "Book Lover"; 140 | 141 | const currentPreferences = await this.getUserPreferences(); 142 | if (currentPreferences.userName !== userName) { 143 | currentPreferences.userName = userName; 144 | await this.updateUserPreferences(currentPreferences); 145 | } 146 | 147 | console.log(`Book Preferences agent initialized for ${userName}`); 148 | 149 | // Register MCP tools 150 | await this.registerTools(); 151 | 152 | console.log(`BestReads MCP server ready with all tools initialized`); 153 | } 154 | 155 | private async registerTools() { 156 | // ================== MCP TOOLS ================== 157 | 158 | this.server.tool("getProfile", "View your reading history and preferences", {}, async () => { 159 | const preferences = await this.getUserPreferences(); 160 | 161 | const favoriteGenres = preferences.favoriteGenres || []; 162 | const favoriteAuthors = preferences.favoriteAuthors || []; 163 | const booksRead = preferences.booksRead || []; 164 | const dislikedBooks = preferences.dislikedBooks || []; 165 | const dislikedAuthors = preferences.dislikedAuthors || []; 166 | 167 | return { 168 | content: [ 169 | { 170 | type: "text", 171 | text: `**${preferences.userName}'s Reading Profile** 172 | 173 | **Favorite Genres:** ${favoriteGenres.length > 0 ? favoriteGenres.join(", ") : "None yet"} 174 | 175 | **Favorite Authors:** ${favoriteAuthors.length > 0 ? favoriteAuthors.join(", ") : "None yet"} 176 | 177 | **Books Read:** ${booksRead.length} books 178 | ${booksRead.length > 0 ? booksRead.slice(-3).map(book => 179 | `• "${book.title}" by ${book.author}` 180 | ).join('\n') : "None yet"} 181 | 182 | **Disliked Books:** ${dislikedBooks.length} books 183 | ${dislikedBooks.length > 0 ? dislikedBooks.slice(-2).map(book => 184 | `• "${book.title}" by ${book.author}` 185 | ).join('\n') : "None yet"} 186 | 187 | **Disliked Authors:** ${dislikedAuthors.length > 0 ? dislikedAuthors.join(", ") : "None yet"} 188 | 189 | **GitHub User:** ${this.props?.login || 'Anonymous'} 190 | 191 | Use the available tools to add your preferences for better recommendations.`, 192 | }, 193 | ], 194 | }; 195 | }); 196 | 197 | this.server.tool( 198 | "addGenre", 199 | "Add a book genre you enjoy reading", 200 | { 201 | genre: z.string().describe("A book genre you like (e.g., 'science fiction', 'mystery', 'romance')"), 202 | }, 203 | async ({ genre }) => { 204 | const preferences = await this.getUserPreferences(); 205 | const normalizedGenre = genre.toLowerCase().trim(); 206 | 207 | if (preferences.favoriteGenres.includes(normalizedGenre)) { 208 | return { 209 | content: [ 210 | { 211 | type: "text", 212 | text: `"${genre}" is already in your favorites! 213 | 214 | Current genres: ${preferences.favoriteGenres.join(", ")}`, 215 | }, 216 | ], 217 | }; 218 | } 219 | 220 | preferences.favoriteGenres.push(normalizedGenre); 221 | await this.updateUserPreferences(preferences); 222 | 223 | const encouragement = preferences.favoriteGenres.length === 1 224 | ? "Great start! Add more genres to improve recommendations." 225 | : `Perfect! With ${preferences.favoriteGenres.length} genres, I'm learning your taste.`; 226 | 227 | return { 228 | content: [ 229 | { 230 | type: "text", 231 | text: `Added "${genre}" to your favorites! 232 | 233 | **Your favorite genres:** ${preferences.favoriteGenres.join(", ")} 234 | 235 | ${encouragement}`, 236 | }, 237 | ], 238 | }; 239 | } 240 | ); 241 | 242 | this.server.tool( 243 | "addFavoriteAuthor", 244 | "Add an author you enjoy reading", 245 | { 246 | author: z.string().describe("An author you like (e.g., 'J.K. Rowling', 'Stephen King', 'Agatha Christie')"), 247 | }, 248 | async ({ author }) => { 249 | const preferences = await this.getUserPreferences(); 250 | const normalizedAuthor = author.trim(); 251 | const favoriteAuthors = preferences.favoriteAuthors || []; 252 | 253 | if (favoriteAuthors.includes(normalizedAuthor)) { 254 | return { 255 | content: [ 256 | { 257 | type: "text", 258 | text: `"${author}" is already in your favorite authors! 259 | 260 | Current favorite authors: ${favoriteAuthors.join(", ")}`, 261 | }, 262 | ], 263 | }; 264 | } 265 | 266 | favoriteAuthors.push(normalizedAuthor); 267 | preferences.favoriteAuthors = favoriteAuthors; 268 | await this.updateUserPreferences(preferences); 269 | 270 | const encouragement = favoriteAuthors.length === 1 271 | ? "Great start! Add more authors to improve recommendations." 272 | : `Perfect! With ${favoriteAuthors.length} favorite authors, I'm learning your taste.`; 273 | 274 | return { 275 | content: [ 276 | { 277 | type: "text", 278 | text: `Added "${author}" to your favorite authors! 279 | 280 | **Your favorite authors:** ${favoriteAuthors.join(", ")} 281 | 282 | ${encouragement}`, 283 | }, 284 | ], 285 | }; 286 | } 287 | ); 288 | 289 | this.server.tool( 290 | "addBookRead", 291 | "Add a book you have read", 292 | { 293 | title: z.string().describe("The book title"), 294 | author: z.string().describe("The book author"), 295 | }, 296 | async ({ title, author }) => { 297 | const preferences = await this.getUserPreferences(); 298 | 299 | const booksRead = preferences.booksRead || []; 300 | 301 | const bookExists = booksRead.some( 302 | book => book.title.toLowerCase() === title.toLowerCase() && 303 | book.author.toLowerCase() === author.toLowerCase() 304 | ); 305 | 306 | if (bookExists) { 307 | return { 308 | content: [ 309 | { 310 | type: "text", 311 | text: `"${title}" by ${author} is already in your reading list!`, 312 | }, 313 | ], 314 | }; 315 | } 316 | 317 | const bookEntry = { 318 | title, 319 | author, 320 | dateAdded: new Date().toISOString(), 321 | }; 322 | 323 | booksRead.push(bookEntry); 324 | preferences.booksRead = booksRead; 325 | await this.updateUserPreferences(preferences); 326 | 327 | return { 328 | content: [ 329 | { 330 | type: "text", 331 | text: `Added "${title}" by ${author} to your reading list! 332 | 333 | **Total books read:** ${booksRead.length} 334 | **Recent reads:** 335 | ${booksRead.slice(-3).map(book => 336 | `• "${book.title}" by ${book.author}` 337 | ).join('\n')}`, 338 | }, 339 | ], 340 | }; 341 | } 342 | ); 343 | 344 | this.server.tool( 345 | "addDislikedBook", 346 | "Add a book you didn't like", 347 | { 348 | title: z.string().describe("The book title"), 349 | author: z.string().describe("The book author"), 350 | }, 351 | async ({ title, author }) => { 352 | const preferences = await this.getUserPreferences(); 353 | const dislikedBooks = preferences.dislikedBooks || []; 354 | const bookExists = dislikedBooks.some( 355 | book => book.title.toLowerCase() === title.toLowerCase() && 356 | book.author.toLowerCase() === author.toLowerCase() 357 | ); 358 | 359 | if (bookExists) { 360 | return { 361 | content: [ 362 | { 363 | type: "text", 364 | text: `"${title}" by ${author} is already in your disliked books list!`, 365 | }, 366 | ], 367 | }; 368 | } 369 | 370 | const bookEntry = { 371 | title, 372 | author, 373 | dateAdded: new Date().toISOString(), 374 | }; 375 | 376 | dislikedBooks.push(bookEntry); 377 | preferences.dislikedBooks = dislikedBooks; 378 | await this.updateUserPreferences(preferences); 379 | 380 | return { 381 | content: [ 382 | { 383 | type: "text", 384 | text: `Added "${title}" by ${author} to your disliked books list. 385 | 386 | This will help me avoid recommending similar books or this author in the future.`, 387 | }, 388 | ], 389 | }; 390 | } 391 | ); 392 | 393 | this.server.tool( 394 | "addDislikedAuthor", 395 | "Add an author you don't like", 396 | { 397 | author: z.string().describe("An author you don't like"), 398 | }, 399 | async ({ author }) => { 400 | const preferences = await this.getUserPreferences(); 401 | const normalizedAuthor = author.trim(); 402 | const dislikedAuthors = preferences.dislikedAuthors || []; 403 | 404 | if (dislikedAuthors.includes(normalizedAuthor)) { 405 | return { 406 | content: [ 407 | { 408 | type: "text", 409 | text: `"${author}" is already in your disliked authors list! 410 | 411 | Current disliked authors: ${dislikedAuthors.join(", ")}`, 412 | }, 413 | ], 414 | }; 415 | } 416 | 417 | dislikedAuthors.push(normalizedAuthor); 418 | preferences.dislikedAuthors = dislikedAuthors; 419 | await this.updateUserPreferences(preferences); 420 | 421 | return { 422 | content: [ 423 | { 424 | type: "text", 425 | text: `Added "${author}" to your disliked authors list. 426 | 427 | This will help me avoid recommending books by this author in the future.`, 428 | }, 429 | ], 430 | }; 431 | } 432 | ); 433 | 434 | this.server.tool( 435 | "clearPreferences", 436 | "Clear all your reading preferences and start fresh", 437 | {}, 438 | async () => { 439 | const preferences = await this.getUserPreferences(); 440 | const clearedPreferences: BookPreferences = { 441 | userName: preferences.userName, 442 | favoriteGenres: [], 443 | favoriteAuthors: [], 444 | booksRead: [], 445 | dislikedBooks: [], 446 | dislikedAuthors: [], 447 | }; 448 | 449 | await this.updateUserPreferences(clearedPreferences); 450 | 451 | return { 452 | content: [ 453 | { 454 | type: "text", 455 | text: `🧹 **All preferences cleared for ${preferences.userName}!** 456 | 457 | Your reading profile has been reset: 458 | • Favorite genres: cleared 459 | • Favorite authors: cleared 460 | • Books read: cleared 461 | • Disliked books: cleared 462 | • Disliked authors: cleared 463 | 464 | You can start building your preferences again using the available tools.`, 465 | }, 466 | ], 467 | }; 468 | } 469 | ); 470 | 471 | this.server.tool( 472 | "getBookRecommendations", 473 | "Get personalized book recommendations based on your preferences", 474 | {}, 475 | async () => { 476 | const preferences = await this.getUserPreferences(); 477 | 478 | // Build contextual prompt for AI recommendations 479 | let prompt = `Recommend 3 books for ${preferences.userName}. `; 480 | 481 | if (preferences.favoriteGenres.length > 0) { 482 | prompt += `They enjoy these genres: ${preferences.favoriteGenres.join(", ")}. `; 483 | } 484 | 485 | if (preferences.favoriteAuthors.length > 0) { 486 | prompt += `They like these authors: ${preferences.favoriteAuthors.join(", ")}. `; 487 | } 488 | 489 | if (preferences.booksRead.length > 0) { 490 | const recentBooks = preferences.booksRead.slice(-5).map(b => 491 | `"${b.title}" by ${b.author}` 492 | ); 493 | prompt += `They have read: ${recentBooks.join(", ")}. `; 494 | } 495 | 496 | if (preferences.dislikedBooks.length > 0) { 497 | const dislikedBooks = preferences.dislikedBooks.map(b => 498 | `"${b.title}" by ${b.author}` 499 | ); 500 | prompt += `They disliked these books: ${dislikedBooks.join(", ")}. `; 501 | } 502 | 503 | if (preferences.dislikedAuthors.length > 0) { 504 | prompt += `They don't like these authors: ${preferences.dislikedAuthors.join(", ")}. `; 505 | } 506 | 507 | prompt += `Provide specific book recommendations with title, author, and brief explanation of why they'd enjoy it. Avoid recommending books they've already read or authors they dislike.`; 508 | 509 | try { 510 | // Generate recommendation by using Workers AI 511 | const response = await this.env.AI.run("@cf/meta/llama-3.1-8b-instruct-fast", { 512 | prompt, 513 | max_tokens: 600, 514 | }); 515 | 516 | const contextUsed = []; 517 | if (preferences.favoriteGenres.length > 0) contextUsed.push(`${preferences.favoriteGenres.length} favorite genres`); 518 | if (preferences.favoriteAuthors.length > 0) contextUsed.push(`${preferences.favoriteAuthors.length} favorite authors`); 519 | if (preferences.booksRead.length > 0) contextUsed.push(`${preferences.booksRead.length} books read`); 520 | if (preferences.dislikedBooks.length > 0) contextUsed.push(`${preferences.dislikedBooks.length} disliked books`); 521 | if (preferences.dislikedAuthors.length > 0) contextUsed.push(`${preferences.dislikedAuthors.length} disliked authors`); 522 | 523 | const contextText = contextUsed.length > 0 524 | ? `\n\nPersonalized based on: ${contextUsed.join(", ")}.` 525 | : "\n\nAdd your preferences using the available tools for more personalized recommendations."; 526 | 527 | return { 528 | content: [ 529 | { 530 | type: "text", 531 | text: `**Personalized Recommendations for ${preferences.userName}:** 532 | 533 | ${response.response}${contextText}`, 534 | }, 535 | ], 536 | }; 537 | } catch (error) { 538 | console.error("AI recommendation error:", error); 539 | return { 540 | content: [ 541 | { 542 | type: "text", 543 | text: `Sorry, I had trouble generating recommendations right now. Please try again in a moment.`, 544 | }, 545 | ], 546 | }; 547 | } 548 | } 549 | ); 550 | } 551 | } 552 | 553 | // Using the correct OAuth Provider pattern based on the actual library API 554 | export default new OAuthProvider({ 555 | // Configure API routes for MCP - these will have OAuth protection 556 | apiHandlers: { 557 | '/mcp': MyMCP.serve('/mcp', { 558 | binding: 'MCP_OBJECT', 559 | corsOptions: { 560 | origin: "*", 561 | methods: "GET, POST, OPTIONS", 562 | headers: "Content-Type, Authorization", 563 | maxAge: 86400 564 | } 565 | }), 566 | '/sse': MyMCP.serveSSE('/sse', { 567 | binding: 'MCP_OBJECT', 568 | corsOptions: { 569 | origin: "*", 570 | methods: "GET, POST, OPTIONS", 571 | headers: "Content-Type, Authorization, Cache-Control, Last-Event-ID", 572 | maxAge: 86400 573 | } 574 | }), 575 | }, 576 | 577 | // The default handler handles OAuth flow and other non-API requests 578 | defaultHandler: GitHubHandler, 579 | 580 | // OAuth endpoint configuration 581 | authorizeEndpoint: "/authorize", 582 | tokenEndpoint: "/token", 583 | clientRegistrationEndpoint: "/register", 584 | scopesSupported: ["read:user", "user:email"], 585 | // Add access token TTL (optional) 586 | accessTokenTTL: 3600, // 1 hour 587 | }); -------------------------------------------------------------------------------- /src/workers-oauth-utils.ts: -------------------------------------------------------------------------------- 1 | // workers-oauth-utils.ts 2 | 3 | import type { ClientInfo, AuthRequest } from "@cloudflare/workers-oauth-provider"; // Adjust path if necessary 4 | 5 | const COOKIE_NAME = "mcp-approved-clients"; 6 | const ONE_YEAR_IN_SECONDS = 31536000; 7 | 8 | // --- Helper Functions --- 9 | 10 | /** 11 | * Encodes arbitrary data to a URL-safe base64 string. 12 | * @param data - The data to encode (will be stringified). 13 | * @returns A URL-safe base64 encoded string. 14 | */ 15 | function encodeState(data: any): string { 16 | try { 17 | const jsonString = JSON.stringify(data); 18 | // Use btoa for simplicity, assuming Worker environment supports it well enough 19 | // For complex binary data, a Buffer/Uint8Array approach might be better 20 | return btoa(jsonString); 21 | } catch (e) { 22 | console.error("Error encoding state:", e); 23 | throw new Error("Could not encode state"); 24 | } 25 | } 26 | 27 | /** 28 | * Decodes a URL-safe base64 string back to its original data. 29 | * @param encoded - The URL-safe base64 encoded string. 30 | * @returns The original data. 31 | */ 32 | function decodeState(encoded: string): T { 33 | try { 34 | const jsonString = atob(encoded); 35 | return JSON.parse(jsonString); 36 | } catch (e) { 37 | console.error("Error decoding state:", e); 38 | throw new Error("Could not decode state"); 39 | } 40 | } 41 | 42 | /** 43 | * Imports a secret key string for HMAC-SHA256 signing. 44 | * @param secret - The raw secret key string. 45 | * @returns A promise resolving to the CryptoKey object. 46 | */ 47 | async function importKey(secret: string): Promise { 48 | if (!secret) { 49 | throw new Error("COOKIE_SECRET is not defined. A secret key is required for signing cookies."); 50 | } 51 | const enc = new TextEncoder(); 52 | return crypto.subtle.importKey( 53 | "raw", 54 | enc.encode(secret), 55 | { name: "HMAC", hash: "SHA-256" }, 56 | false, // not extractable 57 | ["sign", "verify"], // key usages 58 | ); 59 | } 60 | 61 | /** 62 | * Signs data using HMAC-SHA256. 63 | * @param key - The CryptoKey for signing. 64 | * @param data - The string data to sign. 65 | * @returns A promise resolving to the signature as a hex string. 66 | */ 67 | async function signData(key: CryptoKey, data: string): Promise { 68 | const enc = new TextEncoder(); 69 | const signatureBuffer = await crypto.subtle.sign("HMAC", key, enc.encode(data)); 70 | // Convert ArrayBuffer to hex string 71 | return Array.from(new Uint8Array(signatureBuffer)) 72 | .map((b) => b.toString(16).padStart(2, "0")) 73 | .join(""); 74 | } 75 | 76 | /** 77 | * Verifies an HMAC-SHA256 signature. 78 | * @param key - The CryptoKey for verification. 79 | * @param signatureHex - The signature to verify (hex string). 80 | * @param data - The original data that was signed. 81 | * @returns A promise resolving to true if the signature is valid, false otherwise. 82 | */ 83 | async function verifySignature(key: CryptoKey, signatureHex: string, data: string): Promise { 84 | const enc = new TextEncoder(); 85 | try { 86 | // Convert hex signature back to ArrayBuffer 87 | const signatureBytes = new Uint8Array(signatureHex.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16))); 88 | return await crypto.subtle.verify("HMAC", key, signatureBytes.buffer, enc.encode(data)); 89 | } catch (e) { 90 | // Handle errors during hex parsing or verification 91 | console.error("Error verifying signature:", e); 92 | return false; 93 | } 94 | } 95 | 96 | /** 97 | * Parses the signed cookie and verifies its integrity. 98 | * @param cookieHeader - The value of the Cookie header from the request. 99 | * @param secret - The secret key used for signing. 100 | * @returns A promise resolving to the list of approved client IDs if the cookie is valid, otherwise null. 101 | */ 102 | async function getApprovedClientsFromCookie(cookieHeader: string | null, secret: string): Promise { 103 | if (!cookieHeader) return null; 104 | 105 | const cookies = cookieHeader.split(";").map((c) => c.trim()); 106 | const targetCookie = cookies.find((c) => c.startsWith(`${COOKIE_NAME}=`)); 107 | 108 | if (!targetCookie) return null; 109 | 110 | const cookieValue = targetCookie.substring(COOKIE_NAME.length + 1); 111 | const parts = cookieValue.split("."); 112 | 113 | if (parts.length !== 2) { 114 | console.warn("Invalid cookie format received."); 115 | return null; // Invalid format 116 | } 117 | 118 | const [signatureHex, base64Payload] = parts; 119 | const payload = atob(base64Payload); // Assuming payload is base64 encoded JSON string 120 | 121 | const key = await importKey(secret); 122 | const isValid = await verifySignature(key, signatureHex, payload); 123 | 124 | if (!isValid) { 125 | console.warn("Cookie signature verification failed."); 126 | return null; // Signature invalid 127 | } 128 | 129 | try { 130 | const approvedClients = JSON.parse(payload); 131 | if (!Array.isArray(approvedClients)) { 132 | console.warn("Cookie payload is not an array."); 133 | return null; // Payload isn't an array 134 | } 135 | // Ensure all elements are strings 136 | if (!approvedClients.every((item) => typeof item === "string")) { 137 | console.warn("Cookie payload contains non-string elements."); 138 | return null; 139 | } 140 | return approvedClients as string[]; 141 | } catch (e) { 142 | console.error("Error parsing cookie payload:", e); 143 | return null; // JSON parsing failed 144 | } 145 | } 146 | 147 | // --- Exported Functions --- 148 | 149 | /** 150 | * Checks if a given client ID has already been approved by the user, 151 | * based on a signed cookie. 152 | * 153 | * @param request - The incoming Request object to read cookies from. 154 | * @param clientId - The OAuth client ID to check approval for. 155 | * @param cookieSecret - The secret key used to sign/verify the approval cookie. 156 | * @returns A promise resolving to true if the client ID is in the list of approved clients in a valid cookie, false otherwise. 157 | */ 158 | export async function clientIdAlreadyApproved(request: Request, clientId: string, cookieSecret: string): Promise { 159 | if (!clientId) return false; 160 | const cookieHeader = request.headers.get("Cookie"); 161 | const approvedClients = await getApprovedClientsFromCookie(cookieHeader, cookieSecret); 162 | 163 | return approvedClients?.includes(clientId) ?? false; 164 | } 165 | 166 | /** 167 | * Configuration for the approval dialog 168 | */ 169 | export interface ApprovalDialogOptions { 170 | /** 171 | * Client information to display in the approval dialog 172 | */ 173 | client: ClientInfo | null; 174 | /** 175 | * Server information to display in the approval dialog 176 | */ 177 | server: { 178 | name: string; 179 | logo?: string; 180 | description?: string; 181 | }; 182 | /** 183 | * Arbitrary state data to pass through the approval flow 184 | * Will be encoded in the form and returned when approval is complete 185 | */ 186 | state: Record; 187 | /** 188 | * Name of the cookie to use for storing approvals 189 | * @default "mcp_approved_clients" 190 | */ 191 | cookieName?: string; 192 | /** 193 | * Secret used to sign cookies for verification 194 | * Can be a string or Uint8Array 195 | * @default Built-in Uint8Array key 196 | */ 197 | cookieSecret?: string | Uint8Array; 198 | /** 199 | * Cookie domain 200 | * @default current domain 201 | */ 202 | cookieDomain?: string; 203 | /** 204 | * Cookie path 205 | * @default "/" 206 | */ 207 | cookiePath?: string; 208 | /** 209 | * Cookie max age in seconds 210 | * @default 30 days 211 | */ 212 | cookieMaxAge?: number; 213 | } 214 | 215 | /** 216 | * Renders an approval dialog for OAuth authorization 217 | * The dialog displays information about the client and server 218 | * and includes a form to submit approval 219 | * 220 | * @param request - The HTTP request 221 | * @param options - Configuration for the approval dialog 222 | * @returns A Response containing the HTML approval dialog 223 | */ 224 | export function renderApprovalDialog(request: Request, options: ApprovalDialogOptions): Response { 225 | const { client, server, state } = options; 226 | 227 | // Encode state for form submission 228 | const encodedState = btoa(JSON.stringify(state)); 229 | 230 | // Sanitize any untrusted content 231 | const serverName = sanitizeHtml(server.name); 232 | const clientName = client?.clientName ? sanitizeHtml(client.clientName) : "Unknown MCP Client"; 233 | const serverDescription = server.description ? sanitizeHtml(server.description) : ""; 234 | 235 | // Safe URLs 236 | const logoUrl = server.logo ? sanitizeHtml(server.logo) : ""; 237 | const clientUri = client?.clientUri ? sanitizeHtml(client.clientUri) : ""; 238 | const policyUri = client?.policyUri ? sanitizeHtml(client.policyUri) : ""; 239 | const tosUri = client?.tosUri ? sanitizeHtml(client.tosUri) : ""; 240 | 241 | // Client contacts 242 | const contacts = client?.contacts && client.contacts.length > 0 ? sanitizeHtml(client.contacts.join(", ")) : ""; 243 | 244 | // Get redirect URIs 245 | const redirectUris = client?.redirectUris && client.redirectUris.length > 0 ? client.redirectUris.map((uri) => sanitizeHtml(uri)) : []; 246 | 247 | // Generate HTML for the approval dialog 248 | const htmlContent = ` 249 | 250 | 251 | 252 | 253 | 254 | ${clientName} | Authorization Request 255 | 428 | 429 | 430 |
431 |
432 |
433 | ${logoUrl ? `` : ""} 434 |

${serverName}

435 |
436 | 437 | ${serverDescription ? `

${serverDescription}

` : ""} 438 |
439 | 440 |
441 | 442 |

${clientName || "A new MCP Client"} is requesting access

443 | 444 |
445 |
446 |
Name:
447 |
448 | ${clientName} 449 |
450 |
451 | 452 | ${ 453 | clientUri 454 | ? ` 455 |
456 |
Website:
457 | 462 |
463 | ` 464 | : "" 465 | } 466 | 467 | ${ 468 | policyUri 469 | ? ` 470 |
471 |
Privacy Policy:
472 | 477 |
478 | ` 479 | : "" 480 | } 481 | 482 | ${ 483 | tosUri 484 | ? ` 485 |
486 |
Terms of Service:
487 | 492 |
493 | ` 494 | : "" 495 | } 496 | 497 | ${ 498 | redirectUris.length > 0 499 | ? ` 500 |
501 |
Redirect URIs:
502 |
503 | ${redirectUris.map((uri) => `
${uri}
`).join("")} 504 |
505 |
506 | ` 507 | : "" 508 | } 509 | 510 | ${ 511 | contacts 512 | ? ` 513 |
514 |
Contact:
515 |
${contacts}
516 |
517 | ` 518 | : "" 519 | } 520 |
521 | 522 |

This MCP Client is requesting to be authorized on ${serverName}. If you approve, you will be redirected to complete authentication.

523 | 524 |
525 | 526 | 527 |
528 | 529 | 530 |
531 |
532 |
533 |
534 | 535 | 536 | `; 537 | 538 | return new Response(htmlContent, { 539 | headers: { 540 | "Content-Type": "text/html; charset=utf-8", 541 | }, 542 | }); 543 | } 544 | 545 | /** 546 | * Result of parsing the approval form submission. 547 | */ 548 | export interface ParsedApprovalResult { 549 | /** The original state object passed through the form. */ 550 | state: any; 551 | /** Headers to set on the redirect response, including the Set-Cookie header. */ 552 | headers: Record; 553 | } 554 | 555 | /** 556 | * Parses the form submission from the approval dialog, extracts the state, 557 | * and generates Set-Cookie headers to mark the client as approved. 558 | * 559 | * @param request - The incoming POST Request object containing the form data. 560 | * @param cookieSecret - The secret key used to sign the approval cookie. 561 | * @returns A promise resolving to an object containing the parsed state and necessary headers. 562 | * @throws If the request method is not POST, form data is invalid, or state is missing. 563 | */ 564 | export async function parseRedirectApproval(request: Request, cookieSecret: string): Promise { 565 | if (request.method !== "POST") { 566 | throw new Error("Invalid request method. Expected POST."); 567 | } 568 | 569 | let state: any; 570 | let clientId: string | undefined; 571 | 572 | try { 573 | const formData = await request.formData(); 574 | const encodedState = formData.get("state"); 575 | 576 | if (typeof encodedState !== "string" || !encodedState) { 577 | throw new Error("Missing or invalid 'state' in form data."); 578 | } 579 | 580 | state = decodeState<{ oauthReqInfo?: AuthRequest }>(encodedState); // Decode the state 581 | clientId = state?.oauthReqInfo?.clientId; // Extract clientId from within the state 582 | 583 | if (!clientId) { 584 | throw new Error("Could not extract clientId from state object."); 585 | } 586 | } catch (e) { 587 | console.error("Error processing form submission:", e); 588 | // Rethrow or handle as appropriate, maybe return a specific error response 589 | throw new Error(`Failed to parse approval form: ${e instanceof Error ? e.message : String(e)}`); 590 | } 591 | 592 | // Get existing approved clients 593 | const cookieHeader = request.headers.get("Cookie"); 594 | const existingApprovedClients = (await getApprovedClientsFromCookie(cookieHeader, cookieSecret)) || []; 595 | 596 | // Add the newly approved client ID (avoid duplicates) 597 | const updatedApprovedClients = Array.from(new Set([...existingApprovedClients, clientId])); 598 | 599 | // Sign the updated list 600 | const payload = JSON.stringify(updatedApprovedClients); 601 | const key = await importKey(cookieSecret); 602 | const signature = await signData(key, payload); 603 | const newCookieValue = `${signature}.${btoa(payload)}`; // signature.base64(payload) 604 | 605 | // Generate Set-Cookie header 606 | const headers: Record = { 607 | "Set-Cookie": `${COOKIE_NAME}=${newCookieValue}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=${ONE_YEAR_IN_SECONDS}`, 608 | }; 609 | 610 | return { state, headers }; 611 | } 612 | 613 | /** 614 | * Sanitizes HTML content to prevent XSS attacks 615 | * @param unsafe - The unsafe string that might contain HTML 616 | * @returns A safe string with HTML special characters escaped 617 | */ 618 | function sanitizeHtml(unsafe: string): string { 619 | return unsafe.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); 620 | } 621 | --------------------------------------------------------------------------------