diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 0000000..de0ce8d --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,84 @@ +# Gravatar MCP Server - Development Secrets and Overrides +# +# Copy this file to `.dev.vars` and fill in your actual values. +# This file contains ONLY secrets and development-specific overrides. +# All other configuration is defined in wrangler.jsonc for clarity. + +# ============================================================================= +# SECRETS (Not stored in wrangler.jsonc for security) +# ============================================================================= + +# Gravatar API Configuration (Optional) +# Get your API key from: https://gravatar.com/developers/ +GRAVATAR_API_KEY="your-gravatar-api-key-here" + +# OAuth Client Secrets +# Get your app secrets from: https://developer.wordpress.com/apps/ +OAUTH_CLIENT_ID=your-wordpress-app-client-id +OAUTH_CLIENT_SECRET=your-wordpress-app-client-secret + +# OAuth Server Configuration Secrets +# Generate random 32+ character strings for these: +# You can use: openssl rand -hex 32 +OAUTH_SIGNING_SECRET=generate-a-random-32-plus-character-string-for-jwt-signing +OAUTH_COOKIE_SECRET=generate-a-random-32-plus-character-string-for-cookie-encryption + +# ============================================================================= +# DEVELOPMENT-ONLY OVERRIDES (Optional) +# ============================================================================= + +# Debug Configuration +# Set to 'true' to enable detailed MCP transport logging +DEBUG=true + +# OAuth 2.1 Authentication Configuration +# Set to 'true' to enable OAuth authentication for MCP endpoints +OAUTH_ENABLED=true + +# OAuth Server Configuration (Development URLs) +# These should match your local development setup +OAUTH_ISSUER_URL=http://localhost:8787/mcp +OAUTH_BASE_URL=http://localhost:8787/mcp + +# ============================================================================= +# OPTIONAL DEVELOPMENT OVERRIDES +# Uncomment to override wrangler.jsonc defaults for local testing +# ============================================================================= + +# DNS Rebinding Protection Configuration +# ENABLE_DNS_REBINDING_PROTECTION=true +# ALLOWED_HOSTS=localhost:8787,127.0.0.1:8787 +# ALLOWED_ORIGINS=http://localhost:8787,http://127.0.0.1:8787,http://localhost:6274 + +# OAuth Service Documentation +# OAUTH_SERVICE_DOCS_URL=https://docs.yourapp.com/oauth + +# ============================================================================= +# SETUP INSTRUCTIONS +# ============================================================================= +# +# 1. Copy this file to `.dev.vars`: +# cp .dev.vars.example .dev.vars +# +# 2. Get a Gravatar API key (optional): +# - Visit https://gravatar.com/developers/ +# - Create an application and copy the API key +# - Replace "your-gravatar-api-key-here" above +# +# 3. Create a WordPress.com OAuth app: +# - Visit https://developer.wordpress.com/apps/ +# - Create a new application +# - Set redirect URI to: http://localhost:8787/callback +# - Copy the Client ID and Client Secret +# - Replace the OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET above +# +# 4. Generate random secrets: +# - Run: openssl rand -hex 32 +# - Use the output for OAUTH_SIGNING_SECRET and OAUTH_COOKIE_SECRET +# - Make sure each secret is unique +# +# 5. Test your setup: +# - Run: npm run dev +# - Visit: http://localhost:8787 +# - Try the OAuth authentication flow +# \ No newline at end of file diff --git a/.gitignore b/.gitignore index a6cef3c..6fce0e5 100644 --- a/.gitignore +++ b/.gitignore @@ -179,3 +179,6 @@ dist # Client .clinerules/ memory-bank/ + +# MCP +.mcp.json diff --git a/CLAUDE.md b/CLAUDE.md index a332f23..039d34a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -367,19 +367,47 @@ export default { }; ``` -## API Key Configuration +## Development Setup -The server supports optional Gravatar API key configuration: +### Environment Configuration -### Production -```bash -npx wrangler secret put GRAVATAR_API_KEY -``` +The server requires environment variables for secrets and configuration: -### Development -Create `.dev.vars`: +1. **Copy the example file:** + ```bash + cp .dev.vars.example .dev.vars + ``` + +2. **Fill in your values:** + - Get a Gravatar API key from https://gravatar.com/developers/ + - Create a WordPress.com OAuth app at https://developer.wordpress.com/apps/ + - Generate random secrets for JWT signing and cookie encryption + - See `.dev.vars.example` for detailed setup instructions + +### Configuration Architecture + +- **`wrangler.jsonc`** - Contains all explicit configuration for each environment +- **`.dev.vars`** - Contains only secrets and development-specific overrides +- **`.dev.vars.example`** - Template file with setup instructions + +### Production Deployment + +Set secrets for each environment using Wrangler: + +**For staging:** ```bash -GRAVATAR_API_KEY=your-api-key-here +npx wrangler secret put GRAVATAR_API_KEY --env staging +npx wrangler secret put OAUTH_CLIENT_ID --env staging +npx wrangler secret put OAUTH_CLIENT_SECRET --env staging +npx wrangler secret put OAUTH_SIGNING_SECRET --env staging +npx wrangler secret put OAUTH_COOKIE_SECRET --env staging ``` -The API key enables access to additional profile fields and authenticated endpoints. \ No newline at end of file +**For production:** +```bash +npx wrangler secret put GRAVATAR_API_KEY --env production +npx wrangler secret put OAUTH_CLIENT_ID --env production +npx wrangler secret put OAUTH_CLIENT_SECRET --env production +npx wrangler secret put OAUTH_SIGNING_SECRET --env production +npx wrangler secret put OAUTH_COOKIE_SECRET --env production +``` \ No newline at end of file diff --git a/README.md b/README.md index 0b81bb8..1af65b4 100644 --- a/README.md +++ b/README.md @@ -222,30 +222,52 @@ When using email-based tools, you can provide any valid email format. The system 2. Generate the appropriate hash for API requests 3. Process the email securely without storing it -## API Key Configuration (Optional) +## Configuration -The server works without authentication, but you can optionally configure a Gravatar API key to access additional profile fields. +### Development Setup -### For Production Deployment +The server requires environment variables for secrets and OAuth configuration: -Set the API key as a Cloudflare Workers secret: +1. **Copy the example file:** + ```bash + cp .dev.vars.example .dev.vars + ``` -```bash -npx wrangler secret put GRAVATAR_API_KEY -``` +2. **Fill in your values:** + - Get a Gravatar API key from https://gravatar.com/developers/ (optional) + - Create a WordPress.com OAuth app at https://developer.wordpress.com/apps/ + - Generate random secrets for JWT signing and cookie encryption + - See `.dev.vars.example` for detailed setup instructions + +### Configuration Architecture -When prompted, enter your Gravatar API key. The key will be securely stored and automatically used by the deployed server. +- **`wrangler.jsonc`** - Contains all explicit configuration for each environment +- **`.dev.vars`** - Contains only secrets and development-specific overrides +- **`.dev.vars.example`** - Template file with setup instructions (safe to commit) -### For Local Development +### Production Deployment -Create a `.dev.vars` file in the project root: +Set secrets for each environment using Wrangler: + +**For staging:** +```bash +npx wrangler secret put GRAVATAR_API_KEY --env staging +npx wrangler secret put OAUTH_CLIENT_ID --env staging +npx wrangler secret put OAUTH_CLIENT_SECRET --env staging +npx wrangler secret put OAUTH_SIGNING_SECRET --env staging +npx wrangler secret put OAUTH_COOKIE_SECRET --env staging +``` +**For production:** ```bash -# .dev.vars -GRAVATAR_API_KEY=your-api-key-here +npx wrangler secret put GRAVATAR_API_KEY --env production +npx wrangler secret put OAUTH_CLIENT_ID --env production +npx wrangler secret put OAUTH_CLIENT_SECRET --env production +npx wrangler secret put OAUTH_SIGNING_SECRET --env production +npx wrangler secret put OAUTH_COOKIE_SECRET --env production ``` -This file is automatically loaded during local development and should not be committed to version control (it's already in `.gitignore`). +The server works without the Gravatar API key, but configuring it enables access to additional profile fields. ## Development diff --git a/package-lock.json b/package-lock.json index c3b306f..ac1249e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,13 +8,17 @@ "name": "gravatar-mcp-server-remote", "version": "0.1.0", "dependencies": { + "@cloudflare/workers-oauth-provider": "^0.0.5", "@modelcontextprotocol/sdk": "1.15.1", "agents": "^0.0.100", + "axios": "^1.11.0", + "hono": "^4.8.8", + "oauth4webapi": "^3.6.0", "zod": "^3.25.67" }, "devDependencies": { "@biomejs/biome": "^2.0.6", - "@cloudflare/workers-types": "^4.20250709.0", + "@cloudflare/workers-types": "^4.20250726.0", "@kubb/core": "^3.15.0", "@kubb/plugin-client": "^3.15.0", "@kubb/plugin-oas": "^3.15.0", @@ -561,10 +565,19 @@ "node": ">=16" } }, + "node_modules/@cloudflare/workers-oauth-provider": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-oauth-provider/-/workers-oauth-provider-0.0.5.tgz", + "integrity": "sha512-t1x5KAzsubCvb4APnJ93z407X1x7SGj/ga5ziRnwIb/iLy4PMkT/hgd1y5z7Bbsdy5Fy6mywhCP4lym24bX66w==", + "license": "MIT", + "dependencies": { + "@cloudflare/workers-types": "^4.20250311.0" + } + }, "node_modules/@cloudflare/workers-types": { - "version": "4.20250709.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20250709.0.tgz", - "integrity": "sha512-Ai10nE0y6BFLLTm34A5IljuLHFDZG0i4JUgrOT0IsAzHIVM7hBdtueKe1EMjiwHkj5X/B5XlURYjw+5Sw3MfmA==", + "version": "4.20250726.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20250726.0.tgz", + "integrity": "sha512-NtM1yVBKJFX4LgSoZkVU0EDhWWvSb1vt6REO+uMYZRgx1HAfQz9GDN6bBB0B+fm2ZIxzt6FzlDbmrXpGJ2M/4Q==", "license": "MIT OR Apache-2.0" }, "node_modules/@cspotcode/source-map-support": { @@ -2875,6 +2888,18 @@ "react": "*" } }, + "node_modules/agents/node_modules/partyserver": { + "version": "0.0.72", + "resolved": "https://registry.npmjs.org/partyserver/-/partyserver-0.0.72.tgz", + "integrity": "sha512-mYkCQ6Q4KBIy4lFFuA6upmvNeD/FC+CQVTd4V3DYU6nsitKVI3NXxBrNNvmIxJLSwk3JQzYcEOPBkebB7ITVpQ==", + "license": "ISC", + "dependencies": { + "nanoid": "^5.1.5" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20240729.0" + } + }, "node_modules/ai": { "version": "4.3.16", "resolved": "https://registry.npmjs.org/ai/-/ai-4.3.16.tgz", @@ -3003,22 +3028,16 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/axios": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", - "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", - "dev": true, + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -3229,10 +3248,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -3390,10 +3406,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=0.4.0" } @@ -3526,10 +3539,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", @@ -3997,7 +4007,6 @@ "version": "1.15.9", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", - "dev": true, "funding": [ { "type": "individual", @@ -4005,8 +4014,6 @@ } ], "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=4.0" }, @@ -4034,13 +4041,10 @@ } }, "node_modules/form-data": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", - "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", - "dev": true, + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -4056,10 +4060,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">= 0.6" } @@ -4068,10 +4069,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -4275,10 +4273,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "has-symbols": "^1.0.3" }, @@ -4308,6 +4303,15 @@ "dev": true, "license": "MIT" }, + "node_modules/hono": { + "version": "4.8.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.8.8.tgz", + "integrity": "sha512-GbxTGB93Y+MOwCL2tnf9nE6L2Bn3s9D0pS+Mh1FoU4gkuecMVmg8VvHHLO702tq8y9N2fcrlcP8eCm7/feucng==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hotscript": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/hotscript/-/hotscript-1.0.13.tgz", @@ -5458,6 +5462,15 @@ "node": ">= 6" } }, + "node_modules/oauth4webapi": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.6.0.tgz", + "integrity": "sha512-OwXPTXjKPOldTpAa19oksrX9TYHA0rt+VcUFTkJ7QKwgmevPpNm9Cn5vFZUtIo96FiU6AfPuUUGzoXqgOzibWg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5622,18 +5635,6 @@ "node": ">= 0.8" } }, - "node_modules/partyserver": { - "version": "0.0.72", - "resolved": "https://registry.npmjs.org/partyserver/-/partyserver-0.0.72.tgz", - "integrity": "sha512-mYkCQ6Q4KBIy4lFFuA6upmvNeD/FC+CQVTd4V3DYU6nsitKVI3NXxBrNNvmIxJLSwk3JQzYcEOPBkebB7ITVpQ==", - "license": "ISC", - "dependencies": { - "nanoid": "^5.1.5" - }, - "peerDependencies": { - "@cloudflare/workers-types": "^4.20240729.0" - } - }, "node_modules/partysocket": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/partysocket/-/partysocket-1.1.4.tgz", @@ -5824,10 +5825,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/psl": { "version": "1.15.0", diff --git a/package.json b/package.json index 33bdc16..06bc248 100644 --- a/package.json +++ b/package.json @@ -21,13 +21,17 @@ "generate-kubb-with-spec": "make download-spec && npx kubb generate" }, "dependencies": { + "@cloudflare/workers-oauth-provider": "^0.0.5", "@modelcontextprotocol/sdk": "1.15.1", "agents": "^0.0.100", + "axios": "^1.11.0", + "hono": "^4.8.8", + "oauth4webapi": "^3.6.0", "zod": "^3.25.67" }, "devDependencies": { "@biomejs/biome": "^2.0.6", - "@cloudflare/workers-types": "^4.20250709.0", + "@cloudflare/workers-types": "^4.20250726.0", "@kubb/core": "^3.15.0", "@kubb/plugin-client": "^3.15.0", "@kubb/plugin-oas": "^3.15.0", diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..6fd31f3 --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,692 @@ +import { env } from "cloudflare:workers"; +import type { + AuthRequest, + OAuthHelpers, + TokenExchangeCallbackOptions, + TokenExchangeCallbackResult, +} from "@cloudflare/workers-oauth-provider"; +import type { Context } from "hono"; +import { getCookie, setCookie } from "hono/cookie"; +import { html, raw } from "hono/html"; +import * as oauth from "oauth4webapi"; + +import type { UserProps } from "./types.js"; + +/** + * Fetch user info from WordPress.com userinfo endpoint + * + * WordPress.com OAuth2 is not OpenID Connect compliant and uses custom field names + * (e.g., "ID" instead of "sub", "display_name" instead of "name"). This helper + * handles the WordPress-specific format that oauth4webapi cannot process. + */ +async function fetchWordPressUserInfo(accessToken: string, userinfoEndpoint: string) { + const response = await fetch(userinfoEndpoint, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }); + + if (!response.ok) { + throw new Error( + `WordPress user info request failed: ${response.status} ${response.statusText}`, + ); + } + + return response.json(); +} + +type WordPressAuthRequest = { + mcpAuthRequest: AuthRequest; + codeVerifier: string; + codeChallenge: string; + nonce: string; + transactionState: string; + consentToken: string; +}; + +export function getWordPressOAuthConfig({ + client_id, + client_secret, + authorization_endpoint, + token_endpoint, + userinfo_endpoint, +}: { + client_id: string; + client_secret: string; + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint?: string; +}) { + const client: oauth.Client = { + client_id, + token_endpoint_auth_method: "client_secret_post", + }; + + const authorizationServer: oauth.AuthorizationServer = { + issuer: "https://public-api.wordpress.com", + authorization_endpoint, + token_endpoint, + ...(userinfo_endpoint && { userinfo_endpoint }), + }; + + const clientAuth = oauth.ClientSecretPost(client_secret); + + return { authorizationServer, client, clientAuth }; +} + +/** + * OAuth Authorization Endpoint + * + * This route initiates the Authorization Code Flow when a user wants to log in. + * It creates a random state parameter to prevent CSRF attacks and stores the + * original request information in a state-specific cookie for later retrieval. + * Then it shows a consent screen before redirecting to Auth0. + */ +export async function authorize(c: Context<{ Bindings: Env & { OAUTH_PROVIDER: OAuthHelpers } }>) { + const mcpClientAuthRequest = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw); + if (!mcpClientAuthRequest.clientId) { + return c.text("Invalid request", 400); + } + + const client = await c.env.OAUTH_PROVIDER.lookupClient(mcpClientAuthRequest.clientId); + if (!client) { + return c.text("Invalid client", 400); + } + + // Generate all that is needed for the Auth0 auth request + const codeVerifier = oauth.generateRandomCodeVerifier(); + const transactionState = oauth.generateRandomState(); + const consentToken = oauth.generateRandomState(); // For CSRF protection on consent form + + // We will persist everything in a cookie. + const wordPressOAuthAuthRequest: WordPressAuthRequest = { + codeChallenge: await oauth.calculatePKCECodeChallenge(codeVerifier), + codeVerifier, + consentToken, + mcpAuthRequest: mcpClientAuthRequest, + nonce: oauth.generateRandomNonce(), + transactionState, + }; + + // Store the auth request in a transaction-specific cookie + const cookieName = `wordpress_oauth_req_${transactionState}`; + setCookie(c, cookieName, btoa(JSON.stringify(wordPressOAuthAuthRequest)), { + httpOnly: true, + maxAge: 60 * 60 * 1, + path: "/", + sameSite: c.env.NODE_ENV !== "development" ? "none" : "lax", + secure: c.env.NODE_ENV !== "development", // 1 hour + }); + + // Extract client information for the consent screen + const clientName = client.clientName || client.clientId; + const clientLogo = client.logoUri || ""; // No default logo + const clientUri = client.clientUri || "#"; + const requestedScopes = (c.env.OAUTH_SCOPES || "").split(" "); + + // Render the consent screen with CSRF protection + return c.html( + renderConsentScreen({ + clientLogo, + clientName, + clientUri, + consentToken, + redirectUri: mcpClientAuthRequest.redirectUri, + requestedScopes, + transactionState, + }), + ); +} + +/** + * Consent Confirmation Endpoint + * + * This route handles the consent confirmation before redirecting to Auth0 + */ +export async function confirmConsent( + c: Context<{ Bindings: Env & { OAUTH_PROVIDER: OAuthHelpers } }>, +) { + // Get form data + const formData = await c.req.formData(); + const transactionState = formData.get("transaction_state") as string; + const consentToken = formData.get("consent_token") as string; + const consentAction = formData.get("consent_action") as string; + + // Validate the transaction state + if (!transactionState) { + return c.text("Invalid transaction state", 400); + } + + // Get the transaction-specific cookie + const cookieName = `wordpress_oauth_req_${transactionState}`; + const wordPressOAuthAuthRequestCookie = getCookie(c, cookieName); + if (!wordPressOAuthAuthRequestCookie) { + return c.text("Invalid or expired transaction", 400); + } + + // Parse the WordPress OAuth auth request from the cookie + const wordPressOAuthAuthRequest = JSON.parse( + atob(wordPressOAuthAuthRequestCookie), + ) as WordPressAuthRequest; + + // Validate the CSRF token + if (wordPressOAuthAuthRequest.consentToken !== consentToken) { + return c.text("Invalid consent token", 403); + } + + // Handle user denial + if (consentAction !== "approve") { + // Parse the MCP client auth request to get the original redirect URI + const redirectUri = new URL(wordPressOAuthAuthRequest.mcpAuthRequest.redirectUri); + + // Add error parameters to the redirect URI + redirectUri.searchParams.set("error", "access_denied"); + redirectUri.searchParams.set("error_description", "User denied the request"); + if (wordPressOAuthAuthRequest.mcpAuthRequest.state) { + redirectUri.searchParams.set("state", wordPressOAuthAuthRequest.mcpAuthRequest.state); + } + + // Clear the transaction cookie + setCookie(c, cookieName, "", { + maxAge: 0, + path: "/", + }); + + return c.redirect(redirectUri.toString()); + } + + const { authorizationServer } = getWordPressOAuthConfig({ + client_id: c.env.OAUTH_CLIENT_ID!, + client_secret: c.env.OAUTH_CLIENT_SECRET!, + authorization_endpoint: c.env.OAUTH_AUTHORIZATION_ENDPOINT!, + token_endpoint: c.env.OAUTH_TOKEN_ENDPOINT!, + userinfo_endpoint: c.env.OAUTH_USERINFO_ENDPOINT, + }); + + // Redirect to WordPress authorization endpoint + const authorizationUrl = new URL(authorizationServer.authorization_endpoint!); + authorizationUrl.searchParams.set("client_id", c.env.OAUTH_CLIENT_ID!); + authorizationUrl.searchParams.set("redirect_uri", c.env.OAUTH_REDIRECT_URI!); + authorizationUrl.searchParams.set("response_type", "code"); + authorizationUrl.searchParams.set("scope", c.env.OAUTH_SCOPES || "auth"); + authorizationUrl.searchParams.set("code_challenge", wordPressOAuthAuthRequest.codeChallenge); + authorizationUrl.searchParams.set("code_challenge_method", "S256"); + authorizationUrl.searchParams.set("state", transactionState); + + // Use Response.redirect instead of Hono's c.redirect to avoid double encoding + return Response.redirect(authorizationUrl.href); +} + +/** + * OAuth Callback Endpoint + * + * This route handles the callback from WordPress OAuth after user authentication. + * It exchanges the authorization code for tokens and completes the + * authorization process. + */ +export async function callback(c: Context<{ Bindings: Env & { OAUTH_PROVIDER: OAuthHelpers } }>) { + // Parse the state parameter to extract transaction state and Auth0 state + const stateParam = c.req.query("state") as string; + if (!stateParam) { + return c.text("Invalid state parameter", 400); + } + + // Parse the WordPress OAuth auth request from the transaction-specific cookie + const cookieName = `wordpress_oauth_req_${stateParam}`; + const wordPressOAuthAuthRequestCookie = getCookie(c, cookieName); + if (!wordPressOAuthAuthRequestCookie) { + return c.text("Invalid transaction state or session expired", 400); + } + + const wordPressOAuthAuthRequest = JSON.parse( + atob(wordPressOAuthAuthRequestCookie), + ) as WordPressAuthRequest; + + // Clear the transaction cookie as it's no longer needed + setCookie(c, cookieName, "", { + maxAge: 0, + path: "/", + }); + + const { authorizationServer, client, clientAuth } = getWordPressOAuthConfig({ + client_id: c.env.OAUTH_CLIENT_ID!, + client_secret: c.env.OAUTH_CLIENT_SECRET!, + authorization_endpoint: c.env.OAUTH_AUTHORIZATION_ENDPOINT!, + token_endpoint: c.env.OAUTH_TOKEN_ENDPOINT!, + userinfo_endpoint: c.env.OAUTH_USERINFO_ENDPOINT, + }); + + // Perform the Code Exchange + const params = oauth.validateAuthResponse( + authorizationServer, + client, + new URL(c.req.url), + wordPressOAuthAuthRequest.transactionState, + ); + + let result: any; + try { + const response = await oauth.authorizationCodeGrantRequest( + authorizationServer, + client, + clientAuth, + params, + c.env.OAUTH_REDIRECT_URI!, + wordPressOAuthAuthRequest.codeVerifier, + ); + + // Process the response (WordPress OAuth2 doesn't use ID tokens) + result = await oauth.processAuthorizationCodeResponse(authorizationServer, client, response); + + // Check for OAuth error (result would be an error object if there was one) + if ("error" in result) { + console.error("OAuth result error:", result); + return c.text(`OAuth error: ${result.error}`, 400); + } + } catch (error) { + console.error("OAuth exchange failed:", { + error: error instanceof Error ? error.message : String(error), + cause: (error as any)?.cause, + redirect_uri: c.env.OAUTH_REDIRECT_URI, + request_url: c.req.url, + }); + return c.text("OAuth authentication failed. Please try again.", 500); + } + + // Fetch user info from WordPress API + let userInfo: any = null; + if (authorizationServer.userinfo_endpoint && result.access_token) { + try { + userInfo = await fetchWordPressUserInfo( + result.access_token, + authorizationServer.userinfo_endpoint, + ); + } catch (error) { + console.error("Failed to fetch user info:", error); + return c.text("Failed to fetch user information", 500); + } + } + + if (!userInfo) { + return c.text("No user information available", 400); + } + + // Complete the authorization + const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({ + metadata: { + label: userInfo.display_name || userInfo.email || userInfo.login, + }, + props: { + claims: userInfo, + tokenSet: { + access_token: result.access_token, + refresh_token: result.refresh_token, + expires_in: result.expires_in, + token_type: result.token_type || "Bearer", + }, + } as UserProps, + request: wordPressOAuthAuthRequest.mcpAuthRequest, + scope: wordPressOAuthAuthRequest.mcpAuthRequest.scope, + userId: userInfo.ID?.toString() || userInfo.login, + }); + + return Response.redirect(redirectTo); +} + +/** + * Token Exchange Callback + * + * This function handles the token exchange callback for the CloudflareOAuth Provider and allows us to then interact with the Upstream IdP (WordPress OAuth Idp) + */ +export async function tokenExchangeCallback( + options: TokenExchangeCallbackOptions, +): Promise { + // During the Authorization Code Exchange, we want to make sure that the Access Token issued + // by the MCP Server has the same TTL as the one issued by WordPress. + if (options.grantType === "authorization_code") { + // WordPress.com tokens don't expire (expires_in is undefined), but MCP tokens should + // Set 1 hour TTL (standard OAuth practice) for MCP client tokens + const mcpTokenTTL = options.props.tokenSet.expires_in || 60 * 60; // 1 hour in seconds + + return { + accessTokenTTL: mcpTokenTTL, + newProps: { + ...options.props, + }, + }; + } + + if (options.grantType === "refresh_token") { + const wordPressRefreshToken = options.props.tokenSet.refresh_token; + if (!wordPressRefreshToken) { + // WordPress.com tokens don't expire, so we can just issue a new MCP token + // with the same WordPress token and a fresh TTL + const mcpTokenTTL = 60 * 60; // 1 hour + + return { + accessTokenTTL: mcpTokenTTL, + newProps: { + ...options.props, + // Keep the same WordPress token since it doesn't expire + }, + }; + } + + const { authorizationServer, client, clientAuth } = getWordPressOAuthConfig({ + client_id: env.OAUTH_CLIENT_ID!, + client_secret: env.OAUTH_CLIENT_SECRET!, + authorization_endpoint: env.OAUTH_AUTHORIZATION_ENDPOINT!, + token_endpoint: env.OAUTH_TOKEN_ENDPOINT!, + userinfo_endpoint: env.OAUTH_USERINFO_ENDPOINT, + }); + + // Perform the refresh token exchange with WordPress. + const response = await oauth.refreshTokenGrantRequest( + authorizationServer, + client, + clientAuth, + wordPressRefreshToken, + ); + const refreshTokenResponse = await oauth.processRefreshTokenResponse( + authorizationServer, + client, + response, + ); + + // Check for OAuth error + if ("error" in refreshTokenResponse) { + throw new Error(`OAuth refresh error: ${refreshTokenResponse.error}`); + } + + // Fetch updated user info if available + let userInfo = options.props.claims; // fallback to existing claims + if (authorizationServer.userinfo_endpoint && refreshTokenResponse.access_token) { + try { + userInfo = await fetchWordPressUserInfo( + refreshTokenResponse.access_token, + authorizationServer.userinfo_endpoint!, + ); + } catch (error) { + console.warn("Failed to fetch updated user info during refresh:", error); + // Continue with existing claims + } + } + + // Store the new token set and claims. + return { + accessTokenTTL: refreshTokenResponse.expires_in, + newProps: { + ...options.props, + claims: userInfo, + tokenSet: { + access_token: refreshTokenResponse.access_token, + expires_in: refreshTokenResponse.expires_in, + refresh_token: refreshTokenResponse.refresh_token || wordPressRefreshToken, + token_type: refreshTokenResponse.token_type || "Bearer", + }, + }, + }; + } +} + +/** + * Client Registration Endpoint + * + * This endpoint handles dynamic client registration for MCP clients + */ +export async function registerClient( + c: Context<{ Bindings: Env & { OAUTH_PROVIDER: OAuthHelpers } }>, +) { + try { + const registrationRequest = await c.req.json(); + + // For now, return a simple static response since the OAuth provider + // may handle client registration differently + // TODO: Implement proper dynamic client registration + + // Generate a simple client ID for testing + const clientId = `mcp_client_${Date.now()}`; + + return c.json({ + client_id: clientId, + client_name: registrationRequest.client_name || "MCP Client", + redirect_uris: registrationRequest.redirect_uris || [], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", // For public clients + }); + } catch (error) { + console.error("Client registration error:", error); + return c.json( + { + error: "invalid_client_metadata", + error_description: "Failed to register client", + }, + 400, + ); + } +} + +/** + * Renders the consent screen HTML + */ +function renderConsentScreen({ + clientName, + clientLogo, + clientUri, + redirectUri, + requestedScopes, + transactionState, + consentToken, +}: { + clientName: string; + clientLogo: string; + clientUri: string; + redirectUri: string; + requestedScopes: string[]; + transactionState: string; + consentToken: string; +}) { + return html` + + + + + + Authorization Request + + + +
+
+
+ ${clientLogo?.length ? `` : ""} +

Gravatar MCP Server - Authorization Request

+ ${clientName} +
+ +

+ ${clientName} is requesting permission to access the Gravatar API using your + account. Please review the permissions before proceeding. +

+ +

+ By clicking "Allow Access", you authorize ${clientName} to access the following resources: +

+ +
    + ${raw(requestedScopes.map((scope) => `
  • ${scope}
  • `).join("\n"))} +
+ +

+ If you did not initiate the request coming from ${clientName} (${redirectUri}) or you do + not trust this application, you should deny access. +

+ +
+ + + +
+ + +
+
+ +

+ You're signing in to a third-party application. Your account information is never shared without your + permission. +

+
+
+ + + `; +} diff --git a/src/auth/types.ts b/src/auth/types.ts new file mode 100644 index 0000000..99fc7e9 --- /dev/null +++ b/src/auth/types.ts @@ -0,0 +1,23 @@ +export interface WordPressUser { + ID: number; + login: string; + email: string; + display_name: string; + username: string; + avatar_URL: string; + profile_URL: string; + site_count: number; + verified: boolean; +} + +export interface WordPressTokenSet { + access_token: string; + refresh_token?: string; + expires_in?: number; + token_type: string; +} + +export interface UserProps extends Record { + claims: WordPressUser; + tokenSet: WordPressTokenSet; +} diff --git a/src/common/env.ts b/src/common/env.ts deleted file mode 100644 index befc8d5..0000000 --- a/src/common/env.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { env } from "cloudflare:workers"; - -// Helper to cast env as any generic Env type -export function getEnv() { - return env as Env; -} - -export interface Env { - ENVIRONMENT: "development" | "staging" | "production"; - MCP_SERVER_NAME: string; -} diff --git a/src/common/image-utils.ts b/src/common/image-utils.ts new file mode 100644 index 0000000..db144ba --- /dev/null +++ b/src/common/image-utils.ts @@ -0,0 +1,64 @@ +/** + * Shared image utilities for avatar processing + * Used by both avatar image API tools and avatar resources + */ + +/** + * Convert ArrayBuffer to base64 string without stack overflow + * Handles large binary data by processing in chunks + */ +export function arrayBufferToBase64(arrayBuffer: ArrayBuffer): string { + const bytes = new Uint8Array(arrayBuffer); + + // Process in chunks to avoid "Maximum call stack size exceeded" error + const CHUNK_SIZE = 8192; + let binary = ""; + + for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { + const chunk = bytes.slice(i, i + CHUNK_SIZE); + binary += String.fromCharCode.apply(null, Array.from(chunk)); + } + + return btoa(binary); +} + +/** + * Detect MIME type from HTTP response headers + */ +export function detectMimeType(response: Response): string { + const contentType = response.headers.get("content-type"); + + // Validate it's an image MIME type + if (contentType?.startsWith("image/")) { + return contentType; + } + + // Fallback to PNG for safety, since Gravatar defaults to returning PNG images + return "image/png"; +} + +/** + * Fetch image from URL and return base64 data with detected MIME type + */ +export async function fetchImageAsBase64(imageUrl: string): Promise<{ + base64Data: string; + mimeType: string; +}> { + const response = await fetch(imageUrl); + + if (!response.ok) { + throw new Error(`Failed to fetch image: ${response.statusText}`); + } + + // Detect MIME type from response headers + const mimeType = detectMimeType(response); + + // Convert the response to base64 + const arrayBuffer = await response.arrayBuffer(); + const base64Data = arrayBufferToBase64(arrayBuffer); + + return { + base64Data, + mimeType, + }; +} diff --git a/src/config/server-config.ts b/src/config/server-config.ts index 50d1aec..b689409 100644 --- a/src/config/server-config.ts +++ b/src/config/server-config.ts @@ -3,12 +3,10 @@ * Adapted for Cloudflare Workers environment */ -import { getEnv, type Env } from "../common/env.js"; +import { env } from "cloudflare:workers"; import { VERSION } from "../common/version.js"; import type { Implementation, ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; -const env = getEnv(); - // Store client information for client-aware User-Agent generation let _clientInfo: Implementation | undefined; let _clientCapabilities: ClientCapabilities | undefined; @@ -107,10 +105,37 @@ export function getApiHeaders(apiKey?: string): Record { * @param apiKey - Optional API key for authenticated requests * @returns RequestConfig object for Kubb API calls */ -export function getRequestConfig(apiKey?: string) { +export function getApiRequestConfig(apiKey?: string) { return { baseURL: config.restApiBase, headers: getApiHeaders(apiKey), timeout: config.requestTimeout, }; } + +/** + * Get OAuth headers for authenticated requests + * @param accessToken - OAuth access token for authenticated requests + * @returns Headers object for OAuth fetch requests + */ +export function getOAuthHeaders(accessToken: string): Record { + return { + "User-Agent": generateUserAgent(), + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }; +} + +/** + * Get OAuth request configuration for Kubb client functions + * @param accessToken - OAuth access token for authenticated requests + * @returns RequestConfig object for OAuth API calls + */ +export function getOAuthRequestConfig(accessToken: string) { + return { + baseURL: config.restApiBase, + headers: getOAuthHeaders(accessToken), + timeout: config.requestTimeout, + }; +} diff --git a/src/index.ts b/src/index.ts index c88d621..b6fb820 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,14 +6,21 @@ import { registerProfileTools } from "./tools/profiles.js"; import { registerAvatarImageTools } from "./tools/avatar-images.js"; import { registerExperimentalTools } from "./tools/experimental.js"; -// Environment interface for Cloudflare Workers -export interface Env { - GRAVATAR_API_KEY?: string; - ASSETS: Fetcher; -} +import { + authorize, + callback, + confirmConsent, + tokenExchangeCallback, + registerClient, +} from "./auth/index.js"; +import type { UserProps } from "./auth/types.js"; +import { Hono } from "hono"; +import OAuthProvider from "@cloudflare/workers-oauth-provider"; + +export type Props = UserProps; // Define the MCP agent with Gravatar tools -export class GravatarMcpServer extends McpAgent { +export class GravatarMcpServer extends McpAgent { server = new McpServer(getServerInfo()); async init() { @@ -55,8 +62,60 @@ export class GravatarMcpServer extends McpAgent { } } +// Initialize the Hono app with routes for the OAuth Provider +const app = new Hono<{ Bindings: Env & { OAUTH_PROVIDER: any } }>(); +app.get("/authorize", authorize); +app.post("/authorize/consent", confirmConsent); +app.get("/callback", callback); +app.post("/register", registerClient); + +// Root endpoint for basic server info +app.get("/", (c) => { + const serverInfo = getServerInfo(); + return c.json({ + name: serverInfo.name, + version: serverInfo.version, + endpoints: { + mcp: "/mcp", + sse: "/sse", + oauth_authorize: "/authorize", + oauth_callback: "/callback", + oauth_token: "/token", + }, + }); +}); + +function createOAuthProviderIfConfigured(env: Env) { + // Only create OAuth provider if all required variables are set + if (!env.OAUTH_CLIENT_ID || !env.OAUTH_CLIENT_SECRET || !env.OAUTH_AUTHORIZATION_ENDPOINT) { + return null; + } + + return new OAuthProvider({ + apiHandlers: { + // @ts-ignore + "/mcp": GravatarMcpServer.serve("/mcp"), + // @ts-ignore + "/sse": GravatarMcpServer.serveSSE("/sse"), + }, + // @ts-ignore + defaultHandler: app, + authorizeEndpoint: "/authorize", + tokenEndpoint: "/token", + tokenExchangeCallback: (options: any) => tokenExchangeCallback(options), + clientRegistrationEndpoint: "/register", + }); +} + export default { fetch(request: Request, env: Env, ctx: ExecutionContext) { + const oauthProvider = createOAuthProviderIfConfigured(env); + + if (oauthProvider) { + return oauthProvider.fetch(request, env, ctx); + } + + // Fallback to basic MCP server without OAuth const { pathname } = new URL(request.url); if (pathname.startsWith("/sse")) { @@ -67,7 +126,6 @@ export default { return GravatarMcpServer.serve("/mcp").fetch(request, env, ctx); } - // Optional: Handle root path or other routes - return new Response("Not Found", { status: 404 }); + return app.fetch(request, env, ctx); }, }; diff --git a/src/resources/integration-guide.ts b/src/resources/integration-guide.ts index eee5384..37d3895 100644 --- a/src/resources/integration-guide.ts +++ b/src/resources/integration-guide.ts @@ -13,6 +13,7 @@ export async function getGravatarIntegrationGuide(assetsFetcher: Fetcher): Promise { try { const response = await assetsFetcher.fetch( + // TODO: Replace this with a request from the live gravatar.com site new Request("https://assets/gravatar-api-integration-guide.md"), ); diff --git a/src/tools/avatar-image-api.ts b/src/tools/avatar-image-api.ts index e0e7967..c7ed5af 100644 --- a/src/tools/avatar-image-api.ts +++ b/src/tools/avatar-image-api.ts @@ -5,24 +5,8 @@ import { config, generateUserAgent } from "../config/server-config.js"; -/** - * Convert ArrayBuffer to base64 string without stack overflow - * Handles large binary data by processing in chunks - */ -export function arrayBufferToBase64(arrayBuffer: ArrayBuffer): string { - const bytes = new Uint8Array(arrayBuffer); - - // Process in chunks to avoid "Maximum call stack size exceeded" error - const CHUNK_SIZE = 8192; - let binary = ""; - - for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { - const chunk = bytes.slice(i, i + CHUNK_SIZE); - binary += String.fromCharCode.apply(null, Array.from(chunk)); - } - - return btoa(binary); -} +// Import shared image utilities +import { arrayBufferToBase64, detectMimeType } from "../common/image-utils.js"; export interface AvatarParams { avatarIdentifier: string; @@ -37,21 +21,6 @@ export interface AvatarResult { mimeType: string; } -/** - * Detect MIME type from HTTP response headers - */ -function detectMimeType(response: Response): string { - const contentType = response.headers.get("content-type"); - - // Validate it's an image MIME type - if (contentType?.startsWith("image/")) { - return contentType; - } - - // Fallback to PNG for safety, since Gravatar defaults to returning PNG images - return "image/png"; -} - /** * Fetch avatar image by identifier */ diff --git a/src/tools/experimental.ts b/src/tools/experimental.ts index 2d0a74f..ccb729e 100644 --- a/src/tools/experimental.ts +++ b/src/tools/experimental.ts @@ -1,6 +1,16 @@ -import { getProfileInferredInterestsById, createApiKeyOptions } from "./shared/api-client.js"; +import { + getProfileInferredInterestsById, + searchProfilesByVerifiedAccount, + createApiKeyOptions, +} from "./shared/api-client.js"; import { generateIdentifier } from "../common/utils.js"; -import { emailInputShape, interestsOutputShape, profileInputShape } from "./schemas.js"; +import { + emailInputShape, + interestsOutputShape, + profileInputShape, + searchProfilesByVerifiedAccountInputShape, + searchProfilesByVerifiedAccountOutputShape, +} from "./schemas.js"; import type { GravatarMcpServer } from "../index.js"; export function registerExperimentalTools(agent: GravatarMcpServer, apiKey?: string) { @@ -94,4 +104,48 @@ export function registerExperimentalTools(agent: GravatarMcpServer, apiKey?: str } }, ); + + // Register search_profiles_by_verified_account tool + agent.server.registerTool( + "search_profiles_by_verified_account", + { + title: "Search Profiles by Verified Account", + description: + "Search for Gravatar profiles that have a verified account with the given username. Optionally filter by service (e.g., 'github', 'twitter'). Results are paginated and require an API key. 'Search for profiles with GitHub username octocat' or 'Find profiles with Twitter username jack, limit to 10 results'", + inputSchema: searchProfilesByVerifiedAccountInputShape, + outputSchema: searchProfilesByVerifiedAccountOutputShape, + annotations: { + readOnlyHint: true, + openWorldHint: true, + idempotentHint: true, + }, + }, + async (searchParams) => { + try { + const searchResults = await searchProfilesByVerifiedAccount( + searchParams, + createApiKeyOptions(apiKey), + ); + return { + content: [ + { + type: "text", + text: JSON.stringify(searchResults, null, 2), + }, + ], + structuredContent: { ...searchResults }, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Failed to search profiles by verified account: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + }; + } + }, + ); } diff --git a/src/tools/profiles.ts b/src/tools/profiles.ts index c8f2a04..6b46671 100644 --- a/src/tools/profiles.ts +++ b/src/tools/profiles.ts @@ -1,6 +1,19 @@ -import { getProfileById, createApiKeyOptions } from "./shared/api-client.js"; +import { z } from "zod"; +import { + getProfileById, + getProfile, + updateProfile, + createApiKeyOptions, + createOAuthTokenOptions, +} from "./shared/api-client.js"; +import { requireAuth } from "./shared/auth-utils.js"; import { generateIdentifier } from "../common/utils.js"; -import { emailInputShape, profileOutputShape, profileInputShape } from "./schemas.js"; +import { + emailInputShape, + profileOutputShape, + profileInputShape, + updateProfileInputShape, +} from "./schemas.js"; import type { GravatarMcpServer } from "../index.js"; export function registerProfileTools(agent: GravatarMcpServer, apiKey?: string) { @@ -86,4 +99,91 @@ export function registerProfileTools(agent: GravatarMcpServer, apiKey?: string) } }, ); + + // Register get_my_profile tool + agent.server.registerTool( + "get_my_profile", + { + title: "Get My Gravatar Profile (OAuth)", + description: + "Retrieve the Gravatar profile for the authenticated user. 'Get my Gravatar profile' or 'Show my profile information.'", + inputSchema: z.object({}).shape, + outputSchema: profileOutputShape, + annotations: { + readOnlyHint: true, + openWorldHint: true, + idempotentHint: true, + }, + }, + async () => { + try { + const accessToken = requireAuth(agent.props); + const profile = await getProfile(createOAuthTokenOptions(accessToken)); + return { + content: [ + { + type: "text", + text: JSON.stringify(profile, null, 2), + }, + ], + structuredContent: { ...profile }, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Failed to get authenticated user profile: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + }; + } + }, + ); + + // Register update_my_profile tool + agent.server.registerTool( + "update_my_profile", + { + title: "Update My Gravatar Profile (OAuth)", + description: + "Update the Gravatar profile for the authenticated user. Supports partial updates - only provided fields will be updated. To unset a field, set it to an empty string. 'Update my display name to John Smith' or 'Set my job title to Software Engineer and location to San Francisco, CA'", + inputSchema: updateProfileInputShape, + outputSchema: profileOutputShape, + annotations: { + readOnlyHint: false, + openWorldHint: true, + idempotentHint: false, + }, + }, + async (updateData) => { + try { + const accessToken = requireAuth(agent.props); + const updatedProfile = await updateProfile( + updateData, + createOAuthTokenOptions(accessToken), + ); + return { + content: [ + { + type: "text", + text: JSON.stringify(updatedProfile, null, 2), + }, + ], + structuredContent: { ...updatedProfile }, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Failed to update profile: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + }; + } + }, + ); } diff --git a/src/tools/schemas.ts b/src/tools/schemas.ts index 9e44232..2a162fb 100644 --- a/src/tools/schemas.ts +++ b/src/tools/schemas.ts @@ -11,6 +11,11 @@ import { getProfileByIdPathParamsSchema, getProfileInferredInterestsByIdPathParamsSchema, } from "../generated/schemas/index.js"; +import { updateProfileMutationRequestSchema } from "../generated/schemas/profilesSchemas/updateProfileSchema.js"; +import { + searchProfilesByVerifiedAccountQueryParamsSchema, + searchProfilesByVerifiedAccount200Schema, +} from "../generated/schemas/experimentalSchemas/searchProfilesByVerifiedAccountSchema.js"; import { z } from "zod"; // Output schemas for tools @@ -41,9 +46,30 @@ export const emailInputSchema = z.object({ }); export const emailInputShape = emailInputSchema.shape; +// Update profile input schema (for updating user's own profile) +export const updateProfileInputShape = updateProfileMutationRequestSchema.shape; + +// Search profiles by verified account input schema +export const searchProfilesByVerifiedAccountInputShape = + searchProfilesByVerifiedAccountQueryParamsSchema.shape; + +// Search profiles by verified account output schema +export const searchProfilesByVerifiedAccountOutputSchema = ( + searchProfilesByVerifiedAccount200Schema as z.ZodObject +).passthrough(); +export const searchProfilesByVerifiedAccountOutputShape = + searchProfilesByVerifiedAccountOutputSchema.shape; + // Type exports for TypeScript usage export type ProfileOutput = z.infer; export type InterestsOutput = z.infer; export type ProfileInput = z.infer; export type InterestsInput = z.infer; export type EmailInput = z.infer; +export type UpdateProfileInput = z.infer; +export type SearchProfilesByVerifiedAccountInput = z.infer< + typeof searchProfilesByVerifiedAccountQueryParamsSchema +>; +export type SearchProfilesByVerifiedAccountOutput = z.infer< + typeof searchProfilesByVerifiedAccount200Schema +>; diff --git a/src/tools/shared/api-client.ts b/src/tools/shared/api-client.ts index 7b049ef..0e77a16 100644 --- a/src/tools/shared/api-client.ts +++ b/src/tools/shared/api-client.ts @@ -32,3 +32,12 @@ export function createApiKeyOptions(apiKey?: string) { baseURL: "https://api.gravatar.com/v3", }; } + +export function createOAuthTokenOptions(accessToken: string) { + return { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + baseURL: "https://api.gravatar.com/v3", + }; +} diff --git a/src/tools/shared/auth-utils.ts b/src/tools/shared/auth-utils.ts new file mode 100644 index 0000000..23d735d --- /dev/null +++ b/src/tools/shared/auth-utils.ts @@ -0,0 +1,28 @@ +import type { UserProps } from "../../auth/types.js"; + +export function requireAuth(props?: UserProps): string { + if (!props?.tokenSet?.access_token) { + throw new Error("OAuth authentication required. Please authenticate first."); + } + return props.tokenSet.access_token; +} + +export function hasAuth(props?: UserProps): boolean { + return !!props?.tokenSet?.access_token; +} + +export function getTokenExpirationInfo(props?: UserProps): { + hasExpiration: boolean; + expiresInSeconds?: number; + expiresInMinutes?: number; +} { + if (!props?.tokenSet?.expires_in) { + return { hasExpiration: false }; + } + + return { + hasExpiration: true, + expiresInSeconds: props.tokenSet.expires_in, + expiresInMinutes: Math.round(props.tokenSet.expires_in / 60), + }; +} diff --git a/src/tools/shared/error-utils.ts b/src/tools/shared/error-utils.ts new file mode 100644 index 0000000..18b5362 --- /dev/null +++ b/src/tools/shared/error-utils.ts @@ -0,0 +1,26 @@ +import type { ToolResponse } from "./types.js"; + +export function createErrorResponse(message: string, error: unknown): ToolResponse { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: "text", + text: `${message}: ${errorMessage}`, + }, + ], + isError: true, + }; +} + +export function createSuccessResponse(data: any, textOverride?: string): ToolResponse { + return { + content: [ + { + type: "text", + text: textOverride || JSON.stringify(data, null, 2), + }, + ], + structuredContent: data, + }; +} diff --git a/src/tools/shared/types.ts b/src/tools/shared/types.ts new file mode 100644 index 0000000..133d108 --- /dev/null +++ b/src/tools/shared/types.ts @@ -0,0 +1,37 @@ +import type { UserProps } from "../../auth/types.js"; + +export interface ToolDefinition { + name: string; + config: ToolConfig; + handler: ToolHandler; +} + +export interface ToolConfig { + title: string; + description: string; + inputSchema: Record; + outputSchema?: Record; + annotations?: { + readOnlyHint?: boolean; + openWorldHint?: boolean; + idempotentHint?: boolean; + }; +} + +export interface ToolContext { + props?: UserProps; + apiKey?: string; +} + +export type ToolHandler = (params: any, context: ToolContext) => Promise; + +export interface ToolResponse { + content: Array<{ + type: "text" | "image"; + text?: string; + data?: string; + mimeType?: string; + }>; + structuredContent?: any; + isError?: boolean; +} diff --git a/tests/integration/mcp-server.test.ts b/tests/integration/mcp-server.test.ts deleted file mode 100644 index 6197c44..0000000 --- a/tests/integration/mcp-server.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { GravatarMcpServer } from "../../src/index.js"; - -// Mock Cloudflare Workers environment -vi.mock("cloudflare:workers", () => ({ - env: { - ENVIRONMENT: "development", - MCP_SERVER_NAME: "Test Gravatar MCP Server", - }, -})); - -// Mock the version to avoid test dependency on real version -vi.mock("../../src/common/version.js", () => ({ - VERSION: "1.0.0", -})); - -// Create a proper mock server that matches the expected nested structure -const mockInnerServer = { - oninitialized: undefined, // This will be set by the production code - getClientVersion: vi.fn(), - getClientCapabilities: vi.fn(), -}; - -const mockMcpServer = { - registerTool: vi.fn(), - registerPrompt: vi.fn(), - name: "test-server", - version: "1.0.0", - server: mockInnerServer, // This provides the nested structure that production code expects -}; - -// Mock the MCP SDK - single, comprehensive mock -vi.mock("@modelcontextprotocol/sdk/server/mcp.js", () => ({ - McpServer: vi.fn().mockImplementation((_serverInfo) => mockMcpServer), -})); - -// Use real server config to test actual User-Agent functionality -// The version is mocked above to ensure test independence - -// Mock the agents module to avoid complex MCP agent initialization -vi.mock("agents/mcp", () => ({ - McpAgent: class MockMcpAgent { - env: any; - constructor() { - // Don't override the server property - let the production code set it - this.env = { - ASSETS: { - fetch: vi.fn().mockResolvedValue(new Response("mock markdown content")), - }, - }; - } - async init() { - // Mock init method - } - static mount(path: string) { - // Mock mount method for Cloudflare Workers - return { - path, - handler: "mocked-handler", - }; - } - }, -})); - -vi.mock("../../src/resources/integration-guide.js", () => ({ - getGravatarIntegrationGuide: vi - .fn() - .mockResolvedValue( - "# Mock Gravatar Integration Guide\n\nThis is a mock integration guide for testing.", - ), -})); - -describe("MCP Server Integration Tests", () => { - let server: any; // Use any to avoid protected constructor issues - - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe("Server Initialization", () => { - it("should create a GravatarMcpServer instance", async () => { - // Test basic instantiation - use any to bypass protected constructor - expect(() => { - server = new (GravatarMcpServer as any)(); - }).not.toThrow(); - - expect(server).toBeInstanceOf(GravatarMcpServer); - }); - - it("should initialize the server with proper configuration", async () => { - server = new (GravatarMcpServer as any)(); - - // Check if server has the expected properties - expect(server.server).toBeDefined(); - // Remove specific property checks that don't exist on McpServer - }); - - it("should register all 6 MCP tools during initialization", async () => { - server = new (GravatarMcpServer as any)(); - - // Initialize the server - await server.init(); - - // Check that registerTool was called 6 times (for each tool) - expect(server.server.registerTool).toHaveBeenCalledTimes(6); - - // Check that specific tools were registered - const registerToolCalls = (server.server.registerTool as any).mock.calls; - const toolNames = registerToolCalls.map((call: any) => call[0]); - - expect(toolNames).toContain("get_profile_by_email"); - expect(toolNames).toContain("get_profile_by_id"); - expect(toolNames).toContain("get_inferred_interests_by_email"); - expect(toolNames).toContain("get_inferred_interests_by_id"); - expect(toolNames).toContain("get_avatar_by_email"); - expect(toolNames).toContain("get_avatar_by_id"); - }); - }); - - describe("Tool Registration Details", () => { - beforeEach(async () => { - server = new (GravatarMcpServer as any)(); - await server.init(); - }); - - it("should register profile tools with correct schemas", async () => { - const registerToolCalls = (server.server.registerTool as any).mock.calls; - - // Find the get_profile_by_email tool registration - const profileByEmailCall = registerToolCalls.find( - (call: any) => call[0] === "get_profile_by_email", - ); - expect(profileByEmailCall).toBeDefined(); - - // Check the tool configuration - const [toolName, toolConfig] = profileByEmailCall; - expect(toolName).toBe("get_profile_by_email"); - expect(toolConfig.title).toBe("Get Gravatar Profile by Email"); - expect(toolConfig.description).toContain( - "Retrieve comprehensive Gravatar profile information", - ); - expect(toolConfig.inputSchema).toBeDefined(); - expect(toolConfig.outputSchema).toBeDefined(); - expect(toolConfig.annotations).toBeDefined(); - }); - - it("should register avatar tools with correct schemas", async () => { - const registerToolCalls = (server.server.registerTool as any).mock.calls; - - // Find the get_avatar_by_email tool registration - const avatarByEmailCall = registerToolCalls.find( - (call: any) => call[0] === "get_avatar_by_email", - ); - expect(avatarByEmailCall).toBeDefined(); - - // Check the tool configuration - const [toolName, toolConfig] = avatarByEmailCall; - expect(toolName).toBe("get_avatar_by_email"); - expect(toolConfig.title).toBe("Get Avatar Image by Email"); - expect(toolConfig.inputSchema).toBeDefined(); - expect(toolConfig.inputSchema.email).toBeDefined(); - expect(toolConfig.inputSchema.size).toBeDefined(); - expect(toolConfig.annotations).toBeDefined(); - }); - - it("should register interests tools with correct schemas", async () => { - const registerToolCalls = (server.server.registerTool as any).mock.calls; - - // Find the get_inferred_interests_by_email tool registration - const interestsCall = registerToolCalls.find( - (call: any) => call[0] === "get_inferred_interests_by_email", - ); - expect(interestsCall).toBeDefined(); - - // Check the tool configuration - const [toolName, toolConfig] = interestsCall; - expect(toolName).toBe("get_inferred_interests_by_email"); - expect(toolConfig.title).toBe("Get Inferred Interests by Email"); - expect(toolConfig.description).toContain("AI-inferred interests"); - expect(toolConfig.inputSchema).toBeDefined(); - expect(toolConfig.outputSchema).toBeDefined(); - }); - }); - - describe("User-Agent Integration", () => { - beforeEach(async () => { - server = new (GravatarMcpServer as any)(); - }); - - it("should use generated User-Agent in HTTP requests", async () => { - const { getApiHeaders, setClientInfo } = await import("../../src/config/server-config.js"); - - // Set up client info to test real User-Agent generation - setClientInfo({ name: "Test-Client", version: "1.0.0" }, { sampling: {}, elicitation: {} }); - - // Get headers using the real function - const headers = getApiHeaders("test-api-key"); - - // Verify the User-Agent contains real client information - expect(headers["User-Agent"]).toMatch( - /Test-Gravatar-MCP-Server\/1\.0\.0 Test-Client\/1\.0\.0 \(sampling; elicitation\)/, - ); - expect(headers.Authorization).toBe("Bearer test-api-key"); - expect(headers.Accept).toBe("application/json"); - expect(headers["Content-Type"]).toBe("application/json"); - }); - - it("should handle missing client info gracefully in HTTP requests", async () => { - const { getApiHeaders, setClientInfo } = await import("../../src/config/server-config.js"); - - // Clear client info - setClientInfo(undefined, undefined); - - // Get headers using the real function - const headers = getApiHeaders(); - - // Verify the User-Agent handles missing client info - expect(headers["User-Agent"]).toMatch( - /Test-Gravatar-MCP-Server\/1\.0\.0 unknown\/unknown \(none\)/, - ); - expect(headers).not.toHaveProperty("Authorization"); - }); - }); -}); diff --git a/tests/unit/api-client.test.ts b/tests/unit/api-client.test.ts index a4a8dfb..b3e4f11 100644 --- a/tests/unit/api-client.test.ts +++ b/tests/unit/api-client.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { createApiKeyOptions } from "../../src/tools/shared/api-client.js"; +import { createApiKeyOptions, createOAuthTokenOptions } from "../../src/tools/shared/api-client.js"; describe("API Client Utilities", () => { describe("createApiKeyOptions", () => { @@ -48,4 +48,45 @@ describe("API Client Utilities", () => { expect(result.baseURL).toBe("https://api.gravatar.com/v3"); }); }); + + describe("createOAuthTokenOptions", () => { + it("should create OAuth options with access token", () => { + const accessToken = "oauth-token-xyz"; + const result = createOAuthTokenOptions(accessToken); + + expect(result).toEqual({ + headers: { + Authorization: "Bearer oauth-token-xyz", + }, + baseURL: "https://api.gravatar.com/v3", + }); + }); + + it("should work with different token formats", () => { + const tokens = [ + "simple-token", + "token.with.dots", + "token-with-dashes", + "token_with_underscores", + "UPPERCASE_TOKEN", + ]; + + tokens.forEach((token) => { + const result = createOAuthTokenOptions(token); + expect(result.headers.Authorization).toBe(`Bearer ${token}`); + expect(result.baseURL).toBe("https://api.gravatar.com/v3"); + }); + }); + + it("should handle empty token", () => { + const result = createOAuthTokenOptions(""); + + expect(result).toEqual({ + headers: { + Authorization: "Bearer ", + }, + baseURL: "https://api.gravatar.com/v3", + }); + }); + }); }); diff --git a/tests/unit/auth-utils.test.ts b/tests/unit/auth-utils.test.ts new file mode 100644 index 0000000..f156b07 --- /dev/null +++ b/tests/unit/auth-utils.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect } from "vitest"; +import { requireAuth, hasAuth } from "../../src/tools/shared/auth-utils.js"; +import type { UserProps } from "../../src/auth/types.js"; + +describe("Auth Utilities", () => { + describe("requireAuth", () => { + it("should return access token when valid props provided", () => { + const props: UserProps = { + tokenSet: { + access_token: "valid-access-token", + refresh_token: "refresh-token", + expires_at: Date.now() + 3600000, + token_type: "Bearer", + }, + userInfo: { + sub: "user123", + name: "Test User", + email: "test@example.com", + }, + }; + + const result = requireAuth(props); + expect(result).toBe("valid-access-token"); + }); + + it("should throw error when props is undefined", () => { + expect(() => requireAuth(undefined)).toThrow( + "OAuth authentication required. Please authenticate first.", + ); + }); + + it("should throw error when props is null", () => { + expect(() => requireAuth(null as any)).toThrow( + "OAuth authentication required. Please authenticate first.", + ); + }); + + it("should throw error when tokenSet is undefined", () => { + const props: UserProps = { + tokenSet: undefined as any, + userInfo: { + sub: "user123", + name: "Test User", + email: "test@example.com", + }, + }; + + expect(() => requireAuth(props)).toThrow( + "OAuth authentication required. Please authenticate first.", + ); + }); + + it("should throw error when access_token is undefined", () => { + const props: UserProps = { + tokenSet: { + access_token: undefined as any, + refresh_token: "refresh-token", + expires_at: Date.now() + 3600000, + token_type: "Bearer", + }, + userInfo: { + sub: "user123", + name: "Test User", + email: "test@example.com", + }, + }; + + expect(() => requireAuth(props)).toThrow( + "OAuth authentication required. Please authenticate first.", + ); + }); + + it("should throw error when access_token is empty string", () => { + const props: UserProps = { + tokenSet: { + access_token: "", + refresh_token: "refresh-token", + expires_at: Date.now() + 3600000, + token_type: "Bearer", + }, + userInfo: { + sub: "user123", + name: "Test User", + email: "test@example.com", + }, + }; + + expect(() => requireAuth(props)).toThrow( + "OAuth authentication required. Please authenticate first.", + ); + }); + }); + + describe("hasAuth", () => { + it("should return true when valid props with access token provided", () => { + const props: UserProps = { + tokenSet: { + access_token: "valid-access-token", + refresh_token: "refresh-token", + expires_at: Date.now() + 3600000, + token_type: "Bearer", + }, + userInfo: { + sub: "user123", + name: "Test User", + email: "test@example.com", + }, + }; + + expect(hasAuth(props)).toBe(true); + }); + + it("should return false when props is undefined", () => { + expect(hasAuth(undefined)).toBe(false); + }); + + it("should return false when props is null", () => { + expect(hasAuth(null as any)).toBe(false); + }); + + it("should return false when tokenSet is undefined", () => { + const props: UserProps = { + tokenSet: undefined as any, + userInfo: { + sub: "user123", + name: "Test User", + email: "test@example.com", + }, + }; + + expect(hasAuth(props)).toBe(false); + }); + + it("should return false when access_token is undefined", () => { + const props: UserProps = { + tokenSet: { + access_token: undefined as any, + refresh_token: "refresh-token", + expires_at: Date.now() + 3600000, + token_type: "Bearer", + }, + userInfo: { + sub: "user123", + name: "Test User", + email: "test@example.com", + }, + }; + + expect(hasAuth(props)).toBe(false); + }); + + it("should return false when access_token is empty string", () => { + const props: UserProps = { + tokenSet: { + access_token: "", + refresh_token: "refresh-token", + expires_at: Date.now() + 3600000, + token_type: "Bearer", + }, + userInfo: { + sub: "user123", + name: "Test User", + email: "test@example.com", + }, + }; + + expect(hasAuth(props)).toBe(false); + }); + + it("should return true for minimal valid props", () => { + const props: UserProps = { + tokenSet: { + access_token: "token", + refresh_token: "", + expires_at: 0, + token_type: "", + }, + userInfo: { + sub: "", + name: "", + email: "", + }, + }; + + expect(hasAuth(props)).toBe(true); + }); + }); +}); diff --git a/tests/unit/avatar-image-api.test.ts b/tests/unit/avatar-image-api.test.ts index 2839de0..7a32551 100644 --- a/tests/unit/avatar-image-api.test.ts +++ b/tests/unit/avatar-image-api.test.ts @@ -1,10 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { - fetchAvatar, - avatarParams, - arrayBufferToBase64, - type AvatarParams, -} from "../../src/tools/avatar-image-api.js"; +import { fetchAvatar, avatarParams, type AvatarParams } from "../../src/tools/avatar-image-api.js"; +import { arrayBufferToBase64 } from "../../src/common/image-utils.js"; // Mock the config module vi.mock("../../src/config/server-config.js", () => ({ diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 40b8a6c..3983dd6 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -3,7 +3,7 @@ import { config, getServerInfo, getApiHeaders, - getRequestConfig, + getApiRequestConfig, generateUserAgent, setClientInfo, } from "../../src/config/server-config.js"; @@ -458,7 +458,7 @@ describe("User-Agent integration with API headers", () => { }; setClientInfo(clientInfo, capabilities); - const requestConfig = getRequestConfig(); + const requestConfig = getApiRequestConfig(); expect(requestConfig.headers["User-Agent"]).toBe( "Gravatar-MCP-Server/99.99.99 VSCode-MCP/1.5.2 (experimental)", @@ -479,7 +479,7 @@ describe("User-Agent integration with API headers", () => { const userAgent = generateUserAgent(); const headers = getApiHeaders(); - const requestConfig = getRequestConfig(); + const requestConfig = getApiRequestConfig(); expect(userAgent).toBe("Gravatar-MCP-Server/99.99.99 Test-Client/1.0.0 (sampling; roots)"); expect(headers["User-Agent"]).toBe(userAgent); @@ -487,28 +487,28 @@ describe("User-Agent integration with API headers", () => { }); }); -describe("getRequestConfig", () => { +describe("getApiRequestConfig", () => { beforeEach(() => { // Reset client info before each test setClientInfo(undefined, undefined); }); it("should return configuration with baseURL", () => { - const requestConfig = getRequestConfig(); + const requestConfig = getApiRequestConfig(); expect(requestConfig).toHaveProperty("baseURL"); expect(requestConfig.baseURL).toBe("https://api.gravatar.com/v3"); }); it("should return configuration with timeout", () => { - const requestConfig = getRequestConfig(); + const requestConfig = getApiRequestConfig(); expect(requestConfig).toHaveProperty("timeout"); expect(requestConfig.timeout).toBe(30000); }); it("should return configuration with headers without API key", () => { - const requestConfig = getRequestConfig(); + const requestConfig = getApiRequestConfig(); expect(requestConfig).toHaveProperty("headers"); expect(requestConfig.headers).toEqual({ @@ -520,7 +520,7 @@ describe("getRequestConfig", () => { it("should return configuration with headers including API key", () => { const apiKey = "test-api-key-123"; - const requestConfig = getRequestConfig(apiKey); + const requestConfig = getApiRequestConfig(apiKey); expect(requestConfig).toHaveProperty("headers"); expect(requestConfig.headers).toEqual({ @@ -532,20 +532,20 @@ describe("getRequestConfig", () => { }); it("should not include Authorization header when API key is undefined", () => { - const requestConfig = getRequestConfig(undefined); + const requestConfig = getApiRequestConfig(undefined); expect(requestConfig.headers).not.toHaveProperty("Authorization"); }); it("should not include Authorization header when API key is empty string", () => { - const requestConfig = getRequestConfig(""); + const requestConfig = getApiRequestConfig(""); expect(requestConfig.headers).not.toHaveProperty("Authorization"); }); it("should return complete configuration object", () => { const apiKey = "test-api-key-123"; - const requestConfig = getRequestConfig(apiKey); + const requestConfig = getApiRequestConfig(apiKey); expect(requestConfig).toEqual({ baseURL: "https://api.gravatar.com/v3", diff --git a/tests/unit/error-utils.test.ts b/tests/unit/error-utils.test.ts new file mode 100644 index 0000000..57f763d --- /dev/null +++ b/tests/unit/error-utils.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect } from "vitest"; +import { createErrorResponse, createSuccessResponse } from "../../src/tools/shared/error-utils.js"; + +describe("Error Utilities", () => { + describe("createErrorResponse", () => { + it("should create error response with Error object", () => { + const error = new Error("Something went wrong"); + const result = createErrorResponse("Failed to process", error); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "Failed to process: Something went wrong", + }, + ], + isError: true, + }); + }); + + it("should create error response with string error", () => { + const error = "Network timeout"; + const result = createErrorResponse("Connection failed", error); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "Connection failed: Network timeout", + }, + ], + isError: true, + }); + }); + + it("should create error response with number error", () => { + const error = 404; + const result = createErrorResponse("HTTP error", error); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "HTTP error: 404", + }, + ], + isError: true, + }); + }); + + it("should create error response with null error", () => { + const error = null; + const result = createErrorResponse("Unknown error", error); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "Unknown error: null", + }, + ], + isError: true, + }); + }); + + it("should create error response with undefined error", () => { + const error = undefined; + const result = createErrorResponse("Undefined error", error); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "Undefined error: undefined", + }, + ], + isError: true, + }); + }); + + it("should create error response with object error", () => { + const error = { code: "ECONNREFUSED", message: "Connection refused" }; + const result = createErrorResponse("Database error", error); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "Database error: [object Object]", + }, + ], + isError: true, + }); + }); + }); + + describe("createSuccessResponse", () => { + it("should create success response with data", () => { + const data = { id: 123, name: "Test User" }; + const result = createSuccessResponse(data); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: JSON.stringify(data, null, 2), + }, + ], + structuredContent: data, + }); + }); + + it("should create success response with text override", () => { + const data = { id: 123, name: "Test User" }; + const textOverride = "User retrieved successfully"; + const result = createSuccessResponse(data, textOverride); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "User retrieved successfully", + }, + ], + structuredContent: data, + }); + }); + + it("should fallback to JSON when text override is empty string", () => { + const data = { count: 42 }; + const result = createSuccessResponse(data, ""); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: JSON.stringify(data, null, 2), + }, + ], + structuredContent: data, + }); + }); + + it("should create success response with array data", () => { + const data = [{ id: 1 }, { id: 2 }]; + const result = createSuccessResponse(data); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: JSON.stringify(data, null, 2), + }, + ], + structuredContent: data, + }); + }); + + it("should create success response with primitive data", () => { + const data = "simple string"; + const result = createSuccessResponse(data); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: JSON.stringify(data, null, 2), + }, + ], + structuredContent: data, + }); + }); + + it("should create success response with null data", () => { + const data = null; + const result = createSuccessResponse(data); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "null", + }, + ], + structuredContent: null, + }); + }); + }); +}); diff --git a/tests/unit/integration-guide.test.ts b/tests/unit/integration-guide.test.ts new file mode 100644 index 0000000..ac9eb22 --- /dev/null +++ b/tests/unit/integration-guide.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi } from "vitest"; +import { getGravatarIntegrationGuide } from "../../src/resources/integration-guide.js"; + +describe("Integration Guide", () => { + describe("getGravatarIntegrationGuide", () => { + it("should return guide content when fetch succeeds", async () => { + const mockContent = "# Gravatar API Integration Guide\n\nThis is the guide content."; + const mockResponse = { + ok: true, + text: vi.fn().mockResolvedValue(mockContent), + }; + + const mockFetcher = { + fetch: vi.fn().mockResolvedValue(mockResponse), + }; + + const result = await getGravatarIntegrationGuide(mockFetcher as any); + + expect(result).toBe(mockContent); + expect(mockFetcher.fetch).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://assets/gravatar-api-integration-guide.md", + }), + ); + expect(mockResponse.text).toHaveBeenCalledOnce(); + }); + + it("should throw error when fetch response is not ok", async () => { + const mockResponse = { + ok: false, + status: 404, + statusText: "Not Found", + text: vi.fn(), + }; + + const mockFetcher = { + fetch: vi.fn().mockResolvedValue(mockResponse), + }; + + await expect(getGravatarIntegrationGuide(mockFetcher as any)).rejects.toThrow( + "Failed to load Gravatar integration guide: Failed to fetch integration guide: 404 Not Found", + ); + + expect(mockFetcher.fetch).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://assets/gravatar-api-integration-guide.md", + }), + ); + expect(mockResponse.text).not.toHaveBeenCalled(); + }); + + it("should handle different HTTP error statuses", async () => { + const testCases = [ + { status: 403, statusText: "Forbidden" }, + { status: 500, statusText: "Internal Server Error" }, + { status: 503, statusText: "Service Unavailable" }, + ]; + + for (const { status, statusText } of testCases) { + const mockResponse = { + ok: false, + status, + statusText, + text: vi.fn(), + }; + + const mockFetcher = { + fetch: vi.fn().mockResolvedValue(mockResponse), + }; + + await expect(getGravatarIntegrationGuide(mockFetcher as any)).rejects.toThrow( + `Failed to load Gravatar integration guide: Failed to fetch integration guide: ${status} ${statusText}`, + ); + } + }); + + it("should throw error when fetch throws", async () => { + const fetchError = new Error("Network connection failed"); + const mockFetcher = { + fetch: vi.fn().mockRejectedValue(fetchError), + }; + + await expect(getGravatarIntegrationGuide(mockFetcher as any)).rejects.toThrow( + "Failed to load Gravatar integration guide: Network connection failed", + ); + + expect(mockFetcher.fetch).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://assets/gravatar-api-integration-guide.md", + }), + ); + }); + + it("should handle non-Error exceptions from fetch", async () => { + const mockFetcher = { + fetch: vi.fn().mockRejectedValue("String error"), + }; + + await expect(getGravatarIntegrationGuide(mockFetcher as any)).rejects.toThrow( + "Failed to load Gravatar integration guide: String error", + ); + }); + + it("should throw error when response.text() throws", async () => { + const textError = new Error("Failed to read response body"); + const mockResponse = { + ok: true, + text: vi.fn().mockRejectedValue(textError), + }; + + const mockFetcher = { + fetch: vi.fn().mockResolvedValue(mockResponse), + }; + + await expect(getGravatarIntegrationGuide(mockFetcher as any)).rejects.toThrow( + "Failed to load Gravatar integration guide: Failed to read response body", + ); + + expect(mockResponse.text).toHaveBeenCalledOnce(); + }); + + it("should return empty string content", async () => { + const mockResponse = { + ok: true, + text: vi.fn().mockResolvedValue(""), + }; + + const mockFetcher = { + fetch: vi.fn().mockResolvedValue(mockResponse), + }; + + const result = await getGravatarIntegrationGuide(mockFetcher as any); + + expect(result).toBe(""); + }); + }); +}); diff --git a/tests/unit/schemas.test.ts b/tests/unit/schemas.test.ts index 494fcf5..0bf724c 100644 --- a/tests/unit/schemas.test.ts +++ b/tests/unit/schemas.test.ts @@ -3,37 +3,33 @@ import { describe, it, expect } from "vitest"; describe("MCP Schema Integration Tests", () => { describe("Schema Integration", () => { it("should import and use generated schemas", async () => { - const { - mcpProfileOutputSchema, - mcpInterestsOutputSchema, - mcpProfileInputShape, - mcpEmailInputShape, - } = await import("../../src/schemas/mcp-schemas.js"); + const { profileOutputSchema, interestsOutputSchema, profileInputShape, emailInputShape } = + await import("../../src/tools/schemas.js"); // Test that schemas are properly structured for MCP tool registration - expect(mcpProfileOutputSchema.shape).toBeDefined(); - expect(mcpInterestsOutputSchema.shape).toBeDefined(); - expect(mcpProfileInputShape).toBeDefined(); - expect(mcpEmailInputShape).toBeDefined(); + expect(profileOutputSchema.shape).toBeDefined(); + expect(interestsOutputSchema.shape).toBeDefined(); + expect(profileInputShape).toBeDefined(); + expect(emailInputShape).toBeDefined(); // Shapes contain the individual field validators - expect(mcpEmailInputShape.email).toBeDefined(); - expect(mcpProfileInputShape.profileIdentifier).toBeDefined(); + expect(emailInputShape.email).toBeDefined(); + expect(profileInputShape.profileIdentifier).toBeDefined(); // Test that the field validators work - expect(mcpEmailInputShape.email.safeParse("test@example.com").success).toBe(true); - expect(mcpProfileInputShape.profileIdentifier.safeParse("test-id").success).toBe(true); + expect(emailInputShape.email.safeParse("test@example.com").success).toBe(true); + expect(profileInputShape.profileIdentifier.safeParse("test-id").success).toBe(true); }); it("should handle schema validation in tool context", async () => { - const { mcpEmailInputShape } = await import("../../src/schemas/mcp-schemas.js"); + const { emailInputShape } = await import("../../src/tools/schemas.js"); // Test email validation that would be used by MCP tools const validEmail = "test@example.com"; const invalidEmail = "not-an-email"; - const validResult = mcpEmailInputShape.email.safeParse(validEmail); - const invalidResult = mcpEmailInputShape.email.safeParse(invalidEmail); + const validResult = emailInputShape.email.safeParse(validEmail); + const invalidResult = emailInputShape.email.safeParse(invalidEmail); expect(validResult.success).toBe(true); expect(invalidResult.success).toBe(false); @@ -50,7 +46,7 @@ describe("MCP Schema Integration Tests", () => { describe("Schema Passthrough Behavior", () => { it("should allow extra properties in profile output schema", async () => { - const { mcpProfileOutputSchema } = await import("../../src/schemas/mcp-schemas.js"); + const { profileOutputSchema } = await import("../../src/tools/schemas.js"); // Mock profile data with required fields const baseProfile = { @@ -75,7 +71,7 @@ describe("MCP Schema Integration Tests", () => { experimental_feature: { nested: "data" }, }; - const result = mcpProfileOutputSchema.safeParse(profileWithExtraFields); + const result = profileOutputSchema.safeParse(profileWithExtraFields); expect(result.success).toBe(true); if (result.success) { @@ -89,7 +85,7 @@ describe("MCP Schema Integration Tests", () => { }); it("should allow extra properties in interests output schema", async () => { - const { mcpInterestsOutputSchema } = await import("../../src/schemas/mcp-schemas.js"); + const { interestsOutputSchema } = await import("../../src/tools/schemas.js"); // Mock interests data with extra fields (like the real 'slug' field) const interestsWithExtraFields = { @@ -111,7 +107,7 @@ describe("MCP Schema Integration Tests", () => { ], }; - const result = mcpInterestsOutputSchema.safeParse(interestsWithExtraFields); + const result = interestsOutputSchema.safeParse(interestsWithExtraFields); expect(result.success).toBe(true); if (result.success) { @@ -126,7 +122,7 @@ describe("MCP Schema Integration Tests", () => { }); it("should still validate required fields in profile schema", async () => { - const { mcpProfileOutputSchema } = await import("../../src/schemas/mcp-schemas.js"); + const { profileOutputSchema } = await import("../../src/tools/schemas.js"); // Test that required fields are still enforced const incompleteProfile = { @@ -136,7 +132,7 @@ describe("MCP Schema Integration Tests", () => { extra_field: "should_be_ignored", }; - const result = mcpProfileOutputSchema.safeParse(incompleteProfile); + const result = profileOutputSchema.safeParse(incompleteProfile); expect(result.success).toBe(false); if (!result.success) { @@ -146,7 +142,7 @@ describe("MCP Schema Integration Tests", () => { }); it("should still validate required fields in interests schema", async () => { - const { mcpInterestsOutputSchema } = await import("../../src/schemas/mcp-schemas.js"); + const { interestsOutputSchema } = await import("../../src/tools/schemas.js"); // Test that required fields are still enforced const incompleteInterests = { @@ -160,7 +156,7 @@ describe("MCP Schema Integration Tests", () => { ], }; - const result = mcpInterestsOutputSchema.safeParse(incompleteInterests); + const result = interestsOutputSchema.safeParse(incompleteInterests); expect(result.success).toBe(false); if (!result.success) { diff --git a/tests/unit/search-profiles-by-verified-account.test.ts b/tests/unit/search-profiles-by-verified-account.test.ts new file mode 100644 index 0000000..7fc135c --- /dev/null +++ b/tests/unit/search-profiles-by-verified-account.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi } from "vitest"; +import { + searchProfilesByVerifiedAccount, + createApiKeyOptions, +} from "../../src/tools/shared/api-client.js"; +import type { SearchProfilesByVerifiedAccountInput } from "../../src/tools/schemas.js"; + +// Mock the API client functions +vi.mock("../../src/tools/shared/api-client.js", async () => { + const actual = await vi.importActual("../../src/tools/shared/api-client.js"); + return { + ...actual, + searchProfilesByVerifiedAccount: vi.fn(), + }; +}); + +describe("Search Profiles by Verified Account Tool", () => { + const mockSearchResults = { + profiles: [ + { + id: "profile1", + hash: "abc123", + display_name: "John Doe", + username: "johndoe", + verified_accounts: [ + { + service_type: "github", + service_label: "GitHub", + service_icon: "https://gravatar.com/icons/github.png", + username: "johndoe", + url: "https://github.com/johndoe", + }, + ], + }, + { + id: "profile2", + hash: "def456", + display_name: "Jane Smith", + username: "janesmith", + verified_accounts: [ + { + service_type: "github", + service_label: "GitHub", + service_icon: "https://gravatar.com/icons/github.png", + username: "janesmith", + url: "https://github.com/janesmith", + }, + ], + }, + ], + total_pages: 1, + }; + + it("should search profiles by username only", async () => { + const searchParams: SearchProfilesByVerifiedAccountInput = { + username: "johndoe", + }; + + (searchProfilesByVerifiedAccount as any).mockResolvedValue(mockSearchResults); + + const result = await searchProfilesByVerifiedAccount( + searchParams, + createApiKeyOptions("test-api-key"), + ); + + expect(searchProfilesByVerifiedAccount).toHaveBeenCalledWith(searchParams, { + headers: { Authorization: "Bearer test-api-key" }, + baseURL: "https://api.gravatar.com/v3", + }); + expect(result).toEqual(mockSearchResults); + }); + + it("should search profiles by username and service", async () => { + const searchParams: SearchProfilesByVerifiedAccountInput = { + username: "johndoe", + service: "github", + }; + + const filteredResults = { + profiles: [mockSearchResults.profiles[0]], + total_pages: 1, + }; + + (searchProfilesByVerifiedAccount as any).mockResolvedValue(filteredResults); + + const result = await searchProfilesByVerifiedAccount( + searchParams, + createApiKeyOptions("test-api-key"), + ); + + expect(searchProfilesByVerifiedAccount).toHaveBeenCalledWith( + searchParams, + expect.objectContaining({ + headers: { Authorization: "Bearer test-api-key" }, + baseURL: "https://api.gravatar.com/v3", + }), + ); + expect(result).toEqual(filteredResults); + }); + + it("should handle pagination parameters", async () => { + const searchParams: SearchProfilesByVerifiedAccountInput = { + username: "testuser", + page: 2, + per_page: 10, + }; + + const paginatedResults = { + profiles: [], + total_pages: 5, + }; + + (searchProfilesByVerifiedAccount as any).mockResolvedValue(paginatedResults); + + const result = await searchProfilesByVerifiedAccount( + searchParams, + createApiKeyOptions("test-api-key"), + ); + + expect(searchProfilesByVerifiedAccount).toHaveBeenCalledWith( + expect.objectContaining({ + username: "testuser", + page: 2, + per_page: 10, + }), + expect.any(Object), + ); + expect(result).toEqual(paginatedResults); + }); + + it("should handle API errors", async () => { + const searchParams: SearchProfilesByVerifiedAccountInput = { + username: "nonexistent", + }; + + (searchProfilesByVerifiedAccount as any).mockRejectedValue( + new Error("API Error: No profiles found"), + ); + + await expect( + searchProfilesByVerifiedAccount(searchParams, createApiKeyOptions("test-api-key")), + ).rejects.toThrow("API Error: No profiles found"); + }); + + it("should work without optional parameters", async () => { + const searchParams: SearchProfilesByVerifiedAccountInput = { + username: "simple-test", + }; + + const simpleResults = { + profiles: [mockSearchResults.profiles[0]], + total_pages: 1, + }; + + (searchProfilesByVerifiedAccount as any).mockResolvedValue(simpleResults); + + const result = await searchProfilesByVerifiedAccount( + searchParams, + createApiKeyOptions("test-api-key"), + ); + + expect(searchProfilesByVerifiedAccount).toHaveBeenCalledWith( + { username: "simple-test" }, + expect.any(Object), + ); + expect(result).toEqual(simpleResults); + }); + + it("should handle empty results", async () => { + const searchParams: SearchProfilesByVerifiedAccountInput = { + username: "nobody", + service: "nonexistent", + }; + + const emptyResults = { + profiles: [], + total_pages: 0, + }; + + (searchProfilesByVerifiedAccount as any).mockResolvedValue(emptyResults); + + const result = await searchProfilesByVerifiedAccount( + searchParams, + createApiKeyOptions("test-api-key"), + ); + + expect(result).toEqual(emptyResults); + expect(result.profiles).toHaveLength(0); + expect(result.total_pages).toBe(0); + }); + + it("should handle maximum pagination limits", async () => { + const searchParams: SearchProfilesByVerifiedAccountInput = { + username: "popular-user", + page: 1, + per_page: 50, // Maximum allowed + }; + + const maxResults = { + profiles: new Array(50).fill(null).map((_, i) => ({ + id: `profile${i}`, + hash: `hash${i}`, + display_name: `User ${i}`, + username: `user${i}`, + verified_accounts: [], + })), + total_pages: 10, + }; + + (searchProfilesByVerifiedAccount as any).mockResolvedValue(maxResults); + + const result = await searchProfilesByVerifiedAccount( + searchParams, + createApiKeyOptions("test-api-key"), + ); + + expect(result.profiles).toHaveLength(50); + expect(result.total_pages).toBe(10); + }); +}); diff --git a/tests/unit/update-profile.test.ts b/tests/unit/update-profile.test.ts new file mode 100644 index 0000000..5d3a45e --- /dev/null +++ b/tests/unit/update-profile.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, vi } from "vitest"; +import { updateProfile, createOAuthTokenOptions } from "../../src/tools/shared/api-client.js"; +import { requireAuth } from "../../src/tools/shared/auth-utils.js"; +import type { UpdateProfileInput } from "../../src/tools/schemas.js"; +import type { UserProps } from "../../src/auth/types.js"; + +// Mock the API client functions +vi.mock("../../src/tools/shared/api-client.js", async () => { + const actual = await vi.importActual("../../src/tools/shared/api-client.js"); + return { + ...actual, + updateProfile: vi.fn(), + }; +}); + +vi.mock("../../src/tools/shared/auth-utils.js", () => ({ + requireAuth: vi.fn(), +})); + +describe("Update Profile Tool", () => { + const mockProps: UserProps = { + claims: { + ID: 123, + login: "testuser", + email: "test@example.com", + display_name: "Test User", + username: "testuser", + avatar_URL: "https://gravatar.com/avatar/test", + profile_URL: "https://gravatar.com/testuser", + site_count: 1, + verified: true, + }, + tokenSet: { + access_token: "test-oauth-token", + token_type: "Bearer", + }, + }; + + const mockUpdatedProfile = { + id: "test-profile-id", + hash: "abc123", + display_name: "John Doe Updated", + first_name: "John", + last_name: "Doe", + job_title: "Senior Developer", + location: "San Francisco, CA", + description: "Updated bio", + pronunciation: "jon-doe", + pronouns: "he/him", + company: "Tech Corp", + contact_email: "john@example.com", + cell_phone: "+1234567890", + hidden_contact_info: false, + }; + + it("should successfully update profile with valid data", async () => { + const updateData: UpdateProfileInput = { + display_name: "John Doe Updated", + job_title: "Senior Developer", + location: "San Francisco, CA", + }; + + (requireAuth as any).mockReturnValue("test-oauth-token"); + (updateProfile as any).mockResolvedValue(mockUpdatedProfile); + + // Simulate the tool function logic + const accessToken = requireAuth(mockProps); + const result = await updateProfile(updateData, createOAuthTokenOptions(accessToken)); + + expect(requireAuth).toHaveBeenCalledWith(mockProps); + expect(updateProfile).toHaveBeenCalledWith(updateData, { + headers: { Authorization: "Bearer test-oauth-token" }, + baseURL: "https://api.gravatar.com/v3", + }); + expect(result).toEqual(mockUpdatedProfile); + }); + + it("should handle partial updates", async () => { + const updateData: UpdateProfileInput = { + job_title: "CTO", + }; + + (requireAuth as any).mockReturnValue("test-oauth-token"); + (updateProfile as any).mockResolvedValue({ + ...mockUpdatedProfile, + job_title: "CTO", + }); + + const accessToken = requireAuth(mockProps); + const result = await updateProfile(updateData, createOAuthTokenOptions(accessToken)); + + expect(result.job_title).toBe("CTO"); + }); + + it("should handle authentication errors", async () => { + (requireAuth as any).mockImplementation(() => { + throw new Error("Authentication required"); + }); + + expect(() => requireAuth(mockProps)).toThrow("Authentication required"); + }); + + it("should handle API errors", async () => { + const updateData: UpdateProfileInput = { + display_name: "Test User", + }; + + (requireAuth as any).mockReturnValue("test-oauth-token"); + (updateProfile as any).mockRejectedValue(new Error("API Error: Invalid request")); + + const accessToken = requireAuth(mockProps); + + await expect(updateProfile(updateData, createOAuthTokenOptions(accessToken))).rejects.toThrow( + "API Error: Invalid request", + ); + }); + + it("should accept all valid profile fields", async () => { + const updateData: UpdateProfileInput = { + first_name: "Jane", + last_name: "Smith", + display_name: "Jane Smith", + description: "Software engineer with 10 years experience", + pronunciation: "jane-smith", + pronouns: "she/her", + location: "New York, NY", + job_title: "Lead Engineer", + company: "Example Corp", + cell_phone: "+1987654321", + contact_email: "jane@example.com", + hidden_contact_info: true, + }; + + (requireAuth as any).mockReturnValue("test-oauth-token"); + (updateProfile as any).mockResolvedValue({ + ...mockUpdatedProfile, + ...updateData, + }); + + const accessToken = requireAuth(mockProps); + const result = await updateProfile(updateData, createOAuthTokenOptions(accessToken)); + + expect(updateProfile).toHaveBeenCalledWith(updateData, expect.any(Object)); + expect(result).toMatchObject(updateData); + }); + + it("should handle empty string values (field unsetting)", async () => { + const updateData: UpdateProfileInput = { + job_title: "", + company: "", + description: "", + }; + + (requireAuth as any).mockReturnValue("test-oauth-token"); + (updateProfile as any).mockResolvedValue({ + ...mockUpdatedProfile, + job_title: "", + company: "", + description: "", + }); + + const accessToken = requireAuth(mockProps); + const result = await updateProfile(updateData, createOAuthTokenOptions(accessToken)); + + expect(result.job_title).toBe(""); + expect(result.company).toBe(""); + expect(result.description).toBe(""); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index e3d3e69..ff6bb38 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,7 +8,18 @@ export default defineConfig({ coverage: { provider: "v8", reporter: ["text", "json", "html"], - exclude: ["src/generated/**", "tests/**", "**/*.d.ts", "vitest.config.ts"], + include: ["src/**/*.ts"], + exclude: [ + "src/generated/**", + "tests/**", + "**/*.d.ts", + "vitest.config.ts", + "node_modules/**", + "dist/**", + "coverage/**", + "**/*.test.ts", + "**/*.spec.ts", + ], }, }, esbuild: { diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index c77365e..75af53c 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,9 +1,28 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 15dd420dfccac8afafeb181790b9eddb) -// Runtime types generated with workerd@1.20250617.0 2025-03-10 nodejs_compat +// Generated by Wrangler by running `wrangler types` (hash: 014af5d41d8e728e0f98e8c725d7491d) +// Runtime types generated with workerd@1.20250709.0 2025-03-10 nodejs_compat declare namespace Cloudflare { interface Env { - MCP_OBJECT: DurableObjectNamespace; + OAUTH_KV: KVNamespace; + ENVIRONMENT: "development" | "staging" | "production"; + MCP_SERVER_NAME: string; + NODE_ENV: string; + GRAVATAR_API_KEY: string; + DEBUG: string; + OAUTH_ENABLED: string; + OAUTH_AUTHORIZATION_ENDPOINT: string; + OAUTH_TOKEN_ENDPOINT: string; + OAUTH_USERINFO_ENDPOINT: string; + OAUTH_SCOPES: string; + OAUTH_CLIENT_ID: string; + OAUTH_CLIENT_SECRET: string; + OAUTH_REDIRECT_URI: string; + OAUTH_ISSUER_URL: string; + OAUTH_BASE_URL: string; + OAUTH_SIGNING_SECRET: string; + OAUTH_COOKIE_SECRET: string; + MCP_OBJECT: DurableObjectNamespace; + ASSETS: Fetcher; } } interface Env extends Cloudflare.Env {} @@ -336,7 +355,8 @@ declare const performance: Performance; declare const Cloudflare: Cloudflare; declare const origin: string; declare const navigator: Navigator; -type TestController = {} +interface TestController { +} interface ExecutionContext { waitUntil(promise: Promise): void; passThroughOnException(): void; @@ -345,7 +365,7 @@ interface ExecutionContext { type ExportedHandlerFetchHandler = (request: Request>, env: Env, ctx: ExecutionContext) => Response | Promise; type ExportedHandlerTailHandler = (events: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise; type ExportedHandlerTraceHandler = (traces: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise; -type ExportedHandlerTailStreamHandler = (event: TailStream.TailEvent, env: Env, ctx: ExecutionContext) => TailStream.TailEventHandlerType | Promise; +type ExportedHandlerTailStreamHandler = (event: TailStream.TailEvent, env: Env, ctx: ExecutionContext) => TailStream.TailEventHandlerType | Promise; type ExportedHandlerScheduledHandler = (controller: ScheduledController, env: Env, ctx: ExecutionContext) => void | Promise; type ExportedHandlerQueueHandler = (batch: MessageBatch, env: Env, ctx: ExecutionContext) => void | Promise; type ExportedHandlerTestHandler = (controller: TestController, env: Env, ctx: ExecutionContext) => void | Promise; @@ -2007,7 +2027,8 @@ interface TraceItem { interface TraceItemAlarmEventInfo { readonly scheduledTime: Date; } -type TraceItemCustomEventInfo = {} +interface TraceItemCustomEventInfo { +} interface TraceItemScheduledEventInfo { readonly scheduledTime: number; readonly cron: string; @@ -5257,7 +5278,7 @@ type AiModelListType = Record; declare abstract class Ai { aiGatewayLogId: string | null; gateway(gatewayId: string): AiGateway; - autorag(autoragId: string): AutoRAG; + autorag(autoragId?: string): AutoRAG; run(model: Name, inputs: InputOptions, options?: Options): Promise; + /** + * Set a new stored value + */ + set(value: string): Promise; +} interface Hyperdrive { /** * Connect directly to Hyperdrive as if it's your database, returning a TCP socket. @@ -6707,7 +6745,8 @@ declare namespace Rpc { }; } declare namespace Cloudflare { - type Env = {} + interface Env { + } } declare module 'cloudflare:workers' { export type RpcStub = Rpc.Stub; @@ -6936,17 +6975,27 @@ declare namespace TailStream { readonly type: "attributes"; readonly info: Attribute[]; } - interface TailEvent { - readonly traceId: string; + type EventType = Onset | Outcome | Hibernate | SpanOpen | SpanClose | DiagnosticChannelEvent | Exception | Log | Return | Link | Attributes; + interface TailEvent { readonly invocationId: string; readonly spanId: string; readonly timestamp: Date; readonly sequence: number; - readonly event: Onset | Outcome | Hibernate | SpanOpen | SpanClose | DiagnosticChannelEvent | Exception | Log | Return | Link | Attributes; + readonly event: Event; } - type TailEventHandler = (event: TailEvent) => void | Promise; - type TailEventHandlerName = "outcome" | "hibernate" | "spanOpen" | "spanClose" | "diagnosticChannel" | "exception" | "log" | "return" | "link" | "attributes"; - type TailEventHandlerObject = Record; + type TailEventHandler = (event: TailEvent) => void | Promise; + type TailEventHandlerObject = { + outcome?: TailEventHandler; + hibernate?: TailEventHandler; + spanOpen?: TailEventHandler; + spanClose?: TailEventHandler; + diagnosticChannel?: TailEventHandler; + exception?: TailEventHandler; + log?: TailEventHandler; + return?: TailEventHandler; + link?: TailEventHandler; + attributes?: TailEventHandler; + }; type TailEventHandlerType = TailEventHandler | TailEventHandlerObject; } // Copyright (c) 2022-2023 Cloudflare, Inc. diff --git a/wrangler.jsonc b/wrangler.jsonc index 838403c..c41c83a 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -26,6 +26,13 @@ } ] }, + "kv_namespaces": [ + { + "binding": "OAUTH_KV", + "id": "42a2c2dab0164bb09019745c907a5d09", + "preview_id": "42a2c2dab0164bb09019745c907a5d09" + } + ], "observability": { "enabled": true }, @@ -39,8 +46,23 @@ */ "vars": { "ENVIRONMENT": "development", - "MCP_SERVER_NAME": "mcp-server-gravatar-development" + "MCP_SERVER_NAME": "mcp-server-gravatar-development", + "NODE_ENV": "development", + "OAUTH_AUTHORIZATION_ENDPOINT": "https://public-api.wordpress.com/oauth2/authorize", + "OAUTH_TOKEN_ENDPOINT": "https://public-api.wordpress.com/oauth2/token", + "OAUTH_USERINFO_ENDPOINT": "https://public-api.wordpress.com/rest/v1.1/me", + "OAUTH_SCOPES": "auth gravatar-profile:read gravatar-profile:manage", + "OAUTH_REDIRECT_URI": "http://localhost:8787/callback" }, + /** + * OAuth Secrets - Set using: npx wrangler secret put + * - OAUTH_CLIENT_ID: WordPress.com application client ID + * - OAUTH_CLIENT_SECRET: WordPress.com application client secret + * - OAUTH_SIGNING_SECRET: Random 32+ character string for JWT signing + * - OAUTH_COOKIE_SECRET: Random 32+ character string for cookie encryption + * + * Note: OAUTH_REDIRECT_URI is now configured as an environment variable above + */ "env": { "staging": { "name": "mcp-server-gravatar-staging", @@ -52,9 +74,22 @@ } ] }, + "kv_namespaces": [ + { + "binding": "OAUTH_KV", + "id": "7f47118d3e874c68a97d9abc26a2e847", + "preview_id": "7f47118d3e874c68a97d9abc26a2e847" + } + ], "vars": { "ENVIRONMENT": "staging", - "MCP_SERVER_NAME": "mcp-server-gravatar-staging" + "MCP_SERVER_NAME": "mcp-server-gravatar-staging", + "NODE_ENV": "staging", + "OAUTH_AUTHORIZATION_ENDPOINT": "https://public-api.wordpress.com/oauth2/authorize", + "OAUTH_TOKEN_ENDPOINT": "https://public-api.wordpress.com/oauth2/token", + "OAUTH_USERINFO_ENDPOINT": "https://public-api.wordpress.com/rest/v1.1/me", + "OAUTH_SCOPES": "auth gravatar-profile:read gravatar-profile:manage", + "OAUTH_REDIRECT_URI": "https://mcp-server-gravatar-staging.a8cai.workers.dev/callback" } }, "production": { @@ -67,9 +102,22 @@ } ] }, + "kv_namespaces": [ + { + "binding": "OAUTH_KV", + "id": "bdaaa6513a74456da8f789dee326c55b", + "preview_id": "bdaaa6513a74456da8f789dee326c55b" + } + ], "vars": { "ENVIRONMENT": "production", - "MCP_SERVER_NAME": "mcp-server-gravatar" + "MCP_SERVER_NAME": "mcp-server-gravatar", + "NODE_ENV": "production", + "OAUTH_AUTHORIZATION_ENDPOINT": "https://public-api.wordpress.com/oauth2/authorize", + "OAUTH_TOKEN_ENDPOINT": "https://public-api.wordpress.com/oauth2/token", + "OAUTH_USERINFO_ENDPOINT": "https://public-api.wordpress.com/rest/v1.1/me", + "OAUTH_SCOPES": "auth gravatar-profile:read", + "OAUTH_REDIRECT_URI": "https://mcp-server-gravatar-production.a8cai.workers.dev/callback" } } },