From 4d44c3fe8c808a54ad8c68db594e8c675d9c3806 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Fri, 25 Jul 2025 14:46:28 -0500 Subject: [PATCH 01/34] Ignore the .mcp.json configuration (for now) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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 From d6a2ea7fdae3e170a417d859dd0f71f5f2c9e0b4 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Fri, 25 Jul 2025 15:00:33 -0500 Subject: [PATCH 02/34] Add @cloudflare/workers-oauth-provider package dependency --- package-lock.json | 10 ++++++++++ package.json | 1 + 2 files changed, 11 insertions(+) diff --git a/package-lock.json b/package-lock.json index c3b306f..f84e5d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "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", "zod": "^3.25.67" @@ -561,6 +562,15 @@ "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", diff --git a/package.json b/package.json index 33bdc16..184bfa4 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "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", "zod": "^3.25.67" From 522cd008ccdac278e2c3e221a81bad215fe8d5ec Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Fri, 25 Jul 2025 15:06:05 -0500 Subject: [PATCH 03/34] Add OAuth Envs --- src/common/env.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/common/env.ts b/src/common/env.ts index befc8d5..f3af637 100644 --- a/src/common/env.ts +++ b/src/common/env.ts @@ -8,4 +8,17 @@ export function getEnv() { export interface Env { ENVIRONMENT: "development" | "staging" | "production"; MCP_SERVER_NAME: string; + GRAVATAR_API_KEY?: string; + ASSETS: Fetcher; + // OAuth2 configuration + OAUTH_CLIENT_ID?: string; + OAUTH_CLIENT_SECRET?: string; + OAUTH_REDIRECT_URI?: string; + OAUTH_AUTHORIZATION_ENDPOINT?: string; + OAUTH_TOKEN_ENDPOINT?: string; + OAUTH_USERINFO_ENDPOINT?: string; + OAUTH_SCOPES?: string; + // OAuth provider configuration + OAUTH_SIGNING_SECRET?: string; + OAUTH_COOKIE_SECRET?: string; } From 5ccdfdaa81b696914762c09873719bcb004e0a86 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Fri, 25 Jul 2025 15:06:30 -0500 Subject: [PATCH 04/34] Create OAuthProvider --- src/auth/oauth-config.ts | 43 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/auth/oauth-config.ts diff --git a/src/auth/oauth-config.ts b/src/auth/oauth-config.ts new file mode 100644 index 0000000..fd2cd46 --- /dev/null +++ b/src/auth/oauth-config.ts @@ -0,0 +1,43 @@ +import { OAuthProvider } from "@cloudflare/workers-oauth-provider"; +import type { Env } from "../common/env.js"; + +export function createOAuthProvider(env: Env): OAuthProvider | null { + // Check if all required OAuth environment variables are present + if ( + !env.OAUTH_CLIENT_ID || + !env.OAUTH_CLIENT_SECRET || + !env.OAUTH_REDIRECT_URI || + !env.OAUTH_SIGNING_SECRET || + !env.OAUTH_COOKIE_SECRET || + !env.OAUTH_AUTHORIZATION_ENDPOINT || + !env.OAUTH_TOKEN_ENDPOINT || + !env.OAUTH_USERINFO_ENDPOINT + ) { + return null; // OAuth not configured + } + + // Parse scopes (comma-separated string to array) + const scopes = env.OAUTH_SCOPES ? env.OAUTH_SCOPES.split(",").map((s) => s.trim()) : ["auth"]; + + return new OAuthProvider({ + // OAuth provider configuration from environment + signingSecret: env.OAUTH_SIGNING_SECRET, + cookieSecret: env.OAUTH_COOKIE_SECRET, + + // OAuth2 endpoints from environment + authorizationEndpoint: env.OAUTH_AUTHORIZATION_ENDPOINT, + tokenEndpoint: env.OAUTH_TOKEN_ENDPOINT, + userinfoEndpoint: env.OAUTH_USERINFO_ENDPOINT, + + // Client configuration from environment + clientId: env.OAUTH_CLIENT_ID, + clientSecret: env.OAUTH_CLIENT_SECRET, + redirectUri: env.OAUTH_REDIRECT_URI, + + // Scopes from environment + scopes: scopes, + + // PKCE configuration (recommended for security) + pkce: true, + }); +} From 880c814499621627ba7c4e90263d780d5beccdaa Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Fri, 25 Jul 2025 15:07:14 -0500 Subject: [PATCH 05/34] Integrate OAuth authorization into MCP server request handling --- src/index.ts | 58 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index c88d621..6500a4a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,11 +6,11 @@ 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 { createOAuthProvider } from "./auth/oauth-config.js"; +import type { Env as ConfigEnv } from "./common/env.js"; + +// Re-export the Env interface from common/env.ts +export type Env = ConfigEnv; // Define the MCP agent with Gravatar tools export class GravatarMcpServer extends McpAgent { @@ -56,18 +56,62 @@ export class GravatarMcpServer extends McpAgent { } export default { - fetch(request: Request, env: Env, ctx: ExecutionContext) { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { const { pathname } = new URL(request.url); + // Handle OAuth routes if OAuth is configured + const oauthProvider = createOAuthProvider(env); + if (oauthProvider) { + // OAuth authorization endpoint + if (pathname.startsWith("/oauth/authorize")) { + return await oauthProvider.authorize(request); + } + + // OAuth callback endpoint + if (pathname.startsWith("/oauth/callback")) { + return await oauthProvider.callback(request); + } + + // OAuth token endpoint + if (pathname.startsWith("/oauth/token")) { + return await oauthProvider.token(request); + } + } + + // MCP Server-Sent Events endpoint if (pathname.startsWith("/sse")) { return GravatarMcpServer.serveSSE("/sse").fetch(request, env, ctx); } + // MCP WebSocket/HTTP endpoint if (pathname.startsWith("/mcp")) { return GravatarMcpServer.serve("/mcp").fetch(request, env, ctx); } - // Optional: Handle root path or other routes + // Root path - provide basic information + if (pathname === "/") { + return new Response( + JSON.stringify({ + name: "Gravatar MCP Server", + version: "0.1.0", + endpoints: { + mcp: "/mcp", + sse: "/sse", + ...(oauthProvider + ? { + oauth_authorize: "/oauth/authorize", + oauth_callback: "/oauth/callback", + oauth_token: "/oauth/token", + } + : {}), + }, + }), + { + headers: { "Content-Type": "application/json" }, + }, + ); + } + return new Response("Not Found", { status: 404 }); }, }; From 8da12383e072c4d45a74a4a933260a9efc95419e Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Fri, 25 Jul 2025 16:20:48 -0500 Subject: [PATCH 06/34] Add skeleton of auth.ts --- package-lock.json | 20 ++ package.json | 2 + src/auth.ts | 557 ++++++++++++++++++++++++++++++++++++++++++++++ src/common/env.ts | 4 + src/types.ts | 23 ++ 5 files changed, 606 insertions(+) create mode 100644 src/auth.ts create mode 100644 src/types.ts diff --git a/package-lock.json b/package-lock.json index f84e5d6..0c96ddf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "@cloudflare/workers-oauth-provider": "^0.0.5", "@modelcontextprotocol/sdk": "1.15.1", "agents": "^0.0.100", + "hono": "^4.8.8", + "oauth4webapi": "^3.6.0", "zod": "^3.25.67" }, "devDependencies": { @@ -4318,6 +4320,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", @@ -5468,6 +5479,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", diff --git a/package.json b/package.json index 184bfa4..1c0bc7a 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "@cloudflare/workers-oauth-provider": "^0.0.5", "@modelcontextprotocol/sdk": "1.15.1", "agents": "^0.0.100", + "hono": "^4.8.8", + "oauth4webapi": "^3.6.0", "zod": "^3.25.67" }, "devDependencies": { diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..0bfe682 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,557 @@ +import { getEnv, type Env } from "./common/env.js"; +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"; + +const env = getEnv(); + +type WordPressAuthRequest = { + mcpAuthRequest: AuthRequest; + codeVerifier: string; + codeChallenge: string; + nonce: string; + transactionState: string; + consentToken: string; +}; + +export async function getOidcConfig({ + issuer, + client_id, + client_secret, +}: { + issuer: string; + client_id: string; + client_secret: string; +}) { + const as = await oauth + .discoveryRequest(new URL(issuer), { algorithm: "oidc" }) + .then((response) => oauth.processDiscoveryResponse(new URL(issuer), response)); + + const client: oauth.Client = { client_id }; + const clientAuth = oauth.ClientSecretPost(client_secret); + + return { as, 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: env.NODE_ENV !== "development" ? "none" : "lax", + secure: 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 = (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 { as } = await getOidcConfig({ + issuer: `https://${env.OAUTH_DOMAIN}/`, + client_id: env.OAUTH_CLIENT_ID, + client_secret: env.OAUTH_CLIENT_SECRET, + }); + + // Redirect to Auth0's authorization endpoint + const authorizationUrl = new URL(as.authorization_endpoint!); + authorizationUrl.searchParams.set("client_id", env.OAUTH_CLIENT_ID); + authorizationUrl.searchParams.set("redirect_uri", new URL("/callback", c.req.url).href); + authorizationUrl.searchParams.set("response_type", "code"); + authorizationUrl.searchParams.set("audience", env.OAUTH_AUDIENCE); + authorizationUrl.searchParams.set("scope", env.OAUTH_SCOPES); + authorizationUrl.searchParams.set("code_challenge", wordPressOAuthAuthRequest.codeChallenge); + authorizationUrl.searchParams.set("code_challenge_method", "S256"); + authorizationUrl.searchParams.set("nonce", wordPressOAuthAuthRequest.nonce); + authorizationUrl.searchParams.set("state", transactionState); + return c.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 { as, client, clientAuth } = await getOidcConfig({ + client_id: env.OAUTH_CLIENT_ID, + client_secret: env.OAUTH_CLIENT_SECRET, + issuer: `https://${env.OAUTH_DOMAIN}/`, + }); + + // Perform the Code Exchange + const params = oauth.validateAuthResponse( + as, + client, + new URL(c.req.url), + wordPressOAuthAuthRequest.transactionState, + ); + const response = await oauth.authorizationCodeGrantRequest( + as, + client, + clientAuth, + params, + new URL("/callback", c.req.url).href, + wordPressOAuthAuthRequest.codeVerifier, + ); + + // Process the response + const result = await oauth.processAuthorizationCodeResponse(as, client, response, { + expectedNonce: wordPressOAuthAuthRequest.nonce, + requireIdToken: true, + }); + + // Get the claims from the id_token + const claims = oauth.getValidatedIdTokenClaims(result); + if (!claims) { + return c.text("Received invalid id_token from Auth0", 400); + } + + // Complete the authorization + const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({ + metadata: { + label: claims.name || claims.email || claims.sub, + }, + props: { + claims: claims, + tokenSet: { + accessToken: result.access_token, + accessTokenTTL: result.expires_in, + idToken: result.id_token, + refreshToken: result.refresh_token, + }, + } as UserProps, + request: wordPressOAuthAuthRequest.mcpAuthRequest, + scope: wordPressOAuthAuthRequest.mcpAuthRequest.scope, + userId: claims.sub!, + }); + + 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 Auth0. + if (options.grantType === "authorization_code") { + return { + accessTokenTTL: options.props.tokenSet.accessTokenTTL, + newProps: { + ...options.props, + }, + }; + } + + if (options.grantType === "refresh_token") { + const wordPressOAuthRefreshToken = options.props.tokenSet.refreshToken; + if (!wordPressOAuthRefreshToken) { + throw new Error("No Auth0 refresh token found"); + } + + const { as, client, clientAuth } = await getOidcConfig({ + client_id: env.OAUTH_CLIENT_ID, + client_secret: env.OAUTH_CLIENT_SECRET, + issuer: `https://${env.OAUTH_DOMAIN}/`, + }); + + // Perform the refresh token exchange with Auth0. + const response = await oauth.refreshTokenGrantRequest( + as, + client, + clientAuth, + wordPressOAuthRefreshToken, + ); + const refreshTokenResponse = await oauth.processRefreshTokenResponse(as, client, response); + + // Get the claims from the id_token + const claims = oauth.getValidatedIdTokenClaims(refreshTokenResponse); + if (!claims) { + throw new Error("Received invalid id_token from WordPress OAuth"); + } + + // Store the new token set and claims. + return { + accessTokenTTL: refreshTokenResponse.expires_in, + newProps: { + ...options.props, + claims: claims, + tokenSet: { + accessToken: refreshTokenResponse.access_token, + accessTokenTTL: refreshTokenResponse.expires_in, + idToken: refreshTokenResponse.id_token, + refreshToken: refreshTokenResponse.refresh_token || wordPressOAuthRefreshToken, + }, + }, + }; + } +} + +/** + * 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/common/env.ts b/src/common/env.ts index f3af637..4642776 100644 --- a/src/common/env.ts +++ b/src/common/env.ts @@ -10,7 +10,10 @@ export interface Env { MCP_SERVER_NAME: string; GRAVATAR_API_KEY?: string; ASSETS: Fetcher; + NODE_ENV: string; // OAuth2 configuration + OAUTH_PROVIDER: string; + OAUTH_DOMAIN: string; OAUTH_CLIENT_ID?: string; OAUTH_CLIENT_SECRET?: string; OAUTH_REDIRECT_URI?: string; @@ -18,6 +21,7 @@ export interface Env { OAUTH_TOKEN_ENDPOINT?: string; OAUTH_USERINFO_ENDPOINT?: string; OAUTH_SCOPES?: string; + OAUTH_AUDIENCE?: string; // OAuth provider configuration OAUTH_SIGNING_SECRET?: string; OAUTH_COOKIE_SECRET?: string; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..ee6c20e --- /dev/null +++ b/src/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 { + claims: WordPressUser; + tokenSet: WordPressTokenSet; +} From 8efd911305b4a22e4d6ba94c28473d3d2435c596 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Fri, 25 Jul 2025 21:13:21 -0500 Subject: [PATCH 07/34] Implement the Cloudflare recommended pattern --- src/auth.ts | 254 +++++++++++++++++++++++++++----------- src/auth/oauth-config.ts | 43 ------- src/common/env.ts | 5 +- src/index.ts | 104 +++++++++------- worker-configuration.d.ts | 79 +++++++++--- wrangler.jsonc | 54 +++++++- 6 files changed, 358 insertions(+), 181 deletions(-) delete mode 100644 src/auth/oauth-config.ts diff --git a/src/auth.ts b/src/auth.ts index 0bfe682..07a298c 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,4 +1,4 @@ -import { getEnv, type Env } from "./common/env.js"; +import { env } from "cloudflare:workers"; import type { AuthRequest, OAuthHelpers, @@ -12,7 +12,29 @@ import * as oauth from "oauth4webapi"; import type { UserProps } from "./types.js"; -const env = getEnv(); +/** + * 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; @@ -23,23 +45,34 @@ type WordPressAuthRequest = { consentToken: string; }; -export async function getOidcConfig({ - issuer, +export function getWordPressOAuthConfig({ client_id, client_secret, + authorization_endpoint, + token_endpoint, + userinfo_endpoint, }: { - issuer: string; client_id: string; client_secret: string; + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint?: string; }) { - const as = await oauth - .discoveryRequest(new URL(issuer), { algorithm: "oidc" }) - .then((response) => oauth.processDiscoveryResponse(new URL(issuer), response)); + 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 client: oauth.Client = { client_id }; const clientAuth = oauth.ClientSecretPost(client_secret); - return { as, client, clientAuth }; + return { authorizationServer, client, clientAuth }; } /** @@ -82,15 +115,15 @@ export async function authorize(c: Context<{ Bindings: Env & { OAUTH_PROVIDER: O httpOnly: true, maxAge: 60 * 60 * 1, path: "/", - sameSite: env.NODE_ENV !== "development" ? "none" : "lax", - secure: env.NODE_ENV !== "development", // 1 hour + 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 = (env.OAUTH_SCOPES || "").split(" "); + const requestedScopes = (c.env.OAUTH_SCOPES || "").split(" "); // Render the consent screen with CSRF protection return c.html( @@ -163,24 +196,26 @@ export async function confirmConsent( return c.redirect(redirectUri.toString()); } - const { as } = await getOidcConfig({ - issuer: `https://${env.OAUTH_DOMAIN}/`, - client_id: env.OAUTH_CLIENT_ID, - client_secret: env.OAUTH_CLIENT_SECRET, + 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 Auth0's authorization endpoint - const authorizationUrl = new URL(as.authorization_endpoint!); - authorizationUrl.searchParams.set("client_id", env.OAUTH_CLIENT_ID); - authorizationUrl.searchParams.set("redirect_uri", new URL("/callback", c.req.url).href); + // 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("audience", env.OAUTH_AUDIENCE); - authorizationUrl.searchParams.set("scope", env.OAUTH_SCOPES); + 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("nonce", wordPressOAuthAuthRequest.nonce); authorizationUrl.searchParams.set("state", transactionState); - return c.redirect(authorizationUrl.href); + + // Use Response.redirect instead of Hono's c.redirect to avoid double encoding + return Response.redirect(authorizationUrl.href); } /** @@ -214,57 +249,77 @@ export async function callback(c: Context<{ Bindings: Env & { OAUTH_PROVIDER: OA path: "/", }); - const { as, client, clientAuth } = await getOidcConfig({ - client_id: env.OAUTH_CLIENT_ID, - client_secret: env.OAUTH_CLIENT_SECRET, - issuer: `https://${env.OAUTH_DOMAIN}/`, + 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( - as, + authorizationServer, client, new URL(c.req.url), wordPressOAuthAuthRequest.transactionState, ); const response = await oauth.authorizationCodeGrantRequest( - as, + authorizationServer, client, clientAuth, params, - new URL("/callback", c.req.url).href, + c.env.OAUTH_REDIRECT_URI!, wordPressOAuthAuthRequest.codeVerifier, ); - // Process the response - const result = await oauth.processAuthorizationCodeResponse(as, client, response, { - expectedNonce: wordPressOAuthAuthRequest.nonce, - requireIdToken: true, - }); + // Process the response (WordPress OAuth2 doesn't use ID tokens) + const result = await oauth.processAuthorizationCodeResponse( + authorizationServer, + client, + response, + ); - // Get the claims from the id_token - const claims = oauth.getValidatedIdTokenClaims(result); - if (!claims) { - return c.text("Received invalid id_token from Auth0", 400); + // Check for OAuth error (result would be an error object if there was one) + if ("error" in result) { + return c.text(`OAuth error: ${result.error}`, 400); + } + + // 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 env.OAUTH_PROVIDER.completeAuthorization({ + const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({ metadata: { - label: claims.name || claims.email || claims.sub, + label: userInfo.display_name || userInfo.email || userInfo.login, }, props: { - claims: claims, + claims: userInfo, tokenSet: { - accessToken: result.access_token, - accessTokenTTL: result.expires_in, - idToken: result.id_token, - refreshToken: result.refresh_token, + 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: claims.sub!, + userId: userInfo.ID?.toString() || userInfo.login, }); return Response.redirect(redirectTo); @@ -279,10 +334,10 @@ 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 Auth0. + // by the MCP Server has the same TTL as the one issued by WordPress. if (options.grantType === "authorization_code") { return { - accessTokenTTL: options.props.tokenSet.accessTokenTTL, + accessTokenTTL: options.props.tokenSet.expires_in, newProps: { ...options.props, }, @@ -290,30 +345,49 @@ export async function tokenExchangeCallback( } if (options.grantType === "refresh_token") { - const wordPressOAuthRefreshToken = options.props.tokenSet.refreshToken; - if (!wordPressOAuthRefreshToken) { - throw new Error("No Auth0 refresh token found"); + const wordPressRefreshToken = options.props.tokenSet.refresh_token; + if (!wordPressRefreshToken) { + throw new Error("No WordPress refresh token found"); } - const { as, client, clientAuth } = await getOidcConfig({ - client_id: env.OAUTH_CLIENT_ID, - client_secret: env.OAUTH_CLIENT_SECRET, - issuer: `https://${env.OAUTH_DOMAIN}/`, + 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 Auth0. + // Perform the refresh token exchange with WordPress. const response = await oauth.refreshTokenGrantRequest( - as, + authorizationServer, client, clientAuth, - wordPressOAuthRefreshToken, + wordPressRefreshToken, + ); + const refreshTokenResponse = await oauth.processRefreshTokenResponse( + authorizationServer, + client, + response, ); - const refreshTokenResponse = await oauth.processRefreshTokenResponse(as, client, response); - // Get the claims from the id_token - const claims = oauth.getValidatedIdTokenClaims(refreshTokenResponse); - if (!claims) { - throw new Error("Received invalid id_token from WordPress OAuth"); + // 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. @@ -321,18 +395,56 @@ export async function tokenExchangeCallback( accessTokenTTL: refreshTokenResponse.expires_in, newProps: { ...options.props, - claims: claims, + claims: userInfo, tokenSet: { - accessToken: refreshTokenResponse.access_token, - accessTokenTTL: refreshTokenResponse.expires_in, - idToken: refreshTokenResponse.id_token, - refreshToken: refreshTokenResponse.refresh_token || wordPressOAuthRefreshToken, + 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 */ diff --git a/src/auth/oauth-config.ts b/src/auth/oauth-config.ts deleted file mode 100644 index fd2cd46..0000000 --- a/src/auth/oauth-config.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { OAuthProvider } from "@cloudflare/workers-oauth-provider"; -import type { Env } from "../common/env.js"; - -export function createOAuthProvider(env: Env): OAuthProvider | null { - // Check if all required OAuth environment variables are present - if ( - !env.OAUTH_CLIENT_ID || - !env.OAUTH_CLIENT_SECRET || - !env.OAUTH_REDIRECT_URI || - !env.OAUTH_SIGNING_SECRET || - !env.OAUTH_COOKIE_SECRET || - !env.OAUTH_AUTHORIZATION_ENDPOINT || - !env.OAUTH_TOKEN_ENDPOINT || - !env.OAUTH_USERINFO_ENDPOINT - ) { - return null; // OAuth not configured - } - - // Parse scopes (comma-separated string to array) - const scopes = env.OAUTH_SCOPES ? env.OAUTH_SCOPES.split(",").map((s) => s.trim()) : ["auth"]; - - return new OAuthProvider({ - // OAuth provider configuration from environment - signingSecret: env.OAUTH_SIGNING_SECRET, - cookieSecret: env.OAUTH_COOKIE_SECRET, - - // OAuth2 endpoints from environment - authorizationEndpoint: env.OAUTH_AUTHORIZATION_ENDPOINT, - tokenEndpoint: env.OAUTH_TOKEN_ENDPOINT, - userinfoEndpoint: env.OAUTH_USERINFO_ENDPOINT, - - // Client configuration from environment - clientId: env.OAUTH_CLIENT_ID, - clientSecret: env.OAUTH_CLIENT_SECRET, - redirectUri: env.OAUTH_REDIRECT_URI, - - // Scopes from environment - scopes: scopes, - - // PKCE configuration (recommended for security) - pkce: true, - }); -} diff --git a/src/common/env.ts b/src/common/env.ts index 4642776..c09f93e 100644 --- a/src/common/env.ts +++ b/src/common/env.ts @@ -12,8 +12,6 @@ export interface Env { ASSETS: Fetcher; NODE_ENV: string; // OAuth2 configuration - OAUTH_PROVIDER: string; - OAUTH_DOMAIN: string; OAUTH_CLIENT_ID?: string; OAUTH_CLIENT_SECRET?: string; OAUTH_REDIRECT_URI?: string; @@ -21,8 +19,9 @@ export interface Env { OAUTH_TOKEN_ENDPOINT?: string; OAUTH_USERINFO_ENDPOINT?: string; OAUTH_SCOPES?: string; - OAUTH_AUDIENCE?: string; // OAuth provider configuration OAUTH_SIGNING_SECRET?: string; OAUTH_COOKIE_SECRET?: string; + // OAuth KV namespace binding (required by workers-oauth-provider) + OAUTH_KV?: KVNamespace; } diff --git a/src/index.ts b/src/index.ts index 6500a4a..5f12a9b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,8 +6,16 @@ import { registerProfileTools } from "./tools/profiles.js"; import { registerAvatarImageTools } from "./tools/avatar-images.js"; import { registerExperimentalTools } from "./tools/experimental.js"; -import { createOAuthProvider } from "./auth/oauth-config.js"; import type { Env as ConfigEnv } from "./common/env.js"; +import { + authorize, + callback, + confirmConsent, + tokenExchangeCallback, + registerClient, +} from "./auth.js"; +import { Hono } from "hono"; +import OAuthProvider from "@cloudflare/workers-oauth-provider"; // Re-export the Env interface from common/env.ts export type Env = ConfigEnv; @@ -55,63 +63,67 @@ 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({ + // @ts-expect-error - Type issues with the OAuth provider library + apiHandler: GravatarMcpServer.mount("/sse"), + apiRoute: "/sse", + authorizeEndpoint: "/authorize", + clientRegistrationEndpoint: "/register", + tokenEndpoint: "/token", + // @ts-expect-error - Type issues with the OAuth provider library + defaultHandler: app, + tokenExchangeCallback: (options: any) => tokenExchangeCallback(options), + }); +} + export default { - async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { - const { pathname } = new URL(request.url); + fetch(request: Request, env: Env, ctx: ExecutionContext) { + const oauthProvider = createOAuthProviderIfConfigured(env); - // Handle OAuth routes if OAuth is configured - const oauthProvider = createOAuthProvider(env); if (oauthProvider) { - // OAuth authorization endpoint - if (pathname.startsWith("/oauth/authorize")) { - return await oauthProvider.authorize(request); - } - - // OAuth callback endpoint - if (pathname.startsWith("/oauth/callback")) { - return await oauthProvider.callback(request); - } - - // OAuth token endpoint - if (pathname.startsWith("/oauth/token")) { - return await oauthProvider.token(request); - } + return oauthProvider.fetch(request, env, ctx); } - // MCP Server-Sent Events endpoint + // Fallback to basic MCP server without OAuth + const { pathname } = new URL(request.url); + if (pathname.startsWith("/sse")) { return GravatarMcpServer.serveSSE("/sse").fetch(request, env, ctx); } - // MCP WebSocket/HTTP endpoint if (pathname.startsWith("/mcp")) { return GravatarMcpServer.serve("/mcp").fetch(request, env, ctx); } - // Root path - provide basic information - if (pathname === "/") { - return new Response( - JSON.stringify({ - name: "Gravatar MCP Server", - version: "0.1.0", - endpoints: { - mcp: "/mcp", - sse: "/sse", - ...(oauthProvider - ? { - oauth_authorize: "/oauth/authorize", - oauth_callback: "/oauth/callback", - oauth_token: "/oauth/token", - } - : {}), - }, - }), - { - headers: { "Content-Type": "application/json" }, - }, - ); - } - - return new Response("Not Found", { status: 404 }); + return app.fetch(request, env, ctx); }, }; 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..397b54f 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", + "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", + "OAUTH_REDIRECT_URI": "https://mcp-server-gravatar-staging.your-account.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.your-account.workers.dev/callback" } } }, From 5f9a284d18c1f80de1279405b9c58903e30d42b0 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Fri, 25 Jul 2025 21:18:24 -0500 Subject: [PATCH 08/34] Remove env.ts, use generated types from Cloudflare wrangler --- src/common/env.ts | 27 --------------------------- src/config/server-config.ts | 4 +--- src/index.ts | 4 ---- 3 files changed, 1 insertion(+), 34 deletions(-) delete mode 100644 src/common/env.ts diff --git a/src/common/env.ts b/src/common/env.ts deleted file mode 100644 index c09f93e..0000000 --- a/src/common/env.ts +++ /dev/null @@ -1,27 +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; - GRAVATAR_API_KEY?: string; - ASSETS: Fetcher; - NODE_ENV: string; - // OAuth2 configuration - OAUTH_CLIENT_ID?: string; - OAUTH_CLIENT_SECRET?: string; - OAUTH_REDIRECT_URI?: string; - OAUTH_AUTHORIZATION_ENDPOINT?: string; - OAUTH_TOKEN_ENDPOINT?: string; - OAUTH_USERINFO_ENDPOINT?: string; - OAUTH_SCOPES?: string; - // OAuth provider configuration - OAUTH_SIGNING_SECRET?: string; - OAUTH_COOKIE_SECRET?: string; - // OAuth KV namespace binding (required by workers-oauth-provider) - OAUTH_KV?: KVNamespace; -} diff --git a/src/config/server-config.ts b/src/config/server-config.ts index 50d1aec..a4d07ee 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; diff --git a/src/index.ts b/src/index.ts index 5f12a9b..f3327a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,6 @@ import { registerProfileTools } from "./tools/profiles.js"; import { registerAvatarImageTools } from "./tools/avatar-images.js"; import { registerExperimentalTools } from "./tools/experimental.js"; -import type { Env as ConfigEnv } from "./common/env.js"; import { authorize, callback, @@ -17,9 +16,6 @@ import { import { Hono } from "hono"; import OAuthProvider from "@cloudflare/workers-oauth-provider"; -// Re-export the Env interface from common/env.ts -export type Env = ConfigEnv; - // Define the MCP agent with Gravatar tools export class GravatarMcpServer extends McpAgent { server = new McpServer(getServerInfo()); From 4f35e1eaa69098b2c638b62bcdea37e503ff9db7 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Fri, 25 Jul 2025 21:26:40 -0500 Subject: [PATCH 09/34] Organized OAuth source files --- src/{auth.ts => auth/index.ts} | 0 src/{ => auth}/types.ts | 0 src/index.ts | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) rename src/{auth.ts => auth/index.ts} (100%) rename src/{ => auth}/types.ts (100%) diff --git a/src/auth.ts b/src/auth/index.ts similarity index 100% rename from src/auth.ts rename to src/auth/index.ts diff --git a/src/types.ts b/src/auth/types.ts similarity index 100% rename from src/types.ts rename to src/auth/types.ts diff --git a/src/index.ts b/src/index.ts index f3327a7..35f0a7e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ import { confirmConsent, tokenExchangeCallback, registerClient, -} from "./auth.js"; +} from "./auth/index.js"; import { Hono } from "hono"; import OAuthProvider from "@cloudflare/workers-oauth-provider"; From 3a505810cc55c6adb8e9d1217f13866b6423829d Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Fri, 25 Jul 2025 21:47:04 -0500 Subject: [PATCH 10/34] Add support for fetching my profile with OAuth token --- src/config/server-config.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/config/server-config.ts b/src/config/server-config.ts index a4d07ee..b689409 100644 --- a/src/config/server-config.ts +++ b/src/config/server-config.ts @@ -105,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, + }; +} From 98628b808181a2329aba9e3d671a2f3508d55491 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sat, 26 Jul 2025 13:47:59 -0500 Subject: [PATCH 11/34] Update workers-types --- package-lock.json | 38 +++++++++++++++++++------------------- package.json | 2 +- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0c96ddf..1023953 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ }, "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", @@ -574,9 +574,9 @@ } }, "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": { @@ -2887,6 +2887,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", @@ -4046,9 +4058,9 @@ } }, "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==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "dev": true, "license": "MIT", "optional": true, @@ -5652,18 +5664,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", diff --git a/package.json b/package.json index 1c0bc7a..9bb5f97 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ }, "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", From 9f2e0ebecf30812a0393938101ec2b53975322a7 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sat, 26 Jul 2025 13:49:01 -0500 Subject: [PATCH 12/34] Update GravatarMcpServer with UserProps --- src/auth/types.ts | 2 +- src/index.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/auth/types.ts b/src/auth/types.ts index ee6c20e..99fc7e9 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -17,7 +17,7 @@ export interface WordPressTokenSet { token_type: string; } -export interface UserProps { +export interface UserProps extends Record { claims: WordPressUser; tokenSet: WordPressTokenSet; } diff --git a/src/index.ts b/src/index.ts index 35f0a7e..2d5967b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,11 +13,12 @@ import { tokenExchangeCallback, registerClient, } from "./auth/index.js"; +import type { UserProps } from "./auth/types.js"; import { Hono } from "hono"; import OAuthProvider from "@cloudflare/workers-oauth-provider"; // Define the MCP agent with Gravatar tools -export class GravatarMcpServer extends McpAgent { +export class GravatarMcpServer extends McpAgent { server = new McpServer(getServerInfo()); async init() { From 09dec7125a26d7b8a2808ca3ab750f7ffa580190 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sat, 26 Jul 2025 13:53:02 -0500 Subject: [PATCH 13/34] Add tool: get_my_profile --- src/index.ts | 17 +++++++---- src/tools/profiles.ts | 54 +++++++++++++++++++++++++++++++++- src/tools/shared/api-client.ts | 9 ++++++ 3 files changed, 73 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2d5967b..b6fb820 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,8 @@ 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 { server = new McpServer(getServerInfo()); @@ -90,15 +92,18 @@ function createOAuthProviderIfConfigured(env: Env) { } return new OAuthProvider({ - // @ts-expect-error - Type issues with the OAuth provider library - apiHandler: GravatarMcpServer.mount("/sse"), - apiRoute: "/sse", + apiHandlers: { + // @ts-ignore + "/mcp": GravatarMcpServer.serve("/mcp"), + // @ts-ignore + "/sse": GravatarMcpServer.serveSSE("/sse"), + }, + // @ts-ignore + defaultHandler: app, authorizeEndpoint: "/authorize", - clientRegistrationEndpoint: "/register", tokenEndpoint: "/token", - // @ts-expect-error - Type issues with the OAuth provider library - defaultHandler: app, tokenExchangeCallback: (options: any) => tokenExchangeCallback(options), + clientRegistrationEndpoint: "/register", }); } diff --git a/src/tools/profiles.ts b/src/tools/profiles.ts index c8f2a04..abca8c7 100644 --- a/src/tools/profiles.ts +++ b/src/tools/profiles.ts @@ -1,6 +1,13 @@ -import { getProfileById, createApiKeyOptions } from "./shared/api-client.js"; +import { z } from "zod"; +import { + getProfileById, + getProfile, + createApiKeyOptions, + createOAuthTokenOptions, +} from "./shared/api-client.js"; import { generateIdentifier } from "../common/utils.js"; import { emailInputShape, profileOutputShape, profileInputShape } from "./schemas.js"; + import type { GravatarMcpServer } from "../index.js"; export function registerProfileTools(agent: GravatarMcpServer, apiKey?: string) { @@ -86,4 +93,49 @@ export function registerProfileTools(agent: GravatarMcpServer, apiKey?: string) } }, ); + + // Register get_my_profile tool (OAuth authenticated) + 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 { + // Check if user is authenticated + if (!agent.props || !agent.props.tokenSet || !agent.props.tokenSet.access_token) { + throw new Error("OAuth authentication required. Please authenticate first."); + } + + // Get the authenticated user's profile using their OAuth access token + const profile = await getProfile( + createOAuthTokenOptions(agent.props.tokenSet.access_token), + ); + + 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, + }; + } + }, + ); } diff --git a/src/tools/shared/api-client.ts b/src/tools/shared/api-client.ts index 7b049ef..caa1df0 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(oauthToken: string) { + return { + headers: { + Authorization: `Bearer ${oauthToken}`, + }, + baseURL: "https://api.gravatar.com/v3", + }; +} From 494c34c984099a73bb0e1a6130095c68744c4127 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sat, 26 Jul 2025 14:58:11 -0500 Subject: [PATCH 14/34] Create the shared utilities --- src/tools/shared/api-client.ts | 8 +++---- src/tools/shared/auth-utils.ts | 12 +++++++++++ src/tools/shared/error-utils.ts | 26 +++++++++++++++++++++++ src/tools/shared/types.ts | 37 +++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 src/tools/shared/auth-utils.ts create mode 100644 src/tools/shared/error-utils.ts create mode 100644 src/tools/shared/types.ts diff --git a/src/tools/shared/api-client.ts b/src/tools/shared/api-client.ts index caa1df0..a395bfb 100644 --- a/src/tools/shared/api-client.ts +++ b/src/tools/shared/api-client.ts @@ -29,15 +29,15 @@ export function createApiKeyOptions(apiKey?: string) { return { headers, - baseURL: "https://api.gravatar.com/v3", + baseUrl: "https://api.gravatar.com/v3", }; } -export function createOAuthTokenOptions(oauthToken: string) { +export function createOAuthTokenOptions(accessToken: string) { return { headers: { - Authorization: `Bearer ${oauthToken}`, + Authorization: `Bearer ${accessToken}`, }, - baseURL: "https://api.gravatar.com/v3", + 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..add192d --- /dev/null +++ b/src/tools/shared/auth-utils.ts @@ -0,0 +1,12 @@ +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; +} 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; +} From 57a83067edf97300cf9529aa25a86e9cfdc16233 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sat, 26 Jul 2025 14:58:25 -0500 Subject: [PATCH 15/34] Create the tool registry system --- src/tools/registry.ts | 60 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/tools/registry.ts diff --git a/src/tools/registry.ts b/src/tools/registry.ts new file mode 100644 index 0000000..86cdc6b --- /dev/null +++ b/src/tools/registry.ts @@ -0,0 +1,60 @@ +import type { McpAgent } from "agents/mcp"; +import type { ToolDefinition, ToolContext } from "./shared/types.js"; +import type { UserProps } from "../auth/types.js"; + +export interface ToolRegistryOptions { + apiKey?: string; +} + +export class ToolRegistry { + private tools: Map = new Map(); + private apiKey?: string; + + constructor(options: ToolRegistryOptions = {}) { + this.apiKey = options.apiKey; + } + + register(tool: ToolDefinition) { + this.tools.set(tool.name, tool); + } + + async registerAll(agent: McpAgent) { + // Import and register all tool modules + const toolModules = await Promise.all([ + // Profiles + import("./profiles/get-profile-by-email.js"), + import("./profiles/get-profile-by-id.js"), + import("./profiles/get-my-profile.js"), + + // Avatars + import("./avatars/get-avatar-by-email.js"), + import("./avatars/get-avatar-by-id.js"), + + // Experimental + import("./experimental/get-inferred-interests-by-email.js"), + import("./experimental/get-inferred-interests-by-id.js"), + ]); + + for (const toolModule of toolModules) { + const tool = toolModule.default; + this.register(tool); + + // Register with MCP agent + agent.server.registerTool(tool.name, tool.config, async (params: any) => { + const context: ToolContext = { + props: agent.props, + apiKey: this.apiKey, + }; + return tool.handler(params, context); + }); + } + } + + getAll(): ToolDefinition[] { + return Array.from(this.tools.values()); + } + + get(name: string): ToolDefinition | undefined { + return this.tools.get(name); + } +} From 9b135d57b7030aecebe43d7ba8c7261817dac463 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sat, 26 Jul 2025 15:32:07 -0500 Subject: [PATCH 16/34] Delete registry.ts --- src/tools/registry.ts | 60 ------------------------------------------- 1 file changed, 60 deletions(-) delete mode 100644 src/tools/registry.ts diff --git a/src/tools/registry.ts b/src/tools/registry.ts deleted file mode 100644 index 86cdc6b..0000000 --- a/src/tools/registry.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { McpAgent } from "agents/mcp"; -import type { ToolDefinition, ToolContext } from "./shared/types.js"; -import type { UserProps } from "../auth/types.js"; - -export interface ToolRegistryOptions { - apiKey?: string; -} - -export class ToolRegistry { - private tools: Map = new Map(); - private apiKey?: string; - - constructor(options: ToolRegistryOptions = {}) { - this.apiKey = options.apiKey; - } - - register(tool: ToolDefinition) { - this.tools.set(tool.name, tool); - } - - async registerAll(agent: McpAgent) { - // Import and register all tool modules - const toolModules = await Promise.all([ - // Profiles - import("./profiles/get-profile-by-email.js"), - import("./profiles/get-profile-by-id.js"), - import("./profiles/get-my-profile.js"), - - // Avatars - import("./avatars/get-avatar-by-email.js"), - import("./avatars/get-avatar-by-id.js"), - - // Experimental - import("./experimental/get-inferred-interests-by-email.js"), - import("./experimental/get-inferred-interests-by-id.js"), - ]); - - for (const toolModule of toolModules) { - const tool = toolModule.default; - this.register(tool); - - // Register with MCP agent - agent.server.registerTool(tool.name, tool.config, async (params: any) => { - const context: ToolContext = { - props: agent.props, - apiKey: this.apiKey, - }; - return tool.handler(params, context); - }); - } - } - - getAll(): ToolDefinition[] { - return Array.from(this.tools.values()); - } - - get(name: string): ToolDefinition | undefined { - return this.tools.get(name); - } -} From 76268dc82d87c7549a4d27c3b315840bf3c09048 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sun, 27 Jul 2025 12:54:57 -0500 Subject: [PATCH 17/34] Update baseUrl to baseURL to match what fetch expects --- src/tools/shared/api-client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/shared/api-client.ts b/src/tools/shared/api-client.ts index a395bfb..0e77a16 100644 --- a/src/tools/shared/api-client.ts +++ b/src/tools/shared/api-client.ts @@ -29,7 +29,7 @@ export function createApiKeyOptions(apiKey?: string) { return { headers, - baseUrl: "https://api.gravatar.com/v3", + baseURL: "https://api.gravatar.com/v3", }; } @@ -38,6 +38,6 @@ export function createOAuthTokenOptions(accessToken: string) { headers: { Authorization: `Bearer ${accessToken}`, }, - baseUrl: "https://api.gravatar.com/v3", + baseURL: "https://api.gravatar.com/v3", }; } From 7442213b497e40ffab17e0a32db34e8a4491039c Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sun, 27 Jul 2025 20:40:33 -0500 Subject: [PATCH 18/34] Swap out the fetch http client for the axios client in the generated client The fetch api client has a bug that causes it to fail to throw an error on non-2xx responses --- package-lock.json | 46 +++++++--------------------------------------- package.json | 1 + 2 files changed, 8 insertions(+), 39 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1023953..ac1249e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@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" @@ -3027,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" } }, @@ -3253,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" }, @@ -3414,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" } @@ -3550,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", @@ -4021,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", @@ -4029,8 +4014,6 @@ } ], "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=4.0" }, @@ -4061,10 +4044,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -4080,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" } @@ -4092,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" }, @@ -4299,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" }, @@ -5854,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 9bb5f97..06bc248 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@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" From 13c25a694d259a6a1f4ca4c2fe998e579518d48d Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sun, 27 Jul 2025 21:00:04 -0500 Subject: [PATCH 19/34] Delete integration test --- tests/integration/mcp-server.test.ts | 224 --------------------------- 1 file changed, 224 deletions(-) delete mode 100644 tests/integration/mcp-server.test.ts 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"); - }); - }); -}); From 94b7cd3a4993d5ecf24decf6e79b256868ef8778 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sun, 27 Jul 2025 21:00:39 -0500 Subject: [PATCH 20/34] Update tests to be compatible with our changes --- tests/unit/config.test.ts | 22 +++++++++--------- tests/unit/schemas.test.ts | 46 +++++++++++++++++--------------------- 2 files changed, 32 insertions(+), 36 deletions(-) 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/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) { From f3c9258790827068c9d30e6a783b66f9aa622995 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sun, 27 Jul 2025 21:03:32 -0500 Subject: [PATCH 21/34] Constrain test coverage report to source files --- vitest.config.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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: { From f7130b7283dd142d811bb6f3b00f33f3a3c154da Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sun, 27 Jul 2025 21:11:42 -0500 Subject: [PATCH 22/34] Add tests --- tests/unit/api-client.test.ts | 43 +++++- tests/unit/auth-utils.test.ts | 188 ++++++++++++++++++++++++++ tests/unit/error-utils.test.ts | 189 +++++++++++++++++++++++++++ tests/unit/integration-guide.test.ts | 137 +++++++++++++++++++ 4 files changed, 556 insertions(+), 1 deletion(-) create mode 100644 tests/unit/auth-utils.test.ts create mode 100644 tests/unit/error-utils.test.ts create mode 100644 tests/unit/integration-guide.test.ts 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/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(""); + }); + }); +}); From a3047af0e077f9140ad15bc9690741dc1e2e3aeb Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sun, 27 Jul 2025 21:36:37 -0500 Subject: [PATCH 23/34] Add OAuth scope: gravatar-profile:manage --- wrangler.jsonc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wrangler.jsonc b/wrangler.jsonc index 397b54f..2a9fa6d 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -51,7 +51,7 @@ "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_SCOPES": "auth gravatar-profile:read gravatar-profile:manage", "OAUTH_REDIRECT_URI": "http://localhost:8787/callback" }, /** @@ -88,7 +88,7 @@ "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_SCOPES": "auth gravatar-profile:read gravatar-profile:manage", "OAUTH_REDIRECT_URI": "https://mcp-server-gravatar-staging.your-account.workers.dev/callback" } }, From 9a11bfc1389bc71254a204f692a1ecd062c65b8b Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sun, 27 Jul 2025 21:38:32 -0500 Subject: [PATCH 24/34] Add tool: update_my_profile --- src/tools/profiles.ts | 52 ++++++++- src/tools/schemas.ts | 5 + tests/unit/update-profile.test.ts | 169 ++++++++++++++++++++++++++++++ 3 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 tests/unit/update-profile.test.ts diff --git a/src/tools/profiles.ts b/src/tools/profiles.ts index abca8c7..ad7013e 100644 --- a/src/tools/profiles.ts +++ b/src/tools/profiles.ts @@ -2,12 +2,18 @@ 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) { @@ -138,4 +144,46 @@ export function registerProfileTools(agent: GravatarMcpServer, apiKey?: string) } }, ); + + // Register update_my_profile tool (OAuth) + 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..2605699 100644 --- a/src/tools/schemas.ts +++ b/src/tools/schemas.ts @@ -11,6 +11,7 @@ import { getProfileByIdPathParamsSchema, getProfileInferredInterestsByIdPathParamsSchema, } from "../generated/schemas/index.js"; +import { updateProfileMutationRequestSchema } from "../generated/schemas/profilesSchemas/updateProfileSchema.js"; import { z } from "zod"; // Output schemas for tools @@ -41,9 +42,13 @@ 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; + // 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; diff --git a/tests/unit/update-profile.test.ts b/tests/unit/update-profile.test.ts new file mode 100644 index 0000000..6ab86cc --- /dev/null +++ b/tests/unit/update-profile.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, vi } from "vitest"; +import { updateProfile, createOAuthOptions } 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, createOAuthOptions(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, createOAuthOptions(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, createOAuthOptions(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, createOAuthOptions(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, createOAuthOptions(accessToken)); + + expect(result.job_title).toBe(""); + expect(result.company).toBe(""); + expect(result.description).toBe(""); + }); +}); From c77a7735529200f47b9dbd38f47121cd860b0d6f Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sun, 27 Jul 2025 21:46:08 -0500 Subject: [PATCH 25/34] Create .dev.vars.example --- .dev.vars.example | 84 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 .dev.vars.example 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 From 54ad2a2b7fe0d80f3c847f0fdf436e35db7e2fb0 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sun, 27 Jul 2025 21:50:43 -0500 Subject: [PATCH 26/34] Update README.md with details about handling ENVs and secrets --- README.md | 48 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 13 deletions(-) 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 From b0f5ecd8519fad2d23222fa809bbfd4c34db34bc Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sun, 27 Jul 2025 21:51:06 -0500 Subject: [PATCH 27/34] Update CLAUDE.md with details about updating ENVs and secrets --- CLAUDE.md | 48 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 10 deletions(-) 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 From cbcdf3bad84acbfa9ed309c52d3ec73eb6a13656 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sun, 27 Jul 2025 22:09:16 -0500 Subject: [PATCH 28/34] Add tool: search-profiles-by-verified-account --- src/tools/experimental.ts | 58 ++++- src/tools/schemas.ts | 21 ++ ...earch-profiles-by-verified-account.test.ts | 220 ++++++++++++++++++ 3 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 tests/unit/search-profiles-by-verified-account.test.ts 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/schemas.ts b/src/tools/schemas.ts index 2605699..2a162fb 100644 --- a/src/tools/schemas.ts +++ b/src/tools/schemas.ts @@ -12,6 +12,10 @@ import { 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 @@ -45,6 +49,17 @@ 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; @@ -52,3 +67,9 @@ 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/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); + }); +}); From cf8266f293d13fbbc1913b643f4d283ceb22b7f1 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Mon, 28 Jul 2025 11:47:27 -0500 Subject: [PATCH 29/34] Fix redirect uris in wrangler.jsonc --- wrangler.jsonc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wrangler.jsonc b/wrangler.jsonc index 2a9fa6d..c41c83a 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -89,7 +89,7 @@ "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.your-account.workers.dev/callback" + "OAUTH_REDIRECT_URI": "https://mcp-server-gravatar-staging.a8cai.workers.dev/callback" } }, "production": { @@ -117,7 +117,7 @@ "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.your-account.workers.dev/callback" + "OAUTH_REDIRECT_URI": "https://mcp-server-gravatar-production.a8cai.workers.dev/callback" } } }, From 44517e62cc3a008c55f8ec1d7325b5fac3ff3627 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Mon, 28 Jul 2025 11:49:27 -0500 Subject: [PATCH 30/34] Fix handling of token expiration --- src/auth/index.ts | 26 ++++++++++++++++---------- src/tools/shared/auth-utils.ts | 16 ++++++++++++++++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/auth/index.ts b/src/auth/index.ts index 07a298c..61362b8 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -264,14 +264,6 @@ export async function callback(c: Context<{ Bindings: Env & { OAUTH_PROVIDER: OA new URL(c.req.url), wordPressOAuthAuthRequest.transactionState, ); - 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) const result = await oauth.processAuthorizationCodeResponse( @@ -336,8 +328,12 @@ export async function tokenExchangeCallback( // 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: options.props.tokenSet.expires_in, + accessTokenTTL: mcpTokenTTL, newProps: { ...options.props, }, @@ -347,7 +343,17 @@ export async function tokenExchangeCallback( if (options.grantType === "refresh_token") { const wordPressRefreshToken = options.props.tokenSet.refresh_token; if (!wordPressRefreshToken) { - throw new Error("No WordPress refresh token found"); + // 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({ diff --git a/src/tools/shared/auth-utils.ts b/src/tools/shared/auth-utils.ts index add192d..23d735d 100644 --- a/src/tools/shared/auth-utils.ts +++ b/src/tools/shared/auth-utils.ts @@ -10,3 +10,19 @@ export function requireAuth(props?: UserProps): string { 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), + }; +} From 638de3f523136839519c559cf9ae06153f778c1c Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Mon, 28 Jul 2025 11:49:43 -0500 Subject: [PATCH 31/34] Improved logging when OAuth errors occur --- src/auth/index.ts | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/src/auth/index.ts b/src/auth/index.ts index 61362b8..6fd31f3 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -265,16 +265,33 @@ export async function callback(c: Context<{ Bindings: Env & { OAUTH_PROVIDER: OA wordPressOAuthAuthRequest.transactionState, ); - // Process the response (WordPress OAuth2 doesn't use ID tokens) - const result = await oauth.processAuthorizationCodeResponse( - authorizationServer, - client, - response, - ); + 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) { - return c.text(`OAuth error: ${result.error}`, 400); + // 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 From 4c92582c857b9d2f961041e60bca37dc1c2b674d Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Mon, 28 Jul 2025 13:08:24 -0500 Subject: [PATCH 32/34] Move image helpers into a separate source file Preparing for other avatar tools --- src/common/image-utils.ts | 64 +++++++++++++++++++++++++++++ src/tools/avatar-image-api.ts | 35 +--------------- tests/unit/avatar-image-api.test.ts | 8 +--- 3 files changed, 68 insertions(+), 39 deletions(-) create mode 100644 src/common/image-utils.ts 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/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/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", () => ({ From 300a1a967eccb1ba517a505f53471e5020589229 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Mon, 28 Jul 2025 13:08:50 -0500 Subject: [PATCH 33/34] Add TODO comment in integration guide fetcher --- src/resources/integration-guide.ts | 1 + 1 file changed, 1 insertion(+) 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"), ); From e74e28bf9e13641a3a52dcd4dee244b7cfd53765 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Tue, 29 Jul 2025 22:07:41 -0500 Subject: [PATCH 34/34] Fix rebase mistakes --- src/tools/profiles.ts | 28 ++++++++++++++-------------- tests/unit/update-profile.test.ts | 12 ++++++------ 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/tools/profiles.ts b/src/tools/profiles.ts index ad7013e..6b46671 100644 --- a/src/tools/profiles.ts +++ b/src/tools/profiles.ts @@ -100,7 +100,7 @@ export function registerProfileTools(agent: GravatarMcpServer, apiKey?: string) }, ); - // Register get_my_profile tool (OAuth authenticated) + // Register get_my_profile tool agent.server.registerTool( "get_my_profile", { @@ -117,18 +117,15 @@ export function registerProfileTools(agent: GravatarMcpServer, apiKey?: string) }, async () => { try { - // Check if user is authenticated - if (!agent.props || !agent.props.tokenSet || !agent.props.tokenSet.access_token) { - throw new Error("OAuth authentication required. Please authenticate first."); - } - - // Get the authenticated user's profile using their OAuth access token - const profile = await getProfile( - createOAuthTokenOptions(agent.props.tokenSet.access_token), - ); - + const accessToken = requireAuth(agent.props); + const profile = await getProfile(createOAuthTokenOptions(accessToken)); return { - content: [{ type: "text", text: JSON.stringify(profile, null, 2) }], + content: [ + { + type: "text", + text: JSON.stringify(profile, null, 2), + }, + ], structuredContent: { ...profile }, }; } catch (error) { @@ -145,7 +142,7 @@ export function registerProfileTools(agent: GravatarMcpServer, apiKey?: string) }, ); - // Register update_my_profile tool (OAuth) + // Register update_my_profile tool agent.server.registerTool( "update_my_profile", { @@ -163,7 +160,10 @@ export function registerProfileTools(agent: GravatarMcpServer, apiKey?: string) async (updateData) => { try { const accessToken = requireAuth(agent.props); - const updatedProfile = await updateProfile(updateData, createOAuthTokenOptions(accessToken)); + const updatedProfile = await updateProfile( + updateData, + createOAuthTokenOptions(accessToken), + ); return { content: [ { diff --git a/tests/unit/update-profile.test.ts b/tests/unit/update-profile.test.ts index 6ab86cc..5d3a45e 100644 --- a/tests/unit/update-profile.test.ts +++ b/tests/unit/update-profile.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { updateProfile, createOAuthOptions } from "../../src/tools/shared/api-client.js"; +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"; @@ -65,7 +65,7 @@ describe("Update Profile Tool", () => { // Simulate the tool function logic const accessToken = requireAuth(mockProps); - const result = await updateProfile(updateData, createOAuthOptions(accessToken)); + const result = await updateProfile(updateData, createOAuthTokenOptions(accessToken)); expect(requireAuth).toHaveBeenCalledWith(mockProps); expect(updateProfile).toHaveBeenCalledWith(updateData, { @@ -87,7 +87,7 @@ describe("Update Profile Tool", () => { }); const accessToken = requireAuth(mockProps); - const result = await updateProfile(updateData, createOAuthOptions(accessToken)); + const result = await updateProfile(updateData, createOAuthTokenOptions(accessToken)); expect(result.job_title).toBe("CTO"); }); @@ -110,7 +110,7 @@ describe("Update Profile Tool", () => { const accessToken = requireAuth(mockProps); - await expect(updateProfile(updateData, createOAuthOptions(accessToken))).rejects.toThrow( + await expect(updateProfile(updateData, createOAuthTokenOptions(accessToken))).rejects.toThrow( "API Error: Invalid request", ); }); @@ -138,7 +138,7 @@ describe("Update Profile Tool", () => { }); const accessToken = requireAuth(mockProps); - const result = await updateProfile(updateData, createOAuthOptions(accessToken)); + const result = await updateProfile(updateData, createOAuthTokenOptions(accessToken)); expect(updateProfile).toHaveBeenCalledWith(updateData, expect.any(Object)); expect(result).toMatchObject(updateData); @@ -160,7 +160,7 @@ describe("Update Profile Tool", () => { }); const accessToken = requireAuth(mockProps); - const result = await updateProfile(updateData, createOAuthOptions(accessToken)); + const result = await updateProfile(updateData, createOAuthTokenOptions(accessToken)); expect(result.job_title).toBe(""); expect(result.company).toBe("");