├── .dev.vars.example ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── README.md ├── package-lock.json ├── package.json ├── src ├── auth │ ├── handlers │ │ ├── auth.ts │ │ ├── index.ts │ │ ├── oauth.ts │ │ └── token.ts │ ├── index.ts │ ├── middleware │ │ └── session.ts │ ├── oauth2.ts │ └── views │ │ ├── consent.ts │ │ ├── home.ts │ │ ├── index.ts │ │ ├── login.ts │ │ └── register.ts ├── database │ └── index.ts ├── index.ts ├── server │ ├── index.ts │ └── shim.ts ├── tools │ ├── add.ts │ ├── fetch.ts │ ├── generateImage.ts │ ├── index.ts │ ├── me.ts │ ├── personalGreeting.ts │ └── search.ts └── types.ts ├── tsconfig.json ├── worker-configuration.d.ts └── wrangler.jsonc /.dev.vars.example: -------------------------------------------------------------------------------- 1 | JWT_SECRET=your-super-secret-jwt-key-change-this-in-production 2 | COOKIE_ENCRYPTION_KEY=12345678901234567890123456789012 3 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "wrangler.json": "jsonc" 4 | } 5 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MCP Server Boilerplate with OAuth & PostgreSQL 2 | 3 | A complete boilerplate for building remote Model Context Protocol (MCP) servers on Cloudflare Workers with custom OAuth authentication and PostgreSQL database integration. 4 | 5 | ## 🚀 What You Get 6 | 7 | This boilerplate provides everything you need to build production-ready MCP servers: 8 | 9 | - **🔐 Complete OAuth 2.1 Provider** - Custom OAuth implementation with user registration/login 10 | - **🗄️ PostgreSQL Integration** - Full database schema with user management and OAuth tokens 11 | - **⚡ Cloudflare Workers** - Serverless deployment with global edge distribution 12 | - **🛠️ MCP Tools Framework** - Modular tool system with user context 13 | - **🔌 Custom Routes Framework** - Easy-to-use system for adding REST API endpoints 14 | - **🎨 Beautiful UI** - Responsive login/registration pages with customizable consent screen 15 | - **🔒 Security First** - JWT tokens, bcrypt hashing, PKCE support 16 | - **📱 Mobile Ready** - Works on desktop, web, and mobile MCP clients 17 | - **🔧 Developer Friendly** - TypeScript, hot reload, comprehensive error handling 18 | 19 | ### Available MCP Tools 20 | 21 | - `add` - Basic math operations 22 | - `userInfo` - Get current user information 23 | - `personalGreeting` - Personalized user greetings 24 | - `generateImage` - AI image generation 25 | - `getUserStats` - User statistics and analytics 26 | 27 | ## ⚡ Quick Start from index.ts 28 | 29 | **Everything starts from `src/index.ts`** - this is your main configuration file where you can easily: 30 | 31 | ### 🛠️ Register MCP Tools 32 | ```typescript 33 | // Register all MCP tools with one line each 34 | backend 35 | .registerTool(registerMeTool) 36 | .registerTool(registerGreetingTool) 37 | .registerTool(registerAddTool) 38 | .registerTool(registerGenerateImageTool); 39 | ``` 40 | 41 | ### 🔌 Add Custom REST API Endpoints 42 | ```typescript 43 | // Public endpoints (no authentication required) 44 | backend 45 | .route('GET', '/api/ping', (c) => { 46 | return c.json({ message: 'pong', timestamp: new Date().toISOString() }); 47 | }) 48 | .route('POST', '/api/echo', async (c) => { 49 | const body = await c.req.json(); 50 | return c.json({ echo: body, timestamp: new Date().toISOString() }); 51 | }); 52 | 53 | // Protected endpoints (OAuth token required) 54 | backend 55 | .authRoute('GET', '/api/profile', (c, userContext, env) => { 56 | return c.json({ 57 | message: 'User profile data', 58 | user: userContext, 59 | timestamp: new Date().toISOString() 60 | }); 61 | }) 62 | .authRoute('POST', '/api/user/settings', async (c, userContext, env) => { 63 | const settings = await c.req.json(); 64 | return c.json({ 65 | message: 'Settings updated successfully', 66 | userId: userContext.userId, 67 | settings, 68 | timestamp: new Date().toISOString() 69 | }); 70 | }); 71 | ``` 72 | 73 | ### 🎨 Customizable Consent Screen 74 | 75 | The OAuth consent screen is **fully customizable** with pure HTML, CSS, and JavaScript located in `src/auth/views/consent.ts`. Features include: 76 | 77 | - **🌙 Dark/Light Mode Toggle** - Automatic system preference detection with manual override 78 | - **📱 Responsive Design** - Mobile-first design with Tailwind CSS 79 | - **🎨 Custom Branding** - Easy logo, colors, and styling customization 80 | - **⚡ Real-time Updates** - JavaScript-powered consent flow with loading states 81 | - **🔒 Security Features** - CSRF protection and secure form handling 82 | 83 | **Customize the consent screen:** 84 | ```typescript 85 | // In src/auth/views/consent.ts - modify the generateConsentPage function 86 | export function generateConsentPage(data: ConsentPageData, config: ConsentPageConfig = {}) { 87 | const { 88 | title = "Your Custom Title", // ← Customize title 89 | subtitle = "Your custom subtitle", // ← Customize subtitle 90 | logoUrl = "/your-logo.png", // ← Add your logo 91 | brandColor = "purple" // ← Change brand color 92 | } = config; 93 | 94 | // Full HTML/CSS/JS customization available in the template string 95 | return `...`; 96 | } 97 | ``` 98 | 99 | The consent screen includes: 100 | - Application info display with logos 101 | - User profile information 102 | - Granular permission scopes 103 | - Terms of Service and Privacy Policy links 104 | - Beautiful animations and transitions 105 | - Error handling and loading states 106 | 107 | ## 🔌 Custom Routes Framework 108 | 109 | This boilerplate includes a powerful **custom routes framework** that makes it easy to add REST API endpoints alongside your MCP tools: 110 | 111 | ### Public Routes (No Authentication) 112 | ```typescript 113 | backend.route('GET', '/api/status', (c) => { 114 | return c.json({ status: 'operational', version: '1.0.0' }); 115 | }); 116 | 117 | backend.route('POST', '/api/webhook', async (c) => { 118 | const payload = await c.req.json(); 119 | // Process webhook 120 | return c.json({ received: true }); 121 | }); 122 | ``` 123 | 124 | ### Protected Routes (OAuth Required) 125 | ```typescript 126 | backend.authRoute('GET', '/api/user/dashboard', (c, userContext, env) => { 127 | // userContext contains: { userId, name, username, email, scopes } 128 | return c.json({ 129 | welcomeMessage: `Hello ${userContext.name}!`, 130 | userStats: { /* ... */ } 131 | }); 132 | }); 133 | 134 | backend.authRoute('PUT', '/api/user/profile', async (c, userContext, env) => { 135 | const updates = await c.req.json(); 136 | // Update user profile in database 137 | return c.json({ success: true }); 138 | }); 139 | ``` 140 | 141 | ### Framework Features 142 | - **🔒 Automatic Authentication** - Protected routes get user context automatically 143 | - **📝 Type Safety** - Full TypeScript support with proper typing 144 | - **🌐 HTTP Methods** - Support for GET, POST, PUT, DELETE, PATCH 145 | - **📊 Request Handling** - Easy access to headers, body, query params 146 | - **🔄 Response Helpers** - JSON, HTML, redirect, and custom responses 147 | - **⚡ Performance** - Built on Hono framework for maximum speed 148 | 149 | This makes it easy to create complete applications with both MCP tools and traditional REST APIs! 150 | 151 | ## 📋 Prerequisites 152 | 153 | Before you start, ensure you have: 154 | 155 | - **Node.js 18+** - [Download here](https://nodejs.org/) 156 | - **Cloudflare Account** - [Sign up free](https://dash.cloudflare.com/sign-up) 157 | - **PostgreSQL Database** - See [database options](#database-setup-options) below 158 | - **Git** - For cloning the repository 159 | 160 | ## 🏗️ Quick Start 161 | 162 | ### 1. Clone and Install 163 | 164 | ```bash 165 | # Clone the repository 166 | git clone https://github.com/f/mcp-cloudflare-boilerplate 167 | cd mcp-cloudfare-boilerplate 168 | 169 | # Install dependencies 170 | npm install 171 | 172 | # Install Wrangler CLI globally (if not already installed) 173 | npm install -g wrangler 174 | ``` 175 | 176 | ### 2. Database Setup Options 177 | 178 | Choose one of these PostgreSQL hosting options: 179 | 180 | #### Option A: Neon (Recommended - Free Tier) 181 | 1. Go to [neon.tech](https://neon.tech) and create account 182 | 2. Create a new project 183 | 3. Copy the connection string from the dashboard 184 | 185 | #### Option B: Supabase (Free Tier) 186 | 1. Go to [supabase.com](https://supabase.com) and create account 187 | 2. Create a new project 188 | 3. Go to Settings → Database and copy the connection string 189 | 190 | #### Option C: Railway (Simple Setup) 191 | 1. Go to [railway.app](https://railway.app) and create account 192 | 2. Create a new PostgreSQL database 193 | 3. Copy the connection string from the Connect tab 194 | 195 | #### Option D: Local PostgreSQL 196 | ```bash 197 | # Using Docker (recommended for local development) 198 | docker run --name mcp-postgres \ 199 | -e POSTGRES_DB=mcpserver \ 200 | -e POSTGRES_USER=mcpuser \ 201 | -e POSTGRES_PASSWORD=mcppassword \ 202 | -p 5432:5432 \ 203 | -d postgres:15 204 | 205 | # Connection string will be: 206 | # postgresql://mcpuser:mcppassword@localhost:5432/mcpserver 207 | ``` 208 | 209 | ### Database Optimization with Hyperdrive (Optional) 210 | 211 | This boilerplate is **optimized to work on Cloudflare** but **doesn't rely on Cloudflare systems** - you can run it on your own servers with any hosting provider. 212 | 213 | #### Using Cloudflare Hyperdrive (Recommended for Cloudflare deployment) 214 | 215 | [Cloudflare Hyperdrive](https://developers.cloudflare.com/hyperdrive/) dramatically improves database performance by connection pooling and global caching. To enable Hyperdrive: 216 | 217 | 1. **Create a Hyperdrive configuration** in your Cloudflare dashboard: 218 | ```bash 219 | # Via Wrangler CLI 220 | wrangler hyperdrive create my-mcp-db --connection-string="postgresql://username:password@host:port/database" 221 | ``` 222 | 223 | 2. **Update wrangler.toml** to reference your Hyperdrive: 224 | ```toml 225 | [[hyperdrive]] 226 | binding = "HYPERDRIVE" 227 | id = "your-hyperdrive-id" 228 | ``` 229 | 230 | 3. **Use Hyperdrive in your environment**: 231 | ```ini 232 | # In .dev.vars or production secrets 233 | DATABASE_URL="postgresql://username:password@host:port/database" 234 | # Hyperdrive will automatically optimize this connection 235 | ``` 236 | 237 | #### Alternative: Running on Other Platforms 238 | 239 | **Don't want to use Cloudflare?** Simply change your deployment environment: 240 | 241 | - **Vercel**: Use `@vercel/node` runtime with PostgreSQL 242 | - **Railway**: Deploy directly with built-in PostgreSQL 243 | - **AWS Lambda**: Use with RDS or Aurora Serverless 244 | - **Google Cloud Run**: Deploy with Cloud SQL 245 | - **Self-hosted**: Deploy with Docker and any PostgreSQL instance 246 | 247 | The codebase uses standard PostgreSQL connections and OAuth2, making it **platform-agnostic**. Just update your: 248 | - Database connection string 249 | - Deployment configuration 250 | - Environment variables 251 | 252 | No Cloudflare-specific dependencies are required for core functionality. 253 | 254 | ### 3. Configure Environment 255 | 256 | ```bash 257 | # Copy environment template 258 | cp .dev.vars.example .dev.vars 259 | 260 | # Edit .dev.vars with your settings 261 | nano .dev.vars 262 | ``` 263 | 264 | Add your configuration: 265 | 266 | ```ini 267 | # Database Configuration 268 | DATABASE_URL="postgresql://username:password@host:port/database" 269 | 270 | # Security Keys (IMPORTANT: Generate strong, unique keys) 271 | JWT_SECRET="your-super-secret-jwt-key-at-least-32-characters-long" 272 | COOKIE_ENCRYPTION_KEY="exactly-32-characters-for-encryption" 273 | 274 | # Optional: Image Generation (if using Workers AI) 275 | # ALLOWED_IMAGE_USERS="username1,username2" 276 | ``` 277 | 278 | **🔒 Security Note**: Generate strong secrets: 279 | ```bash 280 | # Generate JWT secret (64 characters) 281 | openssl rand -hex 32 282 | 283 | # Generate cookie encryption key (32 characters) 284 | openssl rand -hex 16 285 | ``` 286 | 287 | ### 4. Cloudflare Setup 288 | 289 | ```bash 290 | # Login to Cloudflare 291 | wrangler login 292 | 293 | # Follow the browser authentication flow 294 | ``` 295 | 296 | ### 5. Initialize Database 297 | 298 | ```bash 299 | # Start development server 300 | npm run dev 301 | 302 | # In another terminal, initialize database tables 303 | curl -X POST http://localhost:8787/init-db 304 | 305 | # Expected response: 306 | # {"message":"Database initialized successfully"} 307 | ``` 308 | 309 | ### 6. Test Your Setup 310 | 311 | 1. **Open your browser**: Go to `http://localhost:8787` 312 | 2. **Register account**: Create a new user account 313 | 3. **Test login**: Login with your credentials 314 | 4. **Check tools**: Visit `http://localhost:8787/up` to see available tools 315 | 316 | ## 🚀 Deployment 317 | 318 | ### Production Deployment 319 | 320 | 1. **Set production secrets**: 321 | ```bash 322 | # Set each secret securely (you'll be prompted to enter values) 323 | wrangler secret put JWT_SECRET 324 | wrangler secret put COOKIE_ENCRYPTION_KEY 325 | ``` 326 | 327 | 2. **Deploy to Cloudflare**: 328 | ```bash 329 | npm run deploy 330 | ``` 331 | 332 | 3. **Initialize production database**: 333 | ```bash 334 | # Replace with your actual worker URL 335 | curl -X POST https://your-worker.your-subdomain.workers.dev/init-db 336 | ``` 337 | 338 | 4. **Update OAuth settings**: Update any OAuth client redirect URIs to use your production URL. 339 | 340 | ### Custom Domain (Optional) 341 | 342 | 1. In Cloudflare dashboard, go to Workers & Pages 343 | 2. Select your worker 344 | 3. Go to Settings → Triggers 345 | 4. Add custom domain 346 | 347 | ## 🔧 Development 348 | 349 | ### Available Scripts 350 | 351 | ```bash 352 | # Development with hot reload 353 | npm run dev 354 | 355 | # Type checking 356 | npm run type-check 357 | 358 | # Build for production 359 | npm run build 360 | 361 | # Deploy to Cloudflare 362 | npm run deploy 363 | 364 | # Run tests (if you add them) 365 | npm test 366 | ``` 367 | 368 | ### Project Structure 369 | 370 | ``` 371 | mcp-cf-boilerplate/ 372 | ├── src/ 373 | │ ├── auth/ # Authentication system 374 | │ │ ├── handlers/ # OAuth & auth endpoints 375 | │ │ ├── middleware/ # Session management 376 | │ │ ├── oauth2.ts # OAuth2 server implementation 377 | │ │ └── views/ # Login/register UI 378 | │ ├── database/ # Database models & queries 379 | │ ├── server/ # MCP server implementation 380 | │ ├── tools/ # MCP tools (modular) 381 | │ ├── types.ts # TypeScript definitions 382 | │ └── index.ts # Main entry point 383 | ├── wrangler.toml # Cloudflare Workers config 384 | ├── package.json # Dependencies & scripts 385 | └── README.md # This file 386 | ``` 387 | 388 | ### Adding New MCP Tools 389 | 390 | 1. **Create tool file**: `src/tools/myTool.ts` 391 | ```typescript 392 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 393 | import { UserContext } from "../types"; 394 | 395 | export function registerMyTool(server: McpServer, userContext: UserContext, env: Env) { 396 | server.tool("myTool", "Description of my tool", { 397 | input: { 398 | type: "object", 399 | properties: { 400 | message: { type: "string", description: "Input message" } 401 | }, 402 | required: ["message"] 403 | } 404 | }, async ({ message }) => { 405 | return { 406 | content: [{ 407 | type: "text", 408 | text: `Hello ${userContext.name}! You said: ${message}` 409 | }] 410 | }; 411 | }); 412 | } 413 | ``` 414 | 415 | 2. **Export from index**: Add to `src/tools/index.ts` 416 | ```typescript 417 | export { registerMyTool } from './myTool'; 418 | ``` 419 | 420 | 3. **Register in main**: Add to `src/index.ts` 421 | ```typescript 422 | import { registerMyTool } from "./tools"; 423 | 424 | backend.registerTool(registerMyTool); 425 | ``` 426 | 427 | ## 🔌 Using Your MCP Server 428 | 429 | ### With Claude Desktop 430 | 431 | Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS): 432 | 433 | ```json 434 | { 435 | "mcpServers": { 436 | "my-mcp-server": { 437 | "command": "npx", 438 | "args": [ 439 | "mcp-remote", 440 | "https://your-worker.your-subdomain.workers.dev/mcp" 441 | ] 442 | } 443 | } 444 | } 445 | ``` 446 | 447 | ### MCP Endpoints 448 | - `POST /mcp` - Main MCP endpoint (requires OAuth) 449 | - `GET /up` - Health check and server info 450 | 451 | ### Management Endpoints 452 | - `POST /init-db` - Initialize database (run once) 453 | 454 | ## 🤝 Contributing 455 | 456 | 1. Fork the repository 457 | 2. Create a feature branch: `git checkout -b feature/amazing-feature` 458 | 3. Make your changes 459 | 4. Add tests if applicable 460 | 5. Commit changes: `git commit -m 'Add amazing feature'` 461 | 6. Push to branch: `git push origin feature/amazing-feature` 462 | 7. Open a Pull Request 463 | 464 | ### Development Guidelines 465 | 466 | - Follow TypeScript best practices 467 | - Add JSDoc comments for public APIs 468 | - Update README for new features 469 | - Test OAuth flows thoroughly 470 | - Ensure database migrations are safe 471 | 472 | ## 📄 License 473 | 474 | MIT License - see [LICENSE](LICENSE) file for details. 475 | 476 | ## 🙏 Acknowledgments 477 | 478 | - [Model Context Protocol](https://modelcontextprotocol.io/) - The protocol this server implements 479 | - [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform 480 | - [Hono](https://hono.dev/) - Web framework for Cloudflare Workers 481 | - [@node-oauth/oauth2-server](https://github.com/node-oauth/node-oauth2-server) - OAuth2 implementation 482 | 483 | --- 484 | 485 | **Need help?** Open an issue on GitHub or check the troubleshooting section above. 486 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remote-mcp-custom-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 | "dependencies": { 13 | "@modelcontextprotocol/sdk": "^1.12.0", 14 | "@node-oauth/oauth2-server": "^5.2.0", 15 | "agents": "^0.0.93", 16 | "bcryptjs": "^2.4.3", 17 | "hono": "^4.7.10", 18 | "jsonwebtoken": "^9.0.2", 19 | "just-pick": "^4.2.0", 20 | "pg": "^8.12.0", 21 | "workers-mcp": "^0.0.13", 22 | "zod": "^3.25.28" 23 | }, 24 | "devDependencies": { 25 | "@types/bcryptjs": "^2.4.6", 26 | "@types/jsonwebtoken": "^9.0.7", 27 | "@types/node": "^22.15.22", 28 | "@types/pg": "^8.11.10", 29 | "prettier": "^3.5.3", 30 | "typescript": "^5.8.3", 31 | "wrangler": "^4.17.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/auth/handlers/auth.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { setCookie, deleteCookie } from "hono/cookie"; 3 | import { sign } from "hono/jwt"; 4 | import { CreateUserData, DatabaseService, LoginData } from "../../database"; 5 | import { loginPageHTML, registerPageHTML } from "../views"; 6 | import { getCurrentUser } from "../middleware/session"; 7 | import { getDatabaseConnectionString } from '../../database'; 8 | 9 | const authApp = new Hono<{ Bindings: Env }>(); 10 | 11 | // Helper function to create session token 12 | async function createSessionToken(user: any, jwtSecret: string): Promise { 13 | const payload = { 14 | userId: user.id, 15 | username: user.username, 16 | email: user.email, 17 | name: user.name, 18 | iat: Math.floor(Date.now() / 1000), 19 | exp: Math.floor(Date.now() / 1000) + (30 * 24 * 60 * 60) // 30 days 20 | }; 21 | 22 | return await sign(payload, jwtSecret); 23 | } 24 | 25 | // Login page 26 | authApp.get("/login", async (c) => { 27 | return c.html(loginPageHTML); 28 | }); 29 | 30 | // Register page 31 | authApp.get("/register", async (c) => { 32 | return c.html(registerPageHTML); 33 | }); 34 | 35 | // Login endpoint 36 | authApp.post("/login", async (c) => { 37 | try { 38 | const body = await c.req.json() as LoginData & { state?: string }; 39 | const { username, password, state } = body; 40 | 41 | if (!username || !password) { 42 | return c.json({ message: "Username and password are required" }, 400); 43 | } 44 | 45 | const db = new DatabaseService(getDatabaseConnectionString(c.env)); 46 | await db.connect(); 47 | 48 | try { 49 | // Get user 50 | const user = await db.getUserByUsername(username); 51 | if (!user) { 52 | return c.json({ message: "Invalid username or password" }, 401); 53 | } 54 | 55 | // Verify password 56 | const isValidPassword = await db.verifyPassword(password, user.password_hash); 57 | if (!isValidPassword) { 58 | return c.json({ message: "Invalid username or password" }, 401); 59 | } 60 | 61 | // Handle OAuth flow if state is provided 62 | if (state) { 63 | let oauthReqInfo; 64 | try { 65 | oauthReqInfo = JSON.parse(atob(state)) as any; 66 | } catch (error) { 67 | return c.json({ message: "Invalid state parameter" }, 400); 68 | } 69 | 70 | if (!oauthReqInfo.clientId) { 71 | return c.json({ message: "Invalid state - missing client ID" }, 400); 72 | } 73 | 74 | // Create session token 75 | const sessionToken = await createSessionToken(user, c.env.JWT_SECRET); 76 | 77 | // Set secure session cookie 78 | setCookie(c, 'session', sessionToken, { 79 | httpOnly: true, 80 | secure: true, 81 | sameSite: 'Lax', 82 | maxAge: 30 * 24 * 60 * 60, // 30 days 83 | path: '/' 84 | }); 85 | 86 | // Redirect back to OAuth authorization endpoint to show consent screen 87 | const authUrl = new URL("/oauth/authorize", c.req.url); 88 | authUrl.searchParams.set('client_id', oauthReqInfo.clientId); 89 | authUrl.searchParams.set('redirect_uri', oauthReqInfo.redirectUri); 90 | authUrl.searchParams.set('response_type', oauthReqInfo.responseType); 91 | if (oauthReqInfo.scope && oauthReqInfo.scope.length > 0) { 92 | authUrl.searchParams.set('scope', Array.isArray(oauthReqInfo.scope) ? oauthReqInfo.scope.join(' ') : oauthReqInfo.scope); 93 | } 94 | if (oauthReqInfo.state) { 95 | authUrl.searchParams.set('state', oauthReqInfo.state); 96 | } 97 | if (oauthReqInfo.codeChallenge) { 98 | authUrl.searchParams.set('code_challenge', oauthReqInfo.codeChallenge); 99 | } 100 | if (oauthReqInfo.codeChallengeMethod) { 101 | authUrl.searchParams.set('code_challenge_method', oauthReqInfo.codeChallengeMethod); 102 | } 103 | 104 | return c.json({ redirectTo: authUrl.href }); 105 | } else { 106 | // Direct login without OAuth - create session and set cookie 107 | try { 108 | // Create session token 109 | const sessionToken = await createSessionToken(user, c.env.JWT_SECRET); 110 | 111 | // Set secure session cookie 112 | setCookie(c, 'session', sessionToken, { 113 | httpOnly: true, 114 | secure: true, 115 | sameSite: 'Lax', 116 | maxAge: 30 * 24 * 60 * 60, // 30 days 117 | path: '/' 118 | }); 119 | 120 | return c.json({ 121 | redirectTo: "/", // Redirect to home page 122 | message: "Login successful", 123 | user: { 124 | id: user.id, 125 | username: user.username, 126 | email: user.email, 127 | name: user.name 128 | } 129 | }); 130 | } catch (error) { 131 | console.error("Session token creation error:", error); 132 | return c.json({ message: "Failed to create session" }, 500); 133 | } 134 | } 135 | } finally { 136 | await db.disconnect(); 137 | } 138 | } catch (error) { 139 | console.error("Login error:", error); 140 | return c.json({ message: "Internal server error" }, 500); 141 | } 142 | }); 143 | 144 | // Register endpoint 145 | authApp.post("/register", async (c) => { 146 | try { 147 | const body = await c.req.json() as CreateUserData; 148 | const { username, email, name, password } = body; 149 | 150 | if (!username || !email || !name || !password) { 151 | return c.json({ message: "All fields are required" }, 400); 152 | } 153 | 154 | if (password.length < 6) { 155 | return c.json({ message: "Password must be at least 6 characters long" }, 400); 156 | } 157 | 158 | const db = new DatabaseService(getDatabaseConnectionString(c.env)); 159 | await db.connect(); 160 | 161 | try { 162 | // Check if user already exists 163 | const existingUserByUsername = await db.getUserByUsername(username); 164 | if (existingUserByUsername) { 165 | return c.json({ message: "Username already exists" }, 400); 166 | } 167 | 168 | const existingUserByEmail = await db.getUserByEmail(email); 169 | if (existingUserByEmail) { 170 | return c.json({ message: "Email already exists" }, 400); 171 | } 172 | 173 | // Create user 174 | await db.createUser({ username, email, name, password }); 175 | 176 | return c.json({ message: "User created successfully" }); 177 | } finally { 178 | await db.disconnect(); 179 | } 180 | } catch (error) { 181 | console.error("Registration error:", error); 182 | return c.json({ message: "Internal server error" }, 500); 183 | } 184 | }); 185 | 186 | // Initialize database endpoint (for setup) 187 | authApp.post("/init-db", async (c) => { 188 | try { 189 | const db = new DatabaseService(getDatabaseConnectionString(c.env)); 190 | await db.connect(); 191 | 192 | try { 193 | await db.initializeDatabase(); 194 | return c.json({ message: "Database initialized successfully" }); 195 | } finally { 196 | await db.disconnect(); 197 | } 198 | } catch (error) { 199 | console.error("Database initialization error:", error); 200 | return c.json({ message: "Failed to initialize database" }, 500); 201 | } 202 | }); 203 | 204 | // Logout endpoint 205 | authApp.post("/logout", async (c) => { 206 | try { 207 | // Clear session cookie 208 | deleteCookie(c, 'session', { 209 | path: '/' 210 | }); 211 | 212 | return c.json({ message: "Logged out successfully" }); 213 | } catch (error) { 214 | console.error("Logout error:", error); 215 | return c.json({ message: "Logout failed" }, 500); 216 | } 217 | }); 218 | 219 | export { authApp }; 220 | -------------------------------------------------------------------------------- /src/auth/handlers/index.ts: -------------------------------------------------------------------------------- 1 | export { authApp } from "./auth"; 2 | export { oauthApp } from "./oauth"; -------------------------------------------------------------------------------- /src/auth/handlers/oauth.ts: -------------------------------------------------------------------------------- 1 | import OAuth2Server from '@node-oauth/oauth2-server'; 2 | import { Hono } from "hono"; 3 | import { DatabaseService } from "../../database"; 4 | import { createOAuth2Server } from "../oauth2"; 5 | import { generateConsentPage } from "../views/consent"; 6 | import { getCurrentUser } from "../middleware/session"; 7 | import { OAuth2Model } from "../oauth2"; 8 | import { sessionMiddleware } from "../middleware/session"; 9 | import { getDatabaseConnectionString } from '../../database'; 10 | 11 | // Helper function to redirect to login with OAuth state 12 | function redirectToLogin(request: Request, oauthReqInfo: any, headers: Record = {}) { 13 | const loginUrl = new URL("/login", request.url); 14 | loginUrl.searchParams.set("state", btoa(JSON.stringify(oauthReqInfo))); 15 | 16 | return new Response(null, { 17 | status: 302, 18 | headers: { 19 | ...headers, 20 | location: loginUrl.href, 21 | }, 22 | }); 23 | } 24 | 25 | // Helper function to convert OAuth2Server response to Hono response 26 | function oauth2ToHonoResponse(response: OAuth2Server.Response): Response { 27 | const headers: Record = {}; 28 | 29 | // Copy headers from OAuth2 response 30 | if (response.headers) { 31 | for (const [key, value] of Object.entries(response.headers)) { 32 | headers[key] = Array.isArray(value) ? value[0] : value; 33 | } 34 | } 35 | 36 | return new Response(response.body, { 37 | status: response.status || 200, 38 | headers 39 | }); 40 | } 41 | 42 | const oauthApp = new Hono<{ Bindings: Env }>(); 43 | 44 | // Apply session middleware to OAuth routes that need user context 45 | oauthApp.use('/oauth/authorize', sessionMiddleware); 46 | oauthApp.use('/oauth/consent', sessionMiddleware); 47 | 48 | // OAuth Authorization endpoint 49 | oauthApp.get("/oauth/authorize", async (c) => { 50 | const url = new URL(c.req.url); 51 | const clientId = url.searchParams.get('client_id'); 52 | const redirectUri = url.searchParams.get('redirect_uri'); 53 | const responseType = url.searchParams.get('response_type'); 54 | const scope = url.searchParams.get('scope'); 55 | const state = url.searchParams.get('state'); 56 | const codeChallenge = url.searchParams.get('code_challenge'); 57 | const codeChallengeMethod = url.searchParams.get('code_challenge_method'); 58 | 59 | if (!clientId || !redirectUri || responseType !== 'code') { 60 | return c.text("Invalid request", 400); 61 | } 62 | 63 | // Look up client in our database 64 | const db = new DatabaseService(getDatabaseConnectionString(c.env)); 65 | await db.connect(); 66 | 67 | try { 68 | const application = await db.getOAuthApplicationByClientId(clientId); 69 | if (!application) { 70 | return c.text("Invalid client_id", 400); 71 | } 72 | 73 | // Check if redirect URI is valid 74 | const validRedirectUris = application.redirect_uri.split('\n'); 75 | if (!validRedirectUris.includes(redirectUri)) { 76 | return c.text("Invalid redirect_uri", 400); 77 | } 78 | 79 | // Store OAuth request info 80 | const oauthReqInfo = { 81 | responseType, 82 | clientId, 83 | redirectUri, 84 | scope: scope ? scope.split(' ') : ['read'], 85 | state: state || '', 86 | codeChallenge, 87 | codeChallengeMethod 88 | }; 89 | 90 | // Check if user is already logged in 91 | const user = getCurrentUser(c); 92 | 93 | if (user) { 94 | // User is logged in, show consent page 95 | const consentPageData = { 96 | application: { 97 | name: application.name, 98 | client_uri: application.client_uri, 99 | logo_uri: application.logo_uri, 100 | tos_uri: application.tos_uri, 101 | policy_uri: application.policy_uri 102 | }, 103 | scopes: oauthReqInfo.scope, 104 | user: { 105 | name: user.name, 106 | username: user.username, 107 | email: user.email 108 | }, 109 | oauthState: btoa(JSON.stringify(oauthReqInfo)) 110 | }; 111 | 112 | const html = generateConsentPage(consentPageData); 113 | return c.html(html); 114 | } else { 115 | // User not logged in, redirect to login with OAuth info 116 | return redirectToLogin(c.req.raw, oauthReqInfo); 117 | } 118 | } finally { 119 | await db.disconnect(); 120 | } 121 | }); 122 | 123 | // OAuth Consent endpoint - handles consent form submission 124 | oauthApp.post("/oauth/consent", async (c) => { 125 | try { 126 | const body = await c.req.json(); 127 | const { action, state } = body; 128 | 129 | if (!state) { 130 | return c.json({ message: "Missing state parameter" }, 400); 131 | } 132 | 133 | // Decode OAuth request info from state 134 | let oauthReqInfo; 135 | try { 136 | oauthReqInfo = JSON.parse(atob(state)); 137 | } catch (error) { 138 | return c.json({ message: "Invalid state parameter" }, 400); 139 | } 140 | 141 | // Get current user 142 | const user = getCurrentUser(c); 143 | if (!user) { 144 | return c.json({ message: "User not authenticated" }, 401); 145 | } 146 | 147 | if (action === 'deny') { 148 | // User denied consent, redirect back with error 149 | const redirectUrl = new URL(oauthReqInfo.redirectUri); 150 | redirectUrl.searchParams.set('code', '0'); 151 | if (oauthReqInfo.state) { 152 | redirectUrl.searchParams.set('state', oauthReqInfo.state); 153 | } 154 | 155 | return c.json({ redirectTo: redirectUrl.href }); 156 | } 157 | 158 | if (action === 'approve') { 159 | // User approved consent, create authorization code 160 | const db = new DatabaseService(getDatabaseConnectionString(c.env)); 161 | await db.connect(); 162 | 163 | try { 164 | // Verify client exists 165 | const application = await db.getOAuthApplicationByClientId(oauthReqInfo.clientId); 166 | if (!application) { 167 | return c.json({ message: "Invalid client" }, 400); 168 | } 169 | 170 | // Create OAuth2 model instance 171 | const oauth2Model = new OAuth2Model(getDatabaseConnectionString(c.env)); 172 | 173 | // Create authorization code using OAuth2 model 174 | const authCode = oauth2Model.generateAuthorizationCode( 175 | { id: oauthReqInfo.clientId }, 176 | { 177 | id: user.userId, 178 | username: user.username, 179 | email: user.email, 180 | name: user.name 181 | }, 182 | oauthReqInfo.scope || ['read'] 183 | ); 184 | 185 | // Use OAuth2 model to save the authorization code properly 186 | const codeData = { 187 | authorizationCode: authCode, 188 | expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes 189 | redirectUri: oauthReqInfo.redirectUri, 190 | scope: Array.isArray(oauthReqInfo.scope) ? oauthReqInfo.scope : (oauthReqInfo.scope ? [oauthReqInfo.scope] : ['read']), 191 | codeChallenge: oauthReqInfo.codeChallenge, 192 | codeChallengeMethod: oauthReqInfo.codeChallengeMethod 193 | }; 194 | 195 | const clientData = { 196 | id: oauthReqInfo.clientId, 197 | redirectUris: application.redirect_uri.split('\n'), 198 | grants: ['authorization_code', 'refresh_token'], 199 | scope: application.scopes 200 | }; 201 | 202 | const userData = { 203 | id: user.userId, 204 | username: user.username, 205 | email: user.email, 206 | name: user.name 207 | }; 208 | 209 | // Save authorization code using OAuth2 model 210 | await oauth2Model.saveAuthorizationCode(codeData, clientData, userData); 211 | 212 | // Build redirect URL with authorization code 213 | const redirectUrl = new URL(oauthReqInfo.redirectUri); 214 | redirectUrl.searchParams.set('code', authCode); 215 | if (oauthReqInfo.state) { 216 | redirectUrl.searchParams.set('state', oauthReqInfo.state); 217 | } 218 | 219 | return c.json({ redirectTo: redirectUrl.href }); 220 | } finally { 221 | await db.disconnect(); 222 | } 223 | } 224 | 225 | return c.json({ message: "Invalid action" }, 400); 226 | } catch (error) { 227 | console.error("OAuth consent error:", error); 228 | return c.json({ message: "Internal server error" }, 500); 229 | } 230 | }); 231 | 232 | // OAuth Authorization POST endpoint - handles user consent after login 233 | oauthApp.post("/oauth/authorize", async (c) => { 234 | try { 235 | const oauth2Server = createOAuth2Server(getDatabaseConnectionString(c.env)); 236 | 237 | // Get form data 238 | const formData = await c.req.formData(); 239 | const body: Record = {}; 240 | 241 | for (const [key, value] of formData.entries()) { 242 | body[key] = value; 243 | } 244 | 245 | // Create OAuth2 request object 246 | const request = new OAuth2Server.Request({ 247 | body: body, 248 | headers: Object.fromEntries( 249 | Object.entries(c.req.header()).map(([k, v]) => [k.toLowerCase(), Array.isArray(v) ? v[0] : v]) 250 | ), 251 | method: c.req.method, 252 | query: Object.fromEntries(new URL(c.req.url).searchParams.entries()) 253 | }); 254 | 255 | // Create OAuth2 response object 256 | const response = new OAuth2Server.Response(); 257 | 258 | try { 259 | // Handle authorization request 260 | const code = await oauth2Server.authorize(request, response, { 261 | authenticateHandler: { 262 | handle: async (request: OAuth2Server.Request) => { 263 | // This should be called after user authentication 264 | // For now, we'll extract user info from the request 265 | const userId = request.body.user_id; 266 | if (!userId) return null; 267 | 268 | const db = new DatabaseService(getDatabaseConnectionString(c.env)); 269 | await db.connect(); 270 | try { 271 | const user = await db.getUserById(userId); 272 | return user ? { 273 | id: user.id, 274 | username: user.username, 275 | email: user.email, 276 | name: user.name 277 | } : null; 278 | } finally { 279 | await db.disconnect(); 280 | } 281 | } 282 | } 283 | }); 284 | 285 | return oauth2ToHonoResponse(response); 286 | } catch (error) { 287 | console.error('OAuth authorization error:', error); 288 | return oauth2ToHonoResponse(response); 289 | } 290 | } catch (error) { 291 | console.error('OAuth authorization endpoint error:', error); 292 | return c.json({ 293 | error: "server_error", 294 | error_description: "Internal server error" 295 | }, 500); 296 | } 297 | }); 298 | 299 | // OAuth Client Registration endpoint (RFC 7591) 300 | oauthApp.post("/oauth/applications", async (c) => { 301 | try { 302 | const body = await c.req.json(); 303 | const { 304 | client_name, 305 | redirect_uris, 306 | client_uri, 307 | logo_uri, 308 | tos_uri, 309 | policy_uri, 310 | contacts, 311 | scope 312 | } = body; 313 | 314 | // Validate required fields according to RFC 7591 315 | if (!client_name || !redirect_uris || !Array.isArray(redirect_uris) || redirect_uris.length === 0) { 316 | return c.json({ 317 | error: "invalid_client_metadata", 318 | error_description: "client_name and redirect_uris are required" 319 | }, 400); 320 | } 321 | 322 | // Validate redirect URIs 323 | for (const uri of redirect_uris) { 324 | try { 325 | const url = new URL(uri); 326 | // Only allow HTTPS URIs (except for localhost in development) 327 | if (url.protocol !== 'https:' && !url.hostname.includes('localhost') && !url.hostname.includes('127.0.0.1')) { 328 | return c.json({ 329 | error: "invalid_redirect_uri", 330 | error_description: "Only HTTPS redirect URIs are allowed (except localhost)" 331 | }, 400); 332 | } 333 | } catch { 334 | return c.json({ 335 | error: "invalid_redirect_uri", 336 | error_description: `Invalid redirect URI: ${uri}` 337 | }, 400); 338 | } 339 | } 340 | 341 | const db = new DatabaseService(getDatabaseConnectionString(c.env)); 342 | await db.connect(); 343 | 344 | try { 345 | // Create or update OAuth application in our database 346 | const application = await db.findOrCreateOAuthApplication(client_name, { 347 | client_name, 348 | redirect_uris, 349 | client_uri, 350 | logo_uri, 351 | tos_uri, 352 | policy_uri, 353 | contacts, 354 | scope 355 | }); 356 | 357 | // Return client credentials in RFC 7591 format 358 | return c.json({ 359 | client_id: application.uid, 360 | client_secret: application.secret, 361 | client_name: application.name, 362 | redirect_uris: application.redirect_uri.split('\n'), 363 | grant_types: ["authorization_code", "refresh_token"], 364 | response_types: ["code"], 365 | scope: application.scopes, 366 | client_id_issued_at: Math.floor(new Date(application.created_at).getTime() / 1000), 367 | client_secret_expires_at: 0 // Never expires 368 | }, 201); 369 | 370 | } finally { 371 | await db.disconnect(); 372 | } 373 | 374 | } catch (error) { 375 | console.error("OAuth client registration error:", error); 376 | return c.json({ 377 | error: "server_error", 378 | error_description: "Internal server error during client registration" 379 | }, 500); 380 | } 381 | }); 382 | 383 | // List OAuth applications endpoint 384 | oauthApp.get("/oauth/applications", async (c) => { 385 | try { 386 | const db = new DatabaseService(getDatabaseConnectionString(c.env)); 387 | await db.connect(); 388 | 389 | try { 390 | const applications = await db.listOAuthApplications(); 391 | 392 | // Return applications without exposing client secrets 393 | const safeApplications = applications.map(app => ({ 394 | client_id: app.uid, 395 | client_name: app.name, 396 | redirect_uris: app.redirect_uri.split('\n'), 397 | scopes: app.scopes, 398 | client_uri: app.client_uri, 399 | logo_uri: app.logo_uri, 400 | created_at: app.created_at, 401 | updated_at: app.updated_at 402 | })); 403 | 404 | return c.json(safeApplications); 405 | } finally { 406 | await db.disconnect(); 407 | } 408 | } catch (error) { 409 | console.error("OAuth applications listing error:", error); 410 | return c.json({ 411 | error: "server_error", 412 | error_description: "Internal server error while listing applications" 413 | }, 500); 414 | } 415 | }); 416 | 417 | // OAuth Token endpoint 418 | oauthApp.post("/oauth/token", async (c) => { 419 | try { 420 | const oauth2Server = createOAuth2Server(getDatabaseConnectionString(c.env)); 421 | 422 | // Get form data 423 | const formData = await c.req.formData(); 424 | const body: Record = {}; 425 | 426 | for (const [key, value] of formData.entries()) { 427 | body[key] = value; 428 | } 429 | 430 | // Create OAuth2 request object 431 | const request = new OAuth2Server.Request({ 432 | body: body, 433 | headers: Object.fromEntries( 434 | Object.entries(c.req.header()).map(([k, v]) => [k.toLowerCase(), Array.isArray(v) ? v[0] : v]) 435 | ), 436 | method: c.req.method, 437 | query: Object.fromEntries(new URL(c.req.url).searchParams.entries()) 438 | }); 439 | 440 | // Create OAuth2 response object 441 | const response = new OAuth2Server.Response(); 442 | 443 | try { 444 | // Handle token request 445 | const token = await oauth2Server.token(request, response); 446 | 447 | // Return token response 448 | return c.json({ 449 | access_token: token.accessToken, 450 | refresh_token: token.refreshToken, 451 | token_type: 'Bearer', 452 | expires_in: token.accessTokenExpiresAt ? Math.floor((token.accessTokenExpiresAt.getTime() - Date.now()) / 1000) : 3600, 453 | scope: Array.isArray(token.scope) ? token.scope.join(' ') : token.scope 454 | }); 455 | } catch (error) { 456 | console.error('OAuth token error:', error); 457 | 458 | // Handle OAuth2 errors 459 | if (error instanceof OAuth2Server.OAuthError) { 460 | return c.json({ 461 | error: error.name, 462 | error_description: error.message 463 | }, 400); 464 | } 465 | 466 | return c.json({ 467 | error: "server_error", 468 | error_description: "Internal server error" 469 | }, 500); 470 | } 471 | } catch (error) { 472 | console.error("OAuth token endpoint error:", error); 473 | return c.json({ 474 | error: "server_error", 475 | error_description: "Internal server error" 476 | }, 500); 477 | } 478 | }); 479 | 480 | // OAuth Token Revocation endpoint (RFC 7009) 481 | oauthApp.post("/oauth/revoke", async (c) => { 482 | try { 483 | // Get form data 484 | const formData = await c.req.formData(); 485 | const body: Record = {}; 486 | 487 | for (const [key, value] of formData.entries()) { 488 | body[key] = value; 489 | } 490 | 491 | const token = body.token as string; 492 | if (!token) { 493 | return c.json({ 494 | error: "invalid_request", 495 | error_description: "Missing token parameter" 496 | }, 400); 497 | } 498 | 499 | // Use our database service directly for token revocation 500 | const db = new DatabaseService(getDatabaseConnectionString(c.env)); 501 | await db.connect(); 502 | 503 | try { 504 | // Try to find as access token first 505 | const accessToken = await db.findOAuthAccessTokenByToken(token); 506 | if (accessToken) { 507 | await db.revokeOAuthAccessToken(accessToken.id); 508 | return new Response(null, { status: 200 }); 509 | } 510 | 511 | // Try to find as refresh token 512 | const refreshToken = await db.findOAuthAccessTokenByRefreshToken(token); 513 | if (refreshToken) { 514 | await db.revokeOAuthAccessToken(refreshToken.id); 515 | return new Response(null, { status: 200 }); 516 | } 517 | 518 | // RFC 7009 specifies that revocation should return 200 even if token was invalid 519 | return new Response(null, { status: 200 }); 520 | } finally { 521 | await db.disconnect(); 522 | } 523 | } catch (error) { 524 | console.error("OAuth revocation endpoint error:", error); 525 | return c.json({ 526 | error: "server_error", 527 | error_description: "Internal server error" 528 | }, 500); 529 | } 530 | }); 531 | 532 | // OAuth Token Introspection endpoint (RFC 7662) 533 | oauthApp.post("/oauth/introspect", async (c) => { 534 | try { 535 | // Get form data 536 | const formData = await c.req.formData(); 537 | const body: Record = {}; 538 | 539 | for (const [key, value] of formData.entries()) { 540 | body[key] = value; 541 | } 542 | 543 | const token = body.token as string; 544 | if (!token) { 545 | return c.json({ active: false }); 546 | } 547 | 548 | try { 549 | // Try to get access token info 550 | const db = new DatabaseService(getDatabaseConnectionString(c.env)); 551 | await db.connect(); 552 | 553 | try { 554 | const accessToken = await db.findOAuthAccessTokenByToken(token); 555 | if (!accessToken) { 556 | return c.json({ active: false }); 557 | } 558 | 559 | const application = await db.getOAuthApplicationById(accessToken.application_id); 560 | const user = await db.getUserById(accessToken.user_id); 561 | 562 | if (!application || !user) { 563 | return c.json({ active: false }); 564 | } 565 | 566 | return c.json({ 567 | active: true, 568 | scope: accessToken.scopes, 569 | client_id: application.uid, 570 | username: user.username, 571 | exp: Math.floor(accessToken.expires_at.getTime() / 1000), 572 | iat: Math.floor(new Date(accessToken.created_at).getTime() / 1000), 573 | sub: user.id, 574 | aud: application.uid 575 | }); 576 | } finally { 577 | await db.disconnect(); 578 | } 579 | } catch (error) { 580 | console.error("Token introspection error:", error); 581 | return c.json({ active: false }); 582 | } 583 | } catch (error) { 584 | console.error("OAuth introspection endpoint error:", error); 585 | return c.json({ 586 | error: "server_error", 587 | error_description: "Internal server error" 588 | }, 500); 589 | } 590 | }); 591 | 592 | // OAuth 2.0 Authorization Server Metadata (RFC 8414) 593 | oauthApp.all("/.well-known/oauth-authorization-server", async (c) => { 594 | const origin = new URL(c.req.url).origin; 595 | const baseUrl = origin.includes('localhost') ? origin : origin.replace("http://", "https://"); 596 | 597 | return c.json({ 598 | issuer: baseUrl, 599 | authorization_endpoint: `${baseUrl}/oauth/authorize`, 600 | token_endpoint: `${baseUrl}/oauth/token`, 601 | registration_endpoint: `${baseUrl}/oauth/applications`, 602 | revocation_endpoint: `${baseUrl}/oauth/revoke`, 603 | response_types_supported: ["code"], 604 | response_modes_supported: ["query"], 605 | grant_types_supported: ["authorization_code", "refresh_token", "none"], 606 | token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"], 607 | code_challenge_methods_supported: ["plain", "S256"], 608 | }); 609 | }); 610 | 611 | // OAuth 2.0 Protected Resource Metadata (RFC 8707) 612 | oauthApp.all("/.well-known/oauth-protected-resource", async (c) => { 613 | const origin = new URL(c.req.url).origin; 614 | const baseUrl = origin.includes('localhost') ? origin : origin.replace("http://", "https://"); 615 | 616 | return c.json({ 617 | resource: baseUrl, 618 | authorization_servers: [baseUrl], 619 | protected_resources: [ 620 | { 621 | resource_uri: `${baseUrl}/mcp`, 622 | scopes: ["read", "write"], 623 | description: "Model Context Protocol endpoint for AI tool access" 624 | } 625 | ] 626 | }); 627 | }); 628 | 629 | export { oauthApp }; 630 | -------------------------------------------------------------------------------- /src/auth/handlers/token.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseService } from "../../database"; 2 | import crypto from 'crypto'; 3 | 4 | export class OAuthTokenHandler { 5 | private db: DatabaseService; 6 | 7 | constructor(databaseUrl: string) { 8 | this.db = new DatabaseService(databaseUrl); 9 | } 10 | 11 | // Generate a secure random token 12 | private generateToken(): string { 13 | return crypto.randomBytes(32).toString('hex'); 14 | } 15 | 16 | // Create authorization code 17 | async createAuthorizationCode(data: { 18 | clientId: string; 19 | userId: string; 20 | redirectUri: string; 21 | scopes: string[]; 22 | codeChallenge?: string; 23 | codeChallengeMethod?: string; 24 | }): Promise { 25 | await this.db.connect(); 26 | 27 | try { 28 | // Get application by client_id 29 | const application = await this.db.getOAuthApplicationByClientId(data.clientId); 30 | if (!application) { 31 | throw new Error('Invalid client_id'); 32 | } 33 | 34 | const authCode = this.generateToken(); 35 | const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes 36 | 37 | await this.db.createOAuthGrant({ 38 | applicationId: application.id, 39 | userId: data.userId, 40 | token: authCode, 41 | tokenType: 'authorization_code', 42 | redirectUri: data.redirectUri, 43 | scopes: data.scopes.join(' '), 44 | codeChallenge: data.codeChallenge, 45 | codeChallengeMethod: data.codeChallengeMethod, 46 | expiresAt 47 | }); 48 | 49 | return authCode; 50 | } finally { 51 | await this.db.disconnect(); 52 | } 53 | } 54 | 55 | // Exchange authorization code for access token 56 | async exchangeAuthorizationCode(data: { 57 | code: string; 58 | clientId: string; 59 | clientSecret: string; 60 | redirectUri: string; 61 | codeVerifier?: string; 62 | }): Promise<{ 63 | access_token: string; 64 | refresh_token: string; 65 | token_type: string; 66 | expires_in: number; 67 | scope: string; 68 | }> { 69 | await this.db.connect(); 70 | 71 | try { 72 | // Verify client credentials 73 | const application = await this.db.getOAuthApplicationByClientId(data.clientId); 74 | if (!application || application.secret !== data.clientSecret) { 75 | throw new Error('Invalid client credentials'); 76 | } 77 | 78 | // Find and verify authorization code 79 | const grant = await this.db.findOAuthAccessGrantByToken(data.code); 80 | 81 | if (!grant) { 82 | throw new Error('Invalid authorization code'); 83 | } 84 | 85 | if (grant.application_id !== application.id) { 86 | throw new Error('Invalid authorization code'); 87 | } 88 | 89 | if (grant.redirect_uri !== data.redirectUri) { 90 | throw new Error('Invalid authorization code'); 91 | } 92 | 93 | // Verify PKCE if present 94 | if (grant.code_challenge && grant.code_challenge_method) { 95 | if (!data.codeVerifier) { 96 | throw new Error('Code verifier required'); 97 | } 98 | 99 | let challenge = data.codeVerifier; 100 | if (grant.code_challenge_method === 'S256') { 101 | challenge = crypto.createHash('sha256').update(data.codeVerifier).digest('base64url'); 102 | } 103 | 104 | if (challenge !== grant.code_challenge) { 105 | throw new Error('Invalid code verifier'); 106 | } 107 | } 108 | 109 | // Generate tokens 110 | const accessToken = this.generateToken(); 111 | const refreshToken = this.generateToken(); 112 | const expiresAt = new Date(Date.now() + 3600 * 1000); // 1 hour 113 | 114 | // Create access token record 115 | await this.db.createOAuthAccessToken({ 116 | applicationId: application.id, 117 | userId: grant.user_id, 118 | grantId: grant.id, 119 | token: accessToken, 120 | refreshToken: refreshToken, 121 | scopes: grant.scopes, 122 | expiresAt 123 | }); 124 | 125 | // Revoke the authorization code (one-time use) 126 | await this.db.revokeOAuthGrant(grant.id); 127 | 128 | return { 129 | access_token: accessToken, 130 | refresh_token: refreshToken, 131 | token_type: 'Bearer', 132 | expires_in: 3600, 133 | scope: grant.scopes 134 | }; 135 | } finally { 136 | await this.db.disconnect(); 137 | } 138 | } 139 | 140 | // Refresh access token 141 | async refreshAccessToken(data: { 142 | refreshToken: string; 143 | clientId: string; 144 | clientSecret: string; 145 | }): Promise<{ 146 | access_token: string; 147 | refresh_token: string; 148 | token_type: string; 149 | expires_in: number; 150 | scope: string; 151 | }> { 152 | await this.db.connect(); 153 | 154 | try { 155 | // Verify client credentials 156 | const application = await this.db.getOAuthApplicationByClientId(data.clientId); 157 | if (!application || application.secret !== data.clientSecret) { 158 | throw new Error('Invalid client credentials'); 159 | } 160 | 161 | // Find access token by refresh token 162 | const accessToken = await this.db.findOAuthAccessTokenByRefreshToken(data.refreshToken); 163 | if (!accessToken || accessToken.application_id !== application.id) { 164 | throw new Error('Invalid refresh token'); 165 | } 166 | 167 | // Generate new tokens 168 | const newAccessToken = this.generateToken(); 169 | const newRefreshToken = this.generateToken(); 170 | const expiresAt = new Date(Date.now() + 3600 * 1000); // 1 hour 171 | 172 | // Revoke old access token 173 | await this.db.revokeOAuthAccessToken(accessToken.id); 174 | 175 | // Create new access token record 176 | await this.db.createOAuthAccessToken({ 177 | applicationId: application.id, 178 | userId: accessToken.user_id, 179 | grantId: accessToken.grant_id, 180 | token: newAccessToken, 181 | refreshToken: newRefreshToken, 182 | scopes: accessToken.scopes, 183 | expiresAt 184 | }); 185 | 186 | return { 187 | access_token: newAccessToken, 188 | refresh_token: newRefreshToken, 189 | token_type: 'Bearer', 190 | expires_in: 3600, 191 | scope: accessToken.scopes 192 | }; 193 | } finally { 194 | await this.db.disconnect(); 195 | } 196 | } 197 | 198 | // Verify access token 199 | async verifyAccessToken(token: string): Promise<{ 200 | userId: string; 201 | clientId: string; 202 | scopes: string[]; 203 | } | null> { 204 | await this.db.connect(); 205 | 206 | try { 207 | const accessToken = await this.db.findOAuthAccessTokenByToken(token); 208 | if (!accessToken) { 209 | return null; 210 | } 211 | 212 | // Get application to return client_id 213 | const application = await this.db.getOAuthApplicationById(accessToken.application_id); 214 | if (!application) { 215 | return null; 216 | } 217 | 218 | return { 219 | userId: accessToken.user_id, 220 | clientId: application.uid, 221 | scopes: accessToken.scopes.split(' ') 222 | }; 223 | } finally { 224 | await this.db.disconnect(); 225 | } 226 | } 227 | 228 | // Revoke token 229 | async revokeToken(token: string): Promise { 230 | await this.db.connect(); 231 | 232 | try { 233 | // Try to find as access token first 234 | const accessToken = await this.db.findOAuthAccessTokenByToken(token); 235 | if (accessToken) { 236 | return await this.db.revokeOAuthAccessToken(accessToken.id); 237 | } 238 | 239 | // Try to find as refresh token 240 | const refreshToken = await this.db.findOAuthAccessTokenByRefreshToken(token); 241 | if (refreshToken) { 242 | return await this.db.revokeOAuthAccessToken(refreshToken.id); 243 | } 244 | 245 | return false; 246 | } finally { 247 | await this.db.disconnect(); 248 | } 249 | } 250 | } -------------------------------------------------------------------------------- /src/auth/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { authApp } from "./handlers/auth"; 3 | import { oauthApp } from "./handlers/oauth"; 4 | 5 | const app = new Hono<{ Bindings: Env }>(); 6 | 7 | // Mount auth routes 8 | app.route("/", authApp); 9 | 10 | // Mount OAuth routes 11 | app.route("/", oauthApp); 12 | 13 | export default app; -------------------------------------------------------------------------------- /src/auth/middleware/session.ts: -------------------------------------------------------------------------------- 1 | import { getCookie } from "hono/cookie"; 2 | import { verify } from "hono/jwt"; 3 | import { Context, Next } from "hono"; 4 | import { createOAuth2Server } from "../oauth2"; 5 | import OAuth2Server from '@node-oauth/oauth2-server'; 6 | import { DatabaseService } from '../../database'; 7 | import { getDatabaseConnectionString } from '../../database'; 8 | 9 | export interface SessionUser { 10 | userId: string; 11 | username: string; 12 | email: string; 13 | name: string; 14 | } 15 | 16 | // Helper function to authenticate OAuth token 17 | async function authenticateOAuthToken(token: string, databaseUrl: string): Promise { 18 | try { 19 | // Create OAuth2 server instance 20 | const oauth2Server = createOAuth2Server(databaseUrl); 21 | 22 | // Create a mock request for token authentication 23 | const oauthRequest = new OAuth2Server.Request({ 24 | body: {}, 25 | headers: { authorization: `Bearer ${token}` }, 26 | method: 'GET', 27 | query: {} 28 | }); 29 | 30 | // Create OAuth2 response object 31 | const response = new OAuth2Server.Response(); 32 | 33 | try { 34 | // Authenticate the token using OAuth2 server 35 | const tokenInfo = await oauth2Server.authenticate(oauthRequest, response); 36 | 37 | if (!tokenInfo) { 38 | return null; 39 | } 40 | 41 | return { 42 | userId: tokenInfo.user.id, 43 | username: tokenInfo.user.username, 44 | email: tokenInfo.user.email, 45 | name: tokenInfo.user.name, 46 | }; 47 | } catch (error) { 48 | console.error('OAuth authentication error:', error); 49 | return null; 50 | } 51 | } catch (error) { 52 | console.error('Token verification error:', error); 53 | return null; 54 | } 55 | } 56 | 57 | // Middleware to verify session and add user to context 58 | export async function sessionMiddleware(c: Context, next: Next) { 59 | try { 60 | // First, check for Bearer token in Authorization header (for MCP clients) 61 | const authHeader = c.req.header('Authorization'); 62 | if (authHeader && authHeader.startsWith('Bearer ')) { 63 | const token = authHeader.substring(7); 64 | const user = await authenticateOAuthToken(token, getDatabaseConnectionString(c.env)); 65 | 66 | if (user) { 67 | c.set('user', user); 68 | await next(); 69 | return; 70 | } 71 | } 72 | 73 | // Fallback to session cookie authentication (for web browsers) 74 | const sessionCookie = getCookie(c, 'session'); 75 | 76 | if (!sessionCookie) { 77 | // No session cookie - continue without user 78 | await next(); 79 | return; 80 | } 81 | 82 | // Verify JWT token 83 | const payload = await verify(sessionCookie, c.env.JWT_SECRET); 84 | 85 | if (!payload || typeof payload !== 'object') { 86 | // Invalid token - continue without user 87 | await next(); 88 | return; 89 | } 90 | 91 | // Add user to context 92 | c.set('user', { 93 | userId: payload.userId, 94 | username: payload.username, 95 | email: payload.email, 96 | name: payload.name 97 | } as SessionUser); 98 | 99 | } catch (error) { 100 | console.error('Session verification error:', error); 101 | // Continue without user on error 102 | } 103 | 104 | await next(); 105 | } 106 | 107 | // Helper function to get current user from context 108 | export function getCurrentUser(c: Context): SessionUser | null { 109 | return c.get('user') || null; 110 | } 111 | 112 | // Helper function to require authentication 113 | export function requireAuth(c: Context): SessionUser { 114 | const user = getCurrentUser(c); 115 | if (!user) { 116 | throw new Error('Authentication required'); 117 | } 118 | return user; 119 | } -------------------------------------------------------------------------------- /src/auth/oauth2.ts: -------------------------------------------------------------------------------- 1 | import OAuth2Server from '@node-oauth/oauth2-server'; 2 | import crypto from 'crypto'; 3 | import { DatabaseService } from '../database'; 4 | 5 | // OAuth2 Server Model implementation 6 | export class OAuth2Model { 7 | private databaseUrl: string; 8 | 9 | constructor(databaseUrl: string) { 10 | this.databaseUrl = databaseUrl; 11 | } 12 | 13 | // Helper method to create a fresh database connection 14 | private async withDatabase(callback: (db: DatabaseService) => Promise): Promise { 15 | const db = new DatabaseService(this.databaseUrl); 16 | await db.connect(); 17 | try { 18 | return await callback(db); 19 | } finally { 20 | await db.disconnect(); 21 | } 22 | } 23 | 24 | // Get access token 25 | async getAccessToken(accessToken: string): Promise { 26 | return this.withDatabase(async (db) => { 27 | const token = await db.findOAuthAccessTokenByToken(accessToken); 28 | if (!token) return null; 29 | 30 | const application = await db.getOAuthApplicationById(token.application_id); 31 | const user = await db.getUserById(token.user_id); 32 | 33 | if (!application || !user) return null; 34 | 35 | return { 36 | accessToken: accessToken, 37 | accessTokenExpiresAt: token.expires_at, 38 | scope: token.scopes.split(' '), 39 | client: { 40 | id: application.uid, 41 | grants: ['authorization_code', 'refresh_token'] 42 | }, 43 | user: { 44 | id: user.id, 45 | username: user.username, 46 | email: user.email, 47 | name: user.name 48 | } 49 | }; 50 | }); 51 | } 52 | 53 | // Get refresh token 54 | async getRefreshToken(refreshToken: string): Promise { 55 | return this.withDatabase(async (db) => { 56 | const token = await db.findOAuthAccessTokenByRefreshToken(refreshToken); 57 | if (!token) return null; 58 | 59 | const application = await db.getOAuthApplicationById(token.application_id); 60 | const user = await db.getUserById(token.user_id); 61 | 62 | if (!application || !user) return null; 63 | 64 | return { 65 | refreshToken: refreshToken, 66 | refreshTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days 67 | scope: token.scopes.split(' '), 68 | client: { 69 | id: application.uid, 70 | grants: ['authorization_code', 'refresh_token'] 71 | }, 72 | user: { 73 | id: user.id, 74 | username: user.username, 75 | email: user.email, 76 | name: user.name 77 | } 78 | }; 79 | }); 80 | } 81 | 82 | // Get authorization code 83 | async getAuthorizationCode(authorizationCode: string): Promise { 84 | return this.withDatabase(async (db) => { 85 | // Validate input 86 | if (!authorizationCode || typeof authorizationCode !== 'string') { 87 | console.error('Invalid authorizationCode provided to getAuthorizationCode:', authorizationCode); 88 | return null; 89 | } 90 | 91 | const grant = await db.findOAuthAccessGrantByToken(authorizationCode); 92 | if (!grant || grant.token_type !== 'authorization_code') return null; 93 | 94 | const application = await db.getOAuthApplicationById(grant.application_id); 95 | const user = await db.getUserById(grant.user_id); 96 | 97 | if (!application || !user) return null; 98 | 99 | return { 100 | code: authorizationCode, 101 | expiresAt: grant.expires_at, 102 | redirectUri: grant.redirect_uri, 103 | scope: grant.scopes.split(' '), 104 | client: { 105 | id: application.uid, 106 | grants: ['authorization_code', 'refresh_token'] 107 | }, 108 | user: { 109 | id: user.id, 110 | username: user.username, 111 | email: user.email, 112 | name: user.name 113 | }, 114 | codeChallenge: grant.code_challenge, 115 | codeChallengeMethod: grant.code_challenge_method 116 | }; 117 | }); 118 | } 119 | 120 | // Get client 121 | async getClient(clientId: string, clientSecret?: string): Promise { 122 | return this.withDatabase(async (db) => { 123 | const application = await db.getOAuthApplicationByClientId(clientId); 124 | if (!application) return null; 125 | 126 | // If client secret is provided, verify it 127 | if (clientSecret && application.secret !== clientSecret) { 128 | return null; 129 | } 130 | 131 | return { 132 | id: application.uid, 133 | redirectUris: application.redirect_uri.split('\n'), 134 | grants: ['authorization_code', 'refresh_token'], 135 | scope: application.scopes 136 | }; 137 | }); 138 | } 139 | 140 | // Save token 141 | async saveToken(token: any, client: any, user: any): Promise { 142 | return this.withDatabase(async (db) => { 143 | const application = await db.getOAuthApplicationByClientId(client.id); 144 | if (!application) throw new Error('Invalid client'); 145 | 146 | const expiresAt = new Date(Date.now() + 3600 * 1000); // 1 hour 147 | 148 | await db.createOAuthAccessToken({ 149 | applicationId: application.id, 150 | userId: user.id, 151 | token: token.accessToken, 152 | refreshToken: token.refreshToken, 153 | scopes: Array.isArray(token.scope) ? token.scope.join(' ') : token.scope || 'read', 154 | expiresAt 155 | }); 156 | 157 | return { 158 | accessToken: token.accessToken, 159 | accessTokenExpiresAt: expiresAt, 160 | refreshToken: token.refreshToken, 161 | refreshTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days 162 | scope: token.scope, 163 | client: client, 164 | user: user 165 | }; 166 | }); 167 | } 168 | 169 | // Save authorization code 170 | async saveAuthorizationCode(code: any, client: any, user: any): Promise { 171 | return this.withDatabase(async (db) => { 172 | const application = await db.getOAuthApplicationByClientId(client.id); 173 | if (!application) throw new Error('Invalid client'); 174 | 175 | const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes 176 | 177 | await db.createOAuthGrant({ 178 | applicationId: application.id, 179 | userId: user.id, 180 | token: code.authorizationCode, 181 | tokenType: 'authorization_code', 182 | redirectUri: code.redirectUri, 183 | scopes: Array.isArray(code.scope) ? code.scope.join(' ') : code.scope || 'read', 184 | codeChallenge: code.codeChallenge, 185 | codeChallengeMethod: code.codeChallengeMethod, 186 | expiresAt 187 | }); 188 | 189 | return { 190 | authorizationCode: code.authorizationCode, 191 | expiresAt: expiresAt, 192 | redirectUri: code.redirectUri, 193 | scope: code.scope, 194 | client: client, 195 | user: user, 196 | codeChallenge: code.codeChallenge, 197 | codeChallengeMethod: code.codeChallengeMethod 198 | }; 199 | }); 200 | } 201 | 202 | // Revoke authorization code 203 | async revokeAuthorizationCode(code: any): Promise { 204 | return this.withDatabase(async (db) => { 205 | // Handle different possible code formats 206 | let authCode: string; 207 | 208 | if (typeof code === 'string') { 209 | authCode = code; 210 | } else if (code && code.authorizationCode) { 211 | authCode = code.authorizationCode; 212 | } else if (code && code.code) { 213 | authCode = code.code; 214 | } else { 215 | console.error('Invalid code parameter in revokeAuthorizationCode:', code); 216 | return false; 217 | } 218 | 219 | if (!authCode || typeof authCode !== 'string') { 220 | console.error('Invalid authorization code in revokeAuthorizationCode:', authCode); 221 | return false; 222 | } 223 | 224 | const grant = await db.findOAuthAccessGrantByToken(authCode); 225 | if (!grant) return false; 226 | 227 | return await db.revokeOAuthGrant(grant.id); 228 | }); 229 | } 230 | 231 | // Revoke token 232 | async revokeToken(token: any): Promise { 233 | return this.withDatabase(async (db) => { 234 | const accessToken = await db.findOAuthAccessTokenByRefreshToken(token.refreshToken); 235 | if (!accessToken) return false; 236 | 237 | return await db.revokeOAuthAccessToken(accessToken.id); 238 | }); 239 | } 240 | 241 | // Verify scope 242 | verifyScope(token: any, scope: string): boolean { 243 | if (!token.scope) return false; 244 | const tokenScopes = Array.isArray(token.scope) ? token.scope : token.scope.split(' '); 245 | return tokenScopes.includes(scope); 246 | } 247 | 248 | // Generate access token 249 | generateAccessToken(client: any, user: any, scope: any): string { 250 | return crypto.randomBytes(32).toString('hex'); 251 | } 252 | 253 | // Generate refresh token 254 | generateRefreshToken(client: any, user: any, scope: any): string { 255 | return crypto.randomBytes(32).toString('hex'); 256 | } 257 | 258 | // Generate authorization code 259 | generateAuthorizationCode(client: any, user: any, scope: any): string { 260 | return crypto.randomBytes(32).toString('hex'); 261 | } 262 | } 263 | 264 | // Create OAuth2 server instance 265 | export function createOAuth2Server(databaseUrl: string): OAuth2Server { 266 | const model = new OAuth2Model(databaseUrl); 267 | 268 | return new OAuth2Server({ 269 | model: model as any, 270 | debug: true, 271 | accessTokenLifetime: 3600, // 1 hour 272 | refreshTokenLifetime: 30 * 24 * 60 * 60, // 30 days 273 | authorizationCodeLifetime: 600, // 10 minutes 274 | allowBearerTokensInQueryString: true, 275 | allowEmptyState: true, 276 | requireClientAuthentication: { 277 | authorization_code: true, 278 | refresh_token: true 279 | } 280 | } as any); 281 | } -------------------------------------------------------------------------------- /src/auth/views/consent.ts: -------------------------------------------------------------------------------- 1 | interface ConsentPageConfig { 2 | title?: string; 3 | subtitle?: string; 4 | logoUrl?: string; 5 | brandColor?: string; 6 | } 7 | 8 | interface ConsentPageData { 9 | application: { 10 | name: string; 11 | client_uri?: string; 12 | logo_uri?: string; 13 | tos_uri?: string; 14 | policy_uri?: string; 15 | description?: string; 16 | }; 17 | scopes: string[]; 18 | user: { 19 | name: string; 20 | username: string; 21 | email: string; 22 | }; 23 | oauthState: string; 24 | } 25 | 26 | export function generateConsentPage(data: ConsentPageData, config: ConsentPageConfig = {}) { 27 | const { 28 | title = "Authorize Application", 29 | subtitle = "Grant permission to access your account", 30 | logoUrl = "", 31 | brandColor = "blue" 32 | } = config; 33 | 34 | const scopeDescriptions: { [key: string]: string } = { 35 | read: "View your basic profile information", 36 | write: "Modify your account data", 37 | profile: "Access your full profile details", 38 | claudeai: "Connect with Claude AI services" 39 | }; 40 | 41 | return ` 42 | 43 | 44 | 45 | 46 | 47 | ${title} - MCP Server 48 | 49 | 62 | 76 | 77 | 78 | 79 |
80 | 88 |
89 | 90 |
91 |
92 | 93 |
94 | ${logoUrl ? `Logo` : ''} 95 |

${title}

96 |

${subtitle}

97 |
98 | 99 | 100 |
101 | 102 |
103 | ${data.application.logo_uri ? ` 104 |
105 | ${data.application.name} logo 106 |
107 | ` : ` 108 |
109 |
110 | 111 | 112 | 113 |
114 |
115 | `} 116 | 117 |

${data.application.name}

118 |

wants to access your account

119 | 120 | ${data.application.client_uri ? ` 121 | 122 | 123 | 124 | 125 | Visit website 126 | 127 | ` : ''} 128 |
129 | 130 | 131 |
132 |

You are signed in as:

133 |
134 |
135 |
136 | ${data.user.name.charAt(0).toUpperCase()} 137 |
138 |
139 |
140 |

${data.user.name}

141 |

${data.user.email}

142 |
143 |
144 |
145 | 146 | 147 |
148 |

This application will be able to:

149 |
    150 | ${data.scopes.map(scope => ` 151 |
  • 152 | 153 | 154 | 155 | 156 | ${scopeDescriptions[scope] || `Access ${scope} permissions`} 157 | 158 |
  • 159 | `).join('')} 160 |
161 |
162 | 163 | 164 | ${data.application.tos_uri || data.application.policy_uri ? ` 165 |
166 |

By continuing, you agree to:

167 |
168 | ${data.application.tos_uri ? ` 169 | 170 | Terms of Service 171 | 172 | ` : ''} 173 | ${data.application.policy_uri ? ` 174 | 175 | Privacy Policy 176 | 177 | ` : ''} 178 |
179 |
180 | ` : ''} 181 | 182 | 183 | 184 | 185 | 186 |
187 | 196 | 197 | 202 |
203 |
204 | 205 | 206 |
207 |

208 | Powered by MCP Server Framework 209 |

210 |
211 |
212 |
213 | 214 | 331 | 332 | 333 | `; 334 | } -------------------------------------------------------------------------------- /src/auth/views/home.ts: -------------------------------------------------------------------------------- 1 | interface HomePageConfig { 2 | serverName?: string; 3 | serverVersion?: string; 4 | registeredTools?: number; 5 | showMcpInfo?: boolean; 6 | } 7 | 8 | interface HomePageUser { 9 | name: string; 10 | username: string; 11 | email: string; 12 | } 13 | 14 | export function generateHomePage(config: HomePageConfig = {}, user?: HomePageUser | null) { 15 | const { 16 | serverName = "MCP Server", 17 | serverVersion = "1.0.0", 18 | registeredTools = 0, 19 | showMcpInfo = true 20 | } = config; 21 | 22 | return ` 23 | 24 | 25 | 26 | 27 | 28 | ${serverName} 29 | 30 | 43 | 51 | 52 | 53 | 54 |
55 | 65 |
66 | 67 |
68 |
69 | 70 |
71 |

72 | ${serverName} 73 |

74 |

75 | Version ${serverVersion} 76 |

77 |
78 | 79 | ${user ? ` 80 | 81 |
82 |

83 | Welcome back, ${user.name}! 84 |

85 |
86 |
87 | Username: 88 | ${user.username} 89 |
90 |
91 | Email: 92 | ${user.email} 93 |
94 |
95 |
96 | 99 | 100 | Profile 101 | 102 |
103 |
104 | ` : ` 105 | 106 |
107 |

108 | Welcome to ${serverName} 109 |

110 |

111 | Please sign in or create an account to access the MCP server features and start using our tools. 112 |

113 | 121 |
122 | `} 123 | 124 | ${showMcpInfo ? ` 125 | 126 |
127 |

Server Information

128 |
129 |
130 |
${registeredTools}
131 |
Registered Tools
132 |
133 |
134 |
/mcp
135 |
MCP Endpoint
136 |
137 |
138 |
OAuth2
139 |
Authentication
140 |
141 |
142 |
143 | ` : ''} 144 |
145 |
146 | 147 | 148 |
149 |
150 |

151 | Powered by MCP Server Framework 152 |

153 |
154 |
155 | 156 | 204 | 205 | `; 206 | } 207 | 208 | // Export the default home page for backward compatibility 209 | export const homePageHTML = generateHomePage(); -------------------------------------------------------------------------------- /src/auth/views/index.ts: -------------------------------------------------------------------------------- 1 | export { loginPageHTML, generateLoginPage } from "./login"; 2 | export { registerPageHTML, generateRegisterPage } from "./register"; 3 | export { homePageHTML, generateHomePage } from "./home"; -------------------------------------------------------------------------------- /src/auth/views/login.ts: -------------------------------------------------------------------------------- 1 | interface LoginPageConfig { 2 | title?: string; 3 | subtitle?: string; 4 | showRegisterLink?: boolean; 5 | registerUrl?: string; 6 | logoUrl?: string; 7 | brandColor?: string; 8 | } 9 | 10 | export function generateLoginPage(config: LoginPageConfig = {}) { 11 | const { 12 | title = "Welcome Back", 13 | subtitle = "Sign in to your account", 14 | showRegisterLink = true, 15 | registerUrl = "/register", 16 | logoUrl = "", 17 | brandColor = "blue" 18 | } = config; 19 | 20 | return ` 21 | 22 | 23 | 24 | 25 | 26 | ${title} - MCP Server 27 | 28 | 41 | 49 | 50 | 51 | 52 |
53 | 63 |
64 | 65 |
66 |
67 | 68 |
69 | ${logoUrl ? `Logo` : ''} 70 |

${title}

71 |

${subtitle}

72 |
73 | 74 | 75 |
76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 |
84 |
85 | 88 | 96 |
97 | 98 |
99 | 102 | 110 |
111 | 112 |
113 | 123 |
124 |
125 | 126 | ${showRegisterLink ? ` 127 |
128 | Don't have an account? 129 | 130 | Create one here 131 | 132 |
133 | ` : ''} 134 |
135 | 136 | 137 |
138 |

139 | Powered by MCP Server Framework 140 |

141 |
142 |
143 |
144 | 145 | 259 | 260 | 261 | `; 262 | } 263 | 264 | // Export the default login page for backward compatibility 265 | export const loginPageHTML = generateLoginPage(); -------------------------------------------------------------------------------- /src/auth/views/register.ts: -------------------------------------------------------------------------------- 1 | interface RegisterPageConfig { 2 | title?: string; 3 | subtitle?: string; 4 | showLoginLink?: boolean; 5 | loginUrl?: string; 6 | logoUrl?: string; 7 | brandColor?: string; 8 | } 9 | 10 | export function generateRegisterPage(config: RegisterPageConfig = {}) { 11 | const { 12 | title = "Create Account", 13 | subtitle = "Join us today", 14 | showLoginLink = true, 15 | loginUrl = "/login", 16 | logoUrl = "", 17 | brandColor = "blue" 18 | } = config; 19 | 20 | return ` 21 | 22 | 23 | 24 | 25 | 26 | ${title} - MCP Server 27 | 28 | 41 | 49 | 50 | 51 | 52 |
53 | 63 |
64 | 65 |
66 |
67 | 68 |
69 | ${logoUrl ? `Logo` : ''} 70 |

${title}

71 |

${subtitle}

72 |
73 | 74 | 75 |
76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 |
84 |
85 | 88 | 96 |
97 | 98 |
99 | 102 | 110 |
111 | 112 |
113 | 116 | 124 |
125 | 126 |
127 | 130 | 139 |

Minimum 6 characters

140 |
141 | 142 |
143 | 146 | 155 |
156 | 157 |
158 | 168 |
169 |
170 | 171 | ${showLoginLink ? ` 172 |
173 | Already have an account? 174 | 175 | Sign in here 176 | 177 |
178 | ` : ''} 179 |
180 | 181 | 182 |
183 |

184 | Powered by MCP Server Framework 185 |

186 |
187 |
188 |
189 | 190 | 325 | 326 | 327 | `; 328 | } 329 | 330 | // Export the default register page 331 | export const registerPageHTML = generateRegisterPage(); -------------------------------------------------------------------------------- /src/database/index.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcryptjs'; 2 | import crypto from 'crypto'; 3 | import { Client } from 'pg'; 4 | 5 | // Centralized database connection string management 6 | export function getDatabaseConnectionString(env: any): string { 7 | // For local development with Wrangler + Hyperdrive 8 | if (env.WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE) { 9 | return env.WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE; 10 | } 11 | 12 | // Check for Hyperdrive in production (Cloudflare Workers) 13 | if (env.HYPERDRIVE?.connectionString) { 14 | return env.HYPERDRIVE.connectionString; 15 | } 16 | 17 | // Fallback to direct DATABASE_URL for other environments 18 | if (env.DATABASE_URL) { 19 | return env.DATABASE_URL; 20 | } 21 | 22 | throw new Error('No database connection string available. For local development with Hyperdrive, set WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE. For other environments, set DATABASE_URL or configure HYPERDRIVE.'); 23 | } 24 | 25 | export interface User { 26 | id: string; 27 | username: string; 28 | email: string; 29 | name: string; 30 | password_hash: string; 31 | created_at: Date; 32 | updated_at: Date; 33 | } 34 | 35 | export interface CreateUserData { 36 | username: string; 37 | email: string; 38 | name: string; 39 | password: string; 40 | } 41 | 42 | export interface LoginData { 43 | username: string; 44 | password: string; 45 | } 46 | 47 | export interface OAuthApplication { 48 | id: string; 49 | name: string; 50 | uid: string; // client_id 51 | secret: string; // client_secret 52 | redirect_uri: string; 53 | scopes: string; 54 | confidential: boolean; 55 | client_uri?: string; 56 | logo_uri?: string; 57 | tos_uri?: string; 58 | policy_uri?: string; 59 | contacts?: string; 60 | created_at: Date; 61 | updated_at: Date; 62 | } 63 | 64 | export interface OAuthAccessGrant { 65 | id: string; 66 | application_id: string; // Foreign key to oauth_applications 67 | user_id: string; // Foreign key to users 68 | token: string; // Hashed authorization code or refresh token 69 | token_type: 'authorization_code' | 'refresh_token'; 70 | redirect_uri: string; 71 | scopes: string; 72 | code_challenge?: string; 73 | code_challenge_method?: string; 74 | expires_at: Date; 75 | revoked_at?: Date; 76 | created_at: Date; 77 | updated_at: Date; 78 | } 79 | 80 | export interface OAuthAccessToken { 81 | id: string; 82 | application_id: string; // Foreign key to oauth_applications 83 | user_id: string; // Foreign key to users 84 | grant_id?: string; // Foreign key to oauth_access_grants (for refresh tokens) 85 | token: string; // Hashed access token 86 | refresh_token?: string; // Hashed refresh token 87 | scopes: string; 88 | expires_at: Date; 89 | revoked_at?: Date; 90 | created_at: Date; 91 | updated_at: Date; 92 | } 93 | 94 | export interface CreateOAuthApplicationData { 95 | client_name: string; 96 | redirect_uris: string[]; 97 | client_uri?: string; 98 | logo_uri?: string; 99 | tos_uri?: string; 100 | policy_uri?: string; 101 | contacts?: string[]; 102 | scope?: string; 103 | } 104 | 105 | export class DatabaseService { 106 | private client: Client; 107 | 108 | constructor(connectionString: string) { 109 | this.client = new Client({ 110 | connectionString, 111 | }); 112 | } 113 | 114 | async connect() { 115 | await this.client.connect(); 116 | } 117 | 118 | async disconnect() { 119 | await this.client.end(); 120 | } 121 | 122 | async query(text: string, params?: any[]): Promise { 123 | return await this.client.query(text, params); 124 | } 125 | 126 | async initializeDatabase(): Promise { 127 | // Create users table 128 | await this.client.query(` 129 | CREATE TABLE IF NOT EXISTS users ( 130 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 131 | username VARCHAR(255) UNIQUE NOT NULL, 132 | email VARCHAR(255) UNIQUE NOT NULL, 133 | name VARCHAR(255) NOT NULL, 134 | password_hash VARCHAR(255) NOT NULL, 135 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 136 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP 137 | ) 138 | `); 139 | 140 | // Create OAuth applications table 141 | await this.client.query(` 142 | CREATE TABLE IF NOT EXISTS oauth_applications ( 143 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 144 | name VARCHAR(255) NOT NULL, 145 | uid VARCHAR(255) UNIQUE NOT NULL, -- client_id 146 | secret VARCHAR(255) NOT NULL, -- client_secret 147 | redirect_uri TEXT NOT NULL, 148 | scopes VARCHAR(255) DEFAULT 'read', 149 | confidential BOOLEAN DEFAULT false, 150 | client_uri VARCHAR(255), 151 | logo_uri VARCHAR(255), 152 | tos_uri VARCHAR(255), 153 | policy_uri VARCHAR(255), 154 | contacts TEXT, -- JSON array as text 155 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 156 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP 157 | ) 158 | `); 159 | 160 | // Create OAuth grants table 161 | await this.client.query(` 162 | CREATE TABLE IF NOT EXISTS oauth_access_grants ( 163 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 164 | application_id UUID NOT NULL REFERENCES oauth_applications(id) ON DELETE CASCADE, 165 | user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 166 | token VARCHAR(255) NOT NULL, -- Hashed authorization code or refresh token 167 | token_type VARCHAR(50) NOT NULL CHECK (token_type IN ('authorization_code', 'refresh_token')), 168 | redirect_uri TEXT NOT NULL, 169 | scopes VARCHAR(255) NOT NULL, 170 | code_challenge VARCHAR(255), 171 | code_challenge_method VARCHAR(10), 172 | expires_at TIMESTAMP WITH TIME ZONE NOT NULL, 173 | revoked_at TIMESTAMP WITH TIME ZONE, 174 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 175 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 176 | UNIQUE(token) 177 | ) 178 | `); 179 | 180 | // Create OAuth access tokens table 181 | await this.client.query(` 182 | CREATE TABLE IF NOT EXISTS oauth_access_tokens ( 183 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 184 | application_id UUID NOT NULL REFERENCES oauth_applications(id) ON DELETE CASCADE, 185 | user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 186 | grant_id UUID REFERENCES oauth_access_grants(id) ON DELETE CASCADE, 187 | token VARCHAR(255) NOT NULL, -- Hashed access token 188 | refresh_token VARCHAR(255), -- Hashed refresh token 189 | scopes VARCHAR(255) NOT NULL, 190 | expires_at TIMESTAMP WITH TIME ZONE NOT NULL, 191 | revoked_at TIMESTAMP WITH TIME ZONE, 192 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 193 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 194 | UNIQUE(token), 195 | UNIQUE(refresh_token) 196 | ) 197 | `); 198 | 199 | console.log('Database tables initialized successfully'); 200 | } 201 | 202 | async createUser(userData: CreateUserData): Promise { 203 | const { username, email, name, password } = userData; 204 | 205 | // Hash the password 206 | const saltRounds = 12; 207 | const password_hash = await bcrypt.hash(password, saltRounds); 208 | 209 | const query = ` 210 | INSERT INTO users (username, email, name, password_hash) 211 | VALUES ($1, $2, $3, $4) 212 | RETURNING id, username, email, name, password_hash, created_at, updated_at 213 | `; 214 | 215 | const result = await this.client.query(query, [username, email, name, password_hash]); 216 | return result.rows[0]; 217 | } 218 | 219 | async getUserByUsername(username: string): Promise { 220 | const query = ` 221 | SELECT id, username, email, name, password_hash, created_at, updated_at 222 | FROM users 223 | WHERE username = $1 224 | `; 225 | 226 | const result = await this.client.query(query, [username]); 227 | return result.rows[0] || null; 228 | } 229 | 230 | async getUserByEmail(email: string): Promise { 231 | const query = ` 232 | SELECT id, username, email, name, password_hash, created_at, updated_at 233 | FROM users 234 | WHERE email = $1 235 | `; 236 | 237 | const result = await this.client.query(query, [email]); 238 | return result.rows[0] || null; 239 | } 240 | 241 | async getUserById(id: string): Promise { 242 | const query = ` 243 | SELECT id, username, email, name, password_hash, created_at, updated_at 244 | FROM users 245 | WHERE id = $1 246 | `; 247 | 248 | const result = await this.client.query(query, [id]); 249 | return result.rows[0] || null; 250 | } 251 | 252 | async verifyPassword(password: string, hash: string): Promise { 253 | return bcrypt.compare(password, hash); 254 | } 255 | 256 | async createOAuthApplication(data: CreateOAuthApplicationData): Promise { 257 | const uid = crypto.randomUUID(); 258 | const secret = crypto.randomUUID() + crypto.randomUUID(); // More entropy 259 | const redirect_uri = data.redirect_uris.join('\n'); 260 | const scopes = (data.scope || 'read') + ' read'; // Ensure 'read' scope is always included 261 | const contacts = data.contacts ? JSON.stringify(data.contacts) : null; 262 | 263 | const result = await this.client.query( 264 | `INSERT INTO oauth_applications 265 | (name, uid, secret, redirect_uri, scopes, confidential, client_uri, logo_uri, tos_uri, policy_uri, contacts) 266 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) 267 | RETURNING *`, 268 | [ 269 | data.client_name, 270 | uid, 271 | secret, 272 | redirect_uri, 273 | scopes, 274 | false, // Always false for dynamic registration (public clients) 275 | data.client_uri || null, 276 | data.logo_uri || null, 277 | data.tos_uri || null, 278 | data.policy_uri || null, 279 | contacts 280 | ] 281 | ); 282 | 283 | return result.rows[0] as OAuthApplication; 284 | } 285 | 286 | async findOrCreateOAuthApplication(name: string, data: CreateOAuthApplicationData): Promise { 287 | // First try to find existing application 288 | const existingResult = await this.client.query( 289 | 'SELECT * FROM oauth_applications WHERE name = $1', 290 | [name] 291 | ); 292 | 293 | if (existingResult.rows.length > 0) { 294 | // Update existing application 295 | const redirect_uri = data.redirect_uris.join('\n'); 296 | const scopes = (data.scope || 'read') + ' read'; 297 | const contacts = data.contacts ? JSON.stringify(data.contacts) : null; 298 | 299 | const updateResult = await this.client.query( 300 | `UPDATE oauth_applications 301 | SET redirect_uri = $2, scopes = $3, client_uri = $4, logo_uri = $5, 302 | tos_uri = $6, policy_uri = $7, contacts = $8, updated_at = CURRENT_TIMESTAMP 303 | WHERE name = $1 304 | RETURNING *`, 305 | [ 306 | name, 307 | redirect_uri, 308 | scopes, 309 | data.client_uri || null, 310 | data.logo_uri || null, 311 | data.tos_uri || null, 312 | data.policy_uri || null, 313 | contacts 314 | ] 315 | ); 316 | 317 | return updateResult.rows[0] as OAuthApplication; 318 | } else { 319 | // Create new application 320 | return await this.createOAuthApplication(data); 321 | } 322 | } 323 | 324 | async getOAuthApplicationByClientId(clientId: string): Promise { 325 | const result = await this.client.query( 326 | 'SELECT * FROM oauth_applications WHERE uid = $1', 327 | [clientId] 328 | ); 329 | 330 | return result.rows.length > 0 ? result.rows[0] as OAuthApplication : null; 331 | } 332 | 333 | async getOAuthApplicationById(id: string): Promise { 334 | const result = await this.client.query( 335 | 'SELECT * FROM oauth_applications WHERE id = $1', 336 | [id] 337 | ); 338 | 339 | return result.rows.length > 0 ? result.rows[0] as OAuthApplication : null; 340 | } 341 | 342 | async listOAuthApplications(): Promise { 343 | const result = await this.client.query( 344 | 'SELECT * FROM oauth_applications ORDER BY created_at DESC' 345 | ); 346 | 347 | return result.rows as OAuthApplication[]; 348 | } 349 | 350 | async deleteOAuthApplication(clientId: string): Promise { 351 | const result = await this.client.query( 352 | 'DELETE FROM oauth_applications WHERE uid = $1', 353 | [clientId] 354 | ); 355 | 356 | return (result.rowCount ?? 0) > 0; 357 | } 358 | 359 | // OAuth Grants methods 360 | async createOAuthGrant(data: { 361 | applicationId: string; 362 | userId: string; 363 | token: string; 364 | tokenType: 'authorization_code' | 'refresh_token'; 365 | redirectUri: string; 366 | scopes: string; 367 | codeChallenge?: string; 368 | codeChallengeMethod?: string; 369 | expiresAt: Date; 370 | }): Promise { 371 | const hashedToken = await bcrypt.hash(data.token, 12); 372 | 373 | const result = await this.client.query( 374 | `INSERT INTO oauth_access_grants 375 | (application_id, user_id, token, token_type, redirect_uri, scopes, code_challenge, code_challenge_method, expires_at) 376 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) 377 | RETURNING *`, 378 | [ 379 | data.applicationId, 380 | data.userId, 381 | hashedToken, 382 | data.tokenType, 383 | data.redirectUri, 384 | data.scopes, 385 | data.codeChallenge || null, 386 | data.codeChallengeMethod || null, 387 | data.expiresAt 388 | ] 389 | ); 390 | 391 | return result.rows[0] as OAuthAccessGrant; 392 | } 393 | 394 | async findOAuthAccessGrantByToken(token: string): Promise { 395 | try { 396 | // Validate input token 397 | if (!token || typeof token !== 'string') { 398 | console.error('Invalid token provided to findOAuthGrantByToken:', token); 399 | return null; 400 | } 401 | 402 | const result = await this.client.query( 403 | 'SELECT * FROM oauth_access_grants WHERE revoked_at IS NULL AND expires_at > NOW() AND token_type = $1', 404 | ['authorization_code'] 405 | ); 406 | 407 | // Check each grant to find matching token (since tokens are hashed) 408 | for (const grant of result.rows) { 409 | try { 410 | // Validate that grant.token exists and is not null 411 | if (!grant.token || typeof grant.token !== 'string') { 412 | console.error('Invalid stored token for grant', grant.id, ':', grant.token); 413 | continue; 414 | } 415 | 416 | const isMatch = await bcrypt.compare(token, grant.token); 417 | if (isMatch) { 418 | return grant as OAuthAccessGrant; 419 | } 420 | } catch (bcryptError) { 421 | console.error('Bcrypt comparison error for grant', grant.id, ':', bcryptError); 422 | continue; 423 | } 424 | } 425 | 426 | return null; 427 | } catch (error) { 428 | console.error('Database error in findOAuthGrantByToken:', error); 429 | return null; 430 | } 431 | } 432 | 433 | async revokeOAuthGrant(grantId: string): Promise { 434 | const result = await this.client.query( 435 | 'UPDATE oauth_access_grants SET revoked_at = NOW(), updated_at = NOW() WHERE id = $1', 436 | [grantId] 437 | ); 438 | 439 | return (result.rowCount ?? 0) > 0; 440 | } 441 | 442 | // OAuth Access Tokens methods 443 | async createOAuthAccessToken(data: { 444 | applicationId: string; 445 | userId: string; 446 | grantId?: string; 447 | token: string; 448 | refreshToken?: string; 449 | scopes: string; 450 | expiresAt: Date; 451 | }): Promise { 452 | const hashedToken = await bcrypt.hash(data.token, 12); 453 | const hashedRefreshToken = data.refreshToken ? await bcrypt.hash(data.refreshToken, 12) : null; 454 | 455 | const result = await this.client.query( 456 | `INSERT INTO oauth_access_tokens 457 | (application_id, user_id, grant_id, token, refresh_token, scopes, expires_at) 458 | VALUES ($1, $2, $3, $4, $5, $6, $7) 459 | RETURNING *`, 460 | [ 461 | data.applicationId, 462 | data.userId, 463 | data.grantId || null, 464 | hashedToken, 465 | hashedRefreshToken, 466 | data.scopes, 467 | data.expiresAt 468 | ] 469 | ); 470 | 471 | return result.rows[0] as OAuthAccessToken; 472 | } 473 | 474 | async findOAuthAccessTokenByToken(token: string): Promise { 475 | const result = await this.client.query( 476 | 'SELECT * FROM oauth_access_tokens WHERE revoked_at IS NULL AND expires_at > NOW()', 477 | [] 478 | ); 479 | 480 | // Check each token to find matching token (since tokens are hashed) 481 | for (const accessToken of result.rows) { 482 | const isMatch = await bcrypt.compare(token, accessToken.token); 483 | if (isMatch) { 484 | return accessToken as OAuthAccessToken; 485 | } 486 | } 487 | 488 | return null; 489 | } 490 | 491 | async findOAuthAccessTokenByRefreshToken(refreshToken: string): Promise { 492 | const result = await this.client.query( 493 | 'SELECT * FROM oauth_access_tokens WHERE revoked_at IS NULL AND refresh_token IS NOT NULL', 494 | [] 495 | ); 496 | 497 | // Check each token to find matching refresh token (since tokens are hashed) 498 | for (const accessToken of result.rows) { 499 | if (accessToken.refresh_token) { 500 | const isMatch = await bcrypt.compare(refreshToken, accessToken.refresh_token); 501 | if (isMatch) { 502 | return accessToken as OAuthAccessToken; 503 | } 504 | } 505 | } 506 | 507 | return null; 508 | } 509 | 510 | async revokeOAuthAccessToken(tokenId: string): Promise { 511 | const result = await this.client.query( 512 | 'UPDATE oauth_access_tokens SET revoked_at = NOW(), updated_at = NOW() WHERE id = $1', 513 | [tokenId] 514 | ); 515 | 516 | return (result.rowCount ?? 0) > 0; 517 | } 518 | 519 | async revokeOAuthAccessTokensByUser(userId: string): Promise { 520 | const result = await this.client.query( 521 | 'UPDATE oauth_access_tokens SET revoked_at = NOW(), updated_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL', 522 | [userId] 523 | ); 524 | 525 | return result.rowCount ?? 0; 526 | } 527 | 528 | async revokeOAuthAccessTokensByApplication(applicationId: string): Promise { 529 | const result = await this.client.query( 530 | 'UPDATE oauth_access_tokens SET revoked_at = NOW(), updated_at = NOW() WHERE application_id = $1 AND revoked_at IS NULL', 531 | [applicationId] 532 | ); 533 | 534 | return result.rowCount ?? 0; 535 | } 536 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { McpBackend } from "./server"; 2 | import { 3 | registerMeTool, 4 | registerGreetingTool, 5 | registerAddTool, 6 | registerGenerateImageTool, 7 | registerSearchTool, 8 | registerFetchTool, 9 | } from "./tools"; 10 | 11 | // Create the MCP server framework 12 | const backend = new McpBackend({ 13 | name: "Custom OAuth MCP Server", 14 | version: "1.0.0" 15 | }); 16 | 17 | // Register all tools 18 | backend 19 | .registerTool(registerMeTool) 20 | .registerTool(registerGreetingTool) 21 | .registerTool(registerAddTool) 22 | .registerTool(registerGenerateImageTool) 23 | // OpenAI compatible tools 24 | .registerTool(registerSearchTool) 25 | .registerTool(registerFetchTool); 26 | 27 | // Register custom static routes (no authentication required) 28 | backend 29 | .route('GET', '/api/ping', (c) => { 30 | return c.json({ message: 'pong', timestamp: new Date().toISOString() }); 31 | }) 32 | .route('POST', '/api/echo', async (c) => { 33 | const body = await c.req.json(); 34 | return c.json({ echo: body, timestamp: new Date().toISOString() }); 35 | }); 36 | 37 | // Register custom authenticated routes (OAuth token required) 38 | backend 39 | .authRoute('GET', '/api/profile', (c, userContext, env) => { 40 | return c.json({ 41 | message: 'User profile data', 42 | user: userContext, 43 | timestamp: new Date().toISOString() 44 | }); 45 | }) 46 | .authRoute('GET', '/profile', (c, userContext, env) => { 47 | return c.html('Hello, ' + userContext.name); 48 | }) 49 | .authRoute('POST', '/api/user/settings', async (c, userContext, env) => { 50 | const settings = await c.req.json(); 51 | return c.json({ 52 | message: 'Settings updated successfully', 53 | userId: userContext.userId, 54 | settings, 55 | timestamp: new Date().toISOString() 56 | }); 57 | }); 58 | 59 | // Export the configured app 60 | export default backend.getApp(); 61 | -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | import { ServerOptions } from "@modelcontextprotocol/sdk/server/index.js"; 2 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 3 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; 4 | import { Implementation } from "@modelcontextprotocol/sdk/types.js"; 5 | import { Hono } from "hono"; 6 | import { cors } from "hono/cors"; 7 | import AuthHandler from "../auth"; 8 | import { sessionMiddleware } from "../auth/middleware/session"; 9 | import { generateHomePage } from "../auth/views"; 10 | import { createNodeRequest, createNodeResponse, waitForResponse } from "./shim"; 11 | import { UserContext } from "../types"; 12 | import { getCurrentUser } from "../auth/middleware/session"; 13 | 14 | // Type definitions for route handlers 15 | type RouteHandler = (c: any) => Promise | Response; 16 | type AuthRouteHandler = (c: any, userContext: UserContext, env: Env) => Promise | Response; 17 | 18 | // Helper function to create JSON-RPC error responses 19 | function createJsonRpcError(code: number, message: string, id: any = null) { 20 | return { 21 | jsonrpc: '2.0', 22 | error: { 23 | code, 24 | message, 25 | }, 26 | id, 27 | }; 28 | } 29 | 30 | /** 31 | * Creates an MCP server framework with OAuth authentication 32 | */ 33 | export class McpBackend { 34 | private app: Hono<{ Bindings: Env }>; 35 | private serverInfo: Implementation; 36 | private serverOptions: ServerOptions; 37 | private toolRegistrations: Array<(server: McpServer, userContext: UserContext, env: Env) => void> = []; 38 | private staticRoutes: Array<{ method: string; path: string; handler: RouteHandler }> = []; 39 | private authRoutes: Array<{ method: string; path: string; handler: AuthRouteHandler }> = []; 40 | 41 | constructor(serverInfo: Implementation, serverOptions?: ServerOptions) { 42 | this.serverInfo = serverInfo; 43 | this.serverOptions = serverOptions || {}; 44 | this.app = new Hono<{ Bindings: Env }>(); 45 | this.setupMiddleware(); 46 | this.setupRoutes(); 47 | } 48 | 49 | /** 50 | * Register a tool with the MCP server 51 | */ 52 | registerTool(registration: (server: McpServer, userContext: UserContext, env: Env) => void) { 53 | this.toolRegistrations.push(registration); 54 | return this; 55 | } 56 | 57 | /** 58 | * Register a static route (no authentication required) 59 | */ 60 | route(method: string, path: string, handler: RouteHandler) { 61 | this.staticRoutes.push({ method: method.toUpperCase(), path, handler }); 62 | this.registerRoute(method, path, handler); 63 | return this; 64 | } 65 | 66 | /** 67 | * Register an authenticated route (requires valid OAuth token) 68 | */ 69 | authRoute(method: string, path: string, handler: AuthRouteHandler) { 70 | this.authRoutes.push({ method: method.toUpperCase(), path, handler }); 71 | this.registerAuthRoute(method, path, handler as RouteHandler); 72 | return this; 73 | } 74 | 75 | /** 76 | * Get the configured Hono app 77 | */ 78 | getApp() { 79 | return this.app; 80 | } 81 | 82 | private setupMiddleware() { 83 | // Add CORS middleware to handle OPTIONS requests 84 | this.app.use('*', cors({ 85 | origin: '*', 86 | allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], 87 | allowHeaders: ['Content-Type', 'Accept', 'Authorization', 'Last-Event-ID'], 88 | exposeHeaders: ['Content-Type'], 89 | maxAge: 86400, 90 | })); 91 | 92 | // Add session middleware 93 | this.app.use('/', sessionMiddleware); 94 | } 95 | 96 | private setupRoutes() { 97 | // Mount auth routes 98 | this.app.route("/", AuthHandler); 99 | 100 | // MCP endpoint - add session middleware for authentication 101 | this.app.use('/mcp', sessionMiddleware); 102 | this.app.all('/mcp', this.handleMcpRequest.bind(this)); 103 | 104 | // Backward compatibility endpoints 105 | this.setupLegacyRoutes(); 106 | 107 | // Health check endpoint 108 | this.app.get('/up', this.handleHealthCheck.bind(this)); 109 | 110 | // Home page endpoint 111 | this.app.get('/', this.handleHomePage.bind(this)); 112 | 113 | // Register any custom routes that were added before setup 114 | this.setupCustomRoutes(); 115 | } 116 | 117 | private setupCustomRoutes() { 118 | // Register static routes 119 | this.staticRoutes.forEach(route => { 120 | this.registerRoute(route.method, route.path, route.handler); 121 | }); 122 | 123 | // Register auth routes 124 | this.authRoutes.forEach(route => { 125 | this.registerAuthRoute(route.method, route.path, route.handler); 126 | }); 127 | } 128 | 129 | private registerRoute(method: string, path: string, handler: RouteHandler) { 130 | const normalizedMethod = method.toLowerCase(); 131 | switch (normalizedMethod) { 132 | case 'get': 133 | this.app.get(path, handler); 134 | break; 135 | case 'post': 136 | this.app.post(path, handler); 137 | break; 138 | case 'put': 139 | this.app.put(path, handler); 140 | break; 141 | case 'delete': 142 | this.app.delete(path, handler); 143 | break; 144 | case 'patch': 145 | this.app.patch(path, handler); 146 | break; 147 | default: 148 | throw new Error(`Unsupported HTTP method: ${method}`); 149 | } 150 | } 151 | 152 | private registerAuthRoute(method: string, path: string, handler: AuthRouteHandler) { 153 | // Add session middleware for this route 154 | this.app.use(path, sessionMiddleware); 155 | this.registerRoute(method, path, (c) => { 156 | const user = getCurrentUser(c); 157 | if (!user) { 158 | return c.json(createJsonRpcError(-32000, 'Unauthorized: Missing or invalid access token'), 401); 159 | } 160 | return handler(c, user, c.env); 161 | }); 162 | } 163 | 164 | private setupLegacyRoutes() { 165 | // Forward /sse to /mcp for backward compatibility 166 | const forwardToMcp = async (c: any) => { 167 | const url = new URL(c.req.url); 168 | url.pathname = '/mcp'; 169 | 170 | const newRequest = new Request(url.toString(), { 171 | method: c.req.method, 172 | headers: c.req.raw.headers, 173 | body: c.req.method !== 'GET' ? c.req.raw.body : undefined, 174 | }); 175 | 176 | return this.app.fetch(newRequest, c.env, c.executionCtx); 177 | }; 178 | 179 | this.app.post('/sse', forwardToMcp); 180 | } 181 | 182 | private createMcpServer(userContext: UserContext, env: Env): McpServer { 183 | const server = new McpServer(this.serverInfo as any, this.serverOptions); 184 | 185 | // Register all tools 186 | this.toolRegistrations.forEach(registration => { 187 | registration(server, userContext, env); 188 | }); 189 | 190 | return server; 191 | } 192 | 193 | private async handleMcpRequest(c: any) { 194 | try { 195 | // Get current user from session middleware 196 | const userContext = getCurrentUser(c); 197 | if (!userContext) { 198 | return c.json(createJsonRpcError(-32000, 'Unauthorized: Missing or invalid access token'), 401); 199 | } 200 | 201 | if (['GET', 'DELETE'].includes(c.req.method)) { 202 | return c.json({ 203 | jsonrpc: "2.0", 204 | error: { 205 | code: -32000, 206 | message: "Method not allowed." 207 | }, 208 | id: null 209 | }, 405); 210 | } 211 | 212 | // Get request body 213 | const requestBody = await c.req.json(); 214 | 215 | // Create a new transport for each request 216 | const transport = new StreamableHTTPServerTransport({ 217 | sessionIdGenerator: undefined 218 | }); 219 | 220 | // Create MCP server with user context 221 | const server = this.createMcpServer(userContext, c.env); 222 | 223 | // Connect to the MCP server 224 | await server.connect(transport); 225 | 226 | // Create Node.js-style request and response objects using shims 227 | const req = createNodeRequest(c, requestBody); 228 | const { response: res, responsePromise } = createNodeResponse(); 229 | 230 | // Handle the request with proper objects 231 | await transport.handleRequest(req as any, res as any, requestBody); 232 | 233 | // Wait for the actual response (with timeout) 234 | try { 235 | const response = await waitForResponse(responsePromise); 236 | 237 | // Return the actual response from transport 238 | if (response.data) { 239 | try { 240 | const parsedResponse = JSON.parse(response.data); 241 | return c.json(parsedResponse, response.status as any, response.headers); 242 | } catch { 243 | return c.text(response.data, response.status as any, response.headers); 244 | } 245 | } 246 | 247 | // If no response data but transport ended the response, return empty response 248 | return new Response(null, { 249 | status: response.status, 250 | headers: response.headers, 251 | }); 252 | 253 | } catch (error) { 254 | console.error('Response timeout or error:', error); 255 | return c.json(createJsonRpcError(-32603, 'Internal server error'), 500); 256 | } 257 | 258 | } catch (error) { 259 | console.error('MCP endpoint error:', error); 260 | return c.json(createJsonRpcError(-32603, 'Internal server error'), 500); 261 | } 262 | } 263 | 264 | private handleHealthCheck(c: any) { 265 | return c.json({ 266 | message: this.serverInfo.name + ' (v' + this.serverInfo.version + ') is running', 267 | endpoints: { 268 | mcp: '/mcp', 269 | oauth_authorize: '/oauth/authorize', 270 | oauth_token: '/oauth/token', 271 | oauth_metadata: '/.well-known/oauth-authorization-server', 272 | oauth_protected_resource: '/.well-known/oauth-protected-resource', 273 | jwks: '/.well-known/jwks.json' 274 | }, 275 | tools: this.toolRegistrations.length 276 | }); 277 | } 278 | 279 | private handleHomePage(c: any) { 280 | const user = getCurrentUser(c); 281 | 282 | const html = generateHomePage({ 283 | serverName: this.serverInfo.name, 284 | serverVersion: this.serverInfo.version, 285 | registeredTools: this.toolRegistrations.length, 286 | showMcpInfo: true 287 | }, user); 288 | 289 | return c.html(html); 290 | } 291 | } -------------------------------------------------------------------------------- /src/server/shim.ts: -------------------------------------------------------------------------------- 1 | import { HonoRequest } from "hono"; 2 | 3 | /** 4 | * Creates a Node.js-style IncomingMessage mock from a Hono request 5 | */ 6 | export function createNodeRequest(c: { req: HonoRequest }, requestBody?: unknown) { 7 | return { 8 | method: c.req.method, 9 | url: c.req.url, 10 | headers: Object.fromEntries(c.req.raw.headers.entries()), 11 | body: requestBody, 12 | on: () => {}, 13 | once: () => {}, 14 | emit: () => {}, 15 | removeListener: () => {}, 16 | }; 17 | } 18 | 19 | /** 20 | * Creates a Node.js-style ServerResponse mock that captures response data 21 | * Returns both the mock response object and a promise that resolves when the response is complete 22 | */ 23 | export function createNodeResponse() { 24 | let responseData: any = null; 25 | let responseStatus = 200; 26 | let responseHeaders: Record = {}; 27 | let responseEnded = false; 28 | 29 | let resolveResponse: (response: { data: string; status: number; headers: Record }) => void; 30 | 31 | const responsePromise = new Promise<{ data: string; status: number; headers: Record }>((resolve) => { 32 | resolveResponse = resolve; 33 | }); 34 | 35 | const res = { 36 | writeHead: (status: number, headers?: Record) => { 37 | responseStatus = status; 38 | if (headers) Object.assign(responseHeaders, headers); 39 | return res; 40 | }, 41 | setHeader: (name: string, value: string) => { 42 | responseHeaders[name] = value; 43 | return res; 44 | }, 45 | end: (data?: string) => { 46 | if (data) responseData = data; 47 | responseEnded = true; 48 | 49 | // Resolve the promise when response ends 50 | resolveResponse({ 51 | data: responseData || '', 52 | status: responseStatus, 53 | headers: responseHeaders 54 | }); 55 | 56 | return res; 57 | }, 58 | write: (data: string) => { 59 | if (!responseData) responseData = ''; 60 | responseData += data; 61 | return true; 62 | }, 63 | flushHeaders: () => { 64 | return res; 65 | }, 66 | on: (event: string, callback: Function) => { 67 | // Handle close events for cleanup 68 | if (event === 'close') { 69 | // Store the callback but don't call it immediately 70 | } 71 | return res; 72 | }, 73 | removeListener: () => {}, 74 | get headersSent() { return responseEnded; } 75 | }; 76 | 77 | return { 78 | response: res, 79 | responsePromise, 80 | getResponseData: () => ({ data: responseData, status: responseStatus, headers: responseHeaders }) 81 | }; 82 | } 83 | 84 | /** 85 | * Waits for a response with timeout protection 86 | */ 87 | export async function waitForResponse( 88 | responsePromise: Promise<{ data: string; status: number; headers: Record }>, 89 | timeoutMs: number = 10000 90 | ) { 91 | return Promise.race([ 92 | responsePromise, 93 | new Promise((_, reject) => 94 | setTimeout(() => reject(new Error('Response timeout')), timeoutMs) 95 | ) 96 | ]); 97 | } -------------------------------------------------------------------------------- /src/tools/add.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 3 | 4 | export function registerAddTool(server: McpServer) { 5 | server.tool( 6 | "add", 7 | "Add two numbers the way only MCP can", 8 | { 9 | a: z.number(), 10 | b: z.number() 11 | }, 12 | async ({ a, b }) => ({ 13 | content: [{ type: "text", text: String(a + b) }], 14 | }) 15 | ); 16 | } -------------------------------------------------------------------------------- /src/tools/fetch.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { UserContext } from "../types"; 3 | 4 | export function registerFetchTool(server: McpServer, userContext: UserContext) { 5 | server.tool( 6 | "fetch_content", 7 | "Fetch specific content by ID or URL from various data sources", 8 | { 9 | identifier: { 10 | type: "string", 11 | description: "The ID, URL, or identifier of the content to fetch" 12 | }, 13 | type: { 14 | type: "string", 15 | description: "The type of identifier being used", 16 | enum: ["id", "url", "path", "reference"] 17 | }, 18 | source: { 19 | type: "string", 20 | description: "The data source to fetch from (e.g., 'documents', 'database', 'files')", 21 | enum: ["documents", "database", "files", "web", "knowledge_base", "api"] 22 | }, 23 | format: { 24 | type: "string", 25 | description: "The desired format of the returned content", 26 | enum: ["json", "text", "html", "markdown", "raw"] 27 | } 28 | }, 29 | async (args) => { 30 | const { identifier, type = "id", source = "documents", format = "json" } = args; 31 | 32 | // Simulate fetch functionality - replace with actual fetch logic 33 | const fetchedContent = await performFetch( 34 | identifier as string, 35 | type as string, 36 | source as string, 37 | format as string, 38 | userContext 39 | ); 40 | 41 | return { 42 | content: [ 43 | { 44 | type: "text", 45 | text: JSON.stringify({ 46 | identifier, 47 | type, 48 | source, 49 | format, 50 | content: fetchedContent, 51 | timestamp: new Date().toISOString() 52 | }, null, 2), 53 | }, 54 | ], 55 | }; 56 | } 57 | ); 58 | } 59 | 60 | async function performFetch( 61 | identifier: string, 62 | type: string, 63 | source: string, 64 | format: string, 65 | userContext: UserContext 66 | ) { 67 | // This is a mock implementation - replace with actual fetch logic 68 | // You would integrate with your actual data sources here 69 | 70 | const mockContent = { 71 | id: identifier, 72 | title: `Content fetched from ${source}`, 73 | content: `This is mock content fetched using ${type}: "${identifier}" from ${source}`, 74 | metadata: { 75 | source, 76 | type, 77 | format, 78 | fetched_by: userContext.userId, 79 | fetched_at: new Date().toISOString(), 80 | size: "1.2KB", 81 | mime_type: getMimeType(format), 82 | permissions: ["read"], 83 | version: "1.0" 84 | }, 85 | data: generateMockData(format, identifier) 86 | }; 87 | 88 | // Format the content based on the requested format 89 | switch (format) { 90 | case "text": 91 | return mockContent.content; 92 | case "html": 93 | return `

${mockContent.title}

${mockContent.content}

`; 94 | case "markdown": 95 | return `# ${mockContent.title}\n\n${mockContent.content}`; 96 | case "raw": 97 | return mockContent.data; 98 | default: 99 | return mockContent; 100 | } 101 | } 102 | 103 | function getMimeType(format: string): string { 104 | const mimeTypes: Record = { 105 | json: "application/json", 106 | text: "text/plain", 107 | html: "text/html", 108 | markdown: "text/markdown", 109 | raw: "application/octet-stream" 110 | }; 111 | return mimeTypes[format] || "application/json"; 112 | } 113 | 114 | function generateMockData(format: string, identifier: string) { 115 | // Generate different mock data based on format 116 | switch (format) { 117 | case "json": 118 | return { example: "data", id: identifier, items: [1, 2, 3] }; 119 | case "text": 120 | return `Plain text content for ${identifier}`; 121 | case "html": 122 | return `

HTML content for ${identifier}

`; 123 | case "markdown": 124 | return `## Markdown content for ${identifier}`; 125 | default: 126 | return `Raw data for ${identifier}`; 127 | } 128 | } -------------------------------------------------------------------------------- /src/tools/generateImage.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 3 | import { UserContext } from "../types"; 4 | 5 | const ALLOWED_USERNAMES = new Set([ 6 | // Add usernames of users who should have access to the image generation tool 7 | // For example: 'yourusername', 'coworkerusername' 8 | ]); 9 | 10 | export function registerGenerateImageTool(server: McpServer, userContext: UserContext, env: Env) { 11 | // Only register the tool if the user is authorized 12 | if (ALLOWED_USERNAMES.has(userContext.username)) { 13 | server.tool( 14 | "generateImage", 15 | "Generate an image using the `flux-1-schnell` model. Works best with 8 steps.", 16 | { 17 | prompt: z.string().describe("A text description of the image you want to generate."), 18 | steps: z 19 | .number() 20 | .min(4) 21 | .max(8) 22 | .default(4) 23 | .describe( 24 | "The number of diffusion steps; higher values can improve quality but take longer. Must be between 4 and 8, inclusive.", 25 | ), 26 | }, 27 | async ({ prompt, steps }) => { 28 | const response = await env.AI.run("@cf/black-forest-labs/flux-1-schnell", { 29 | prompt, 30 | steps, 31 | }); 32 | 33 | return { 34 | content: [{ type: "image", data: response.image!, mimeType: "image/jpeg" }], 35 | }; 36 | }, 37 | ); 38 | } 39 | } -------------------------------------------------------------------------------- /src/tools/index.ts: -------------------------------------------------------------------------------- 1 | export { registerAddTool } from './add'; 2 | export { registerMeTool } from './me'; 3 | export { registerGreetingTool } from './personalGreeting'; 4 | export { registerGenerateImageTool } from './generateImage'; 5 | export { registerSearchTool } from './search'; 6 | export { registerFetchTool } from './fetch'; -------------------------------------------------------------------------------- /src/tools/me.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { UserContext } from "../types"; 3 | 4 | export function registerMeTool(server: McpServer, userContext: UserContext) { 5 | server.tool("me", "Get current user information", {}, async () => { 6 | return { 7 | content: [ 8 | { 9 | type: "text", 10 | text: JSON.stringify({ 11 | userId: userContext.userId, 12 | username: userContext.username, 13 | email: userContext.email, 14 | name: userContext.name, 15 | }), 16 | }, 17 | ], 18 | }; 19 | }); 20 | } -------------------------------------------------------------------------------- /src/tools/personalGreeting.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { UserContext } from "../types"; 3 | 4 | export function registerGreetingTool(server: McpServer, userContext: UserContext) { 5 | server.tool("greeting", "Get a personalized greeting", {}, async () => { 6 | return { 7 | content: [ 8 | { 9 | type: "text", 10 | text: `Hello ${userContext.name}! Welcome to your MCP server. Your username is ${userContext.username}.`, 11 | }, 12 | ], 13 | }; 14 | }); 15 | } -------------------------------------------------------------------------------- /src/tools/search.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { UserContext } from "../types"; 3 | 4 | export function registerSearchTool(server: McpServer, userContext: UserContext) { 5 | server.tool( 6 | "search_content", 7 | "Search for content across various data sources", 8 | { 9 | query: { 10 | type: "string", 11 | description: "The search query to find relevant content" 12 | }, 13 | source: { 14 | type: "string", 15 | description: "The data source to search in (e.g., 'documents', 'database', 'files')", 16 | enum: ["documents", "database", "files", "web", "knowledge_base"] 17 | }, 18 | limit: { 19 | type: "number", 20 | description: "Maximum number of results to return (default: 10)", 21 | minimum: 1, 22 | maximum: 50 23 | } 24 | }, 25 | async (args) => { 26 | const { query, source = "documents", limit = 10 } = args; 27 | 28 | // Simulate search functionality - replace with actual search logic 29 | const searchResults = await performSearch(query as string, source as string, limit as number, userContext); 30 | 31 | return { 32 | content: [ 33 | { 34 | type: "text", 35 | text: JSON.stringify({ 36 | query, 37 | source, 38 | results: searchResults, 39 | total_results: searchResults.length, 40 | timestamp: new Date().toISOString() 41 | }, null, 2), 42 | }, 43 | ], 44 | }; 45 | } 46 | ); 47 | } 48 | 49 | async function performSearch(query: string, source: string, limit: number, userContext: UserContext) { 50 | // This is a mock implementation - replace with actual search logic 51 | // You would integrate with your actual data sources here 52 | 53 | const mockResults = [ 54 | { 55 | id: "doc_1", 56 | title: `Search result for "${query}" in ${source}`, 57 | content: `This is a mock search result for the query "${query}" from ${source}`, 58 | relevance_score: 0.95, 59 | url: `https://example.com/${source}/doc_1`, 60 | last_modified: "2024-12-19T10:00:00Z" 61 | }, 62 | { 63 | id: "doc_2", 64 | title: `Another result for "${query}"`, 65 | content: `This is another mock search result related to "${query}"`, 66 | relevance_score: 0.87, 67 | url: `https://example.com/${source}/doc_2`, 68 | last_modified: "2024-12-18T15:30:00Z" 69 | } 70 | ]; 71 | 72 | // Filter based on user context if needed 73 | return mockResults.slice(0, limit).map(result => ({ 74 | ...result, 75 | searched_by: userContext.userId 76 | })); 77 | } -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface UserContext { 2 | userId: string; 3 | username: string; 4 | email: string; 5 | name: string; 6 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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": "mcp-cloudflare-boilerplate", 8 | "main": "src/index.ts", 9 | "compatibility_date": "2025-03-10", 10 | "compatibility_flags": [ 11 | "nodejs_compat" 12 | ], 13 | "vars": { 14 | "JWT_SECRET": "your-super-secret-jwt-key-change-this-in-production", 15 | "COOKIE_ENCRYPTION_KEY": "12345678901234567890123456789012" 16 | }, 17 | "ai": { 18 | "binding": "AI" 19 | }, 20 | "hyperdrive": [ 21 | { 22 | "binding": "HYPERDRIVE", 23 | "id": "320f1361655a4ce2971119edc3317ebf" 24 | } 25 | ], 26 | "observability": { 27 | "enabled": true 28 | }, 29 | "dev": { 30 | "port": 8787 31 | }, 32 | "limits": { 33 | "cpu_ms": 300000 34 | }, 35 | /** 36 | * Smart Placement 37 | * Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement 38 | */ 39 | // "placement": { "mode": "smart" }, 40 | 41 | /** 42 | * Bindings 43 | * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including 44 | * databases, object storage, AI inference, real-time communication and more. 45 | * https://developers.cloudflare.com/workers/runtime-apis/bindings/ 46 | */ 47 | 48 | /** 49 | * Environment Variables 50 | * https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables 51 | */ 52 | // "vars": { "MY_VARIABLE": "production_value" }, 53 | /** 54 | * Note: Use secrets to store sensitive data. 55 | * https://developers.cloudflare.com/workers/configuration/secrets/ 56 | */ 57 | 58 | /** 59 | * Static Assets 60 | * https://developers.cloudflare.com/workers/static-assets/binding/ 61 | */ 62 | // "assets": { "directory": "./public/", "binding": "ASSETS" }, 63 | 64 | /** 65 | * Service Bindings (communicate between multiple Workers) 66 | * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings 67 | */ 68 | // "services": [{ "binding": "MY_SERVICE", "service": "my-service" }] 69 | } 70 | --------------------------------------------------------------------------------