Skip to main content
Glama
index.ts43.1 kB
import { GranolaClient, GranolaTokens, SummaryCache, MeetingSummary } from './granola-client.js'; /** * KV-based cache implementation for document summaries */ class KVSummaryCache implements SummaryCache { private kv: KVNamespace; private prefix = 'summary:'; constructor(kv: KVNamespace) { this.kv = kv; } async get(documentId: string): Promise<MeetingSummary | null> { const cached = await this.kv.get(`${this.prefix}${documentId}`); if (cached) { try { return JSON.parse(cached) as MeetingSummary; } catch { return null; } } return null; } async set(documentId: string, summary: MeetingSummary, ttlSeconds: number = 300): Promise<void> { await this.kv.put(`${this.prefix}${documentId}`, JSON.stringify(summary), { expirationTtl: ttlSeconds, }); } } interface Env { TOKENS: KVNamespace; WORKOS_CLIENT_ID: string; WORKOS_AUTH_URL: string; WORKOS_TOKEN_URL: string; WORKOS_REFRESH_URL: string; GRANOLA_REDIRECT_URI: string; GRANOLA_API_URL: string; GRANOLA_NOTES_URL: string; ENABLE_SUMMARY_CACHE: string; } // Support a wide range of MCP protocol versions for compatibility const SUPPORTED_PROTOCOL_VERSIONS = [ '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07', '2024-09-01', '2024-08-01', '1.0', '0.1', ] as const; const DEFAULT_PROTOCOL_VERSION = '2024-11-05'; const SERVER_INFO = { name: 'Granola MCP Server', version: '1.0.0', title: 'Granola Meetings', instructions: 'Use this integration to retrieve structured Granola meeting data. ' + 'Prefer get_todays_meetings for a daily summary, get_recent_meetings for the last N days, ' + 'search_meetings for keyword lookups, and get_document_summary for a single meeting document. ' + 'All timestamps are ISO-8601 UTC. Tools default to returning 20 items; lower the limit when possible to reduce latency.', }; export default { async fetch(request: Request, env: Env): Promise<Response> { const url = new URL(request.url); // CORS headers for browser access const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', }; if (request.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }); } // OAuth Discovery Endpoints (MCP OAuth spec) if (url.pathname === '/.well-known/oauth-protected-resource') { return new Response( JSON.stringify({ resource: `${url.origin}/mcp`, // Point to the actual MCP resource authorization_servers: [url.origin], scopes_supported: ['access'], bearer_methods_supported: ['header'], }), { headers: { 'Content-Type': 'application/json', ...corsHeaders }, } ); } if (url.pathname === '/.well-known/oauth-authorization-server') { return new Response( JSON.stringify({ issuer: url.origin, authorization_endpoint: `${url.origin}/authorize`, // Changed from /auth to /authorize per spec token_endpoint: `${url.origin}/token`, registration_endpoint: `${url.origin}/register`, // Added DCR endpoint response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], // Added refresh_token code_challenge_methods_supported: ['S256'], // Added PKCE support - CRITICAL! }), { headers: { 'Content-Type': 'application/json', ...corsHeaders }, } ); } // Dynamic Client Registration (DCR) - Step 6 if (url.pathname === '/register' && request.method === 'POST') { try { const body = await request.json() as any; const clientId = `client_${crypto.randomUUID()}`; // Store client registration (in production, save to KV) return new Response( JSON.stringify({ client_id: clientId, client_name: body.client_name || 'MCP Client', redirect_uris: body.redirect_uris || [], }), { status: 201, headers: { 'Content-Type': 'application/json', ...corsHeaders }, } ); } catch (error) { return new Response('Invalid registration request', { status: 400 }); } } // Authorization Page (Step 8: Show user instructions) - Renamed from /auth to /authorize if (url.pathname === '/authorize') { const state = url.searchParams.get('state') || ''; const redirectUri = url.searchParams.get('redirect_uri') || ''; const codeChallenge = url.searchParams.get('code_challenge') || ''; // PKCE parameter const codeChallengeMethod = url.searchParams.get('code_challenge_method') || ''; // PKCE method const clientId = url.searchParams.get('client_id') || ''; const resource = url.searchParams.get('resource') || ''; // Build Granola OAuth URL via WorkOS const workosAuthUrl = new URL(env.WORKOS_AUTH_URL); workosAuthUrl.searchParams.set('client_id', env.WORKOS_CLIENT_ID); workosAuthUrl.searchParams.set('redirect_uri', env.GRANOLA_REDIRECT_URI); workosAuthUrl.searchParams.set('response_type', 'code'); workosAuthUrl.searchParams.set('provider', 'GoogleOAuth'); workosAuthUrl.searchParams.set('prompt', 'consent'); workosAuthUrl.searchParams.set('provider_query_params[include_granted_scopes]', 'true'); workosAuthUrl.searchParams.set( 'provider_scopes', 'https://www.googleapis.com/auth/calendar.events.readonly' ); workosAuthUrl.searchParams.set( 'state', JSON.stringify({ isDev: false, platform: 'macos', wos: true, provider: 'google', sso: false }) ); return new Response(getAuthHTML(workosAuthUrl.toString(), state, redirectUri, codeChallenge, codeChallengeMethod, clientId, resource), { headers: { 'Content-Type': 'text/html', ...corsHeaders }, }); } // Handle pasted redirect URL (Step 2: Process the WorkOS code) if (url.pathname === '/complete-auth' && request.method === 'POST') { const formData = await request.formData(); const redirectUrlRaw = formData.get('redirect_url') as string | null; const state = formData.get('state') as string; const mcpRedirectUri = formData.get('mcp_redirect_uri') as string; const codeChallenge = formData.get('code_challenge') as string; // PKCE parameter const codeChallengeMethod = formData.get('code_challenge_method') as string; // PKCE method const clientId = formData.get('client_id') as string; const resource = formData.get('resource') as string; try { if (!redirectUrlRaw) { throw new Error('Redirect URL missing'); } // Trim and strip stray whitespace so copy/pasted values parse cleanly. const redirectUrl = redirectUrlRaw.trim().replace(/\s+/g, ''); // Parse the desktop app URL to get WorkOS auth code const parsedUrl = new URL(redirectUrl); const workosCode = parsedUrl.searchParams.get('code'); if (!workosCode) { throw new Error('No code found in URL'); } // Exchange WorkOS code for Granola access token const tokenPayload = { code: workosCode, isDev: false, platform: 'macOS', ssoCode: '', sso: false, dubId: '', ampMarketingId: '04cc7179-03d7-44a3-9bf7-c6bb1124ef99', ampWebAppId: '832ff8e8-fedf-4901-8f64-ed18a9b85202', }; const tokenResponse = await fetch(env.WORKOS_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Granola/6.267.0 Chrome/136.0.7103.177 Electron/36.9.3 Safari/537.36', 'X-Client-Version': '6.267.0', 'X-Platform': 'macos', 'Accept': 'application/json', }, body: JSON.stringify(tokenPayload), }); if (!tokenResponse.ok) { const errorText = await tokenResponse.text(); throw new Error(`Failed to exchange WorkOS code: ${errorText}`); } const granolaResponse = (await tokenResponse.json()) as any; const granolaTokens = granolaResponse?.tokens ?? granolaResponse; if (!granolaTokens || !granolaTokens.access_token) { throw new Error('Failed to get Granola access token'); } // Generate a session ID const sessionId = crypto.randomUUID(); // Store Granola tokens in KV const expiresInSeconds = Number(granolaTokens.expires_in ?? 86400) || 86400; const tokensData: GranolaTokens = { access_token: granolaTokens.access_token, refresh_token: granolaTokens.refresh_token, expires_at: Date.now() + expiresInSeconds * 1000, provider: 'google', }; // No TTL - tokens persist until refresh token expires on Granola's side await env.TOKENS.put(sessionId, JSON.stringify(tokensData)); // Generate MCP authorization code const mcpAuthCode = crypto.randomUUID(); // Store session ID with PKCE challenge for later validation await env.TOKENS.put(`code:${mcpAuthCode}`, JSON.stringify({ session_id: sessionId, code_challenge: codeChallenge, code_challenge_method: codeChallengeMethod, client_id: clientId, redirect_uri: mcpRedirectUri, resource: resource, }), { expirationTtl: 600, // 10 minutes }); // Redirect back to MCP client const redirectBack = new URL(mcpRedirectUri); redirectBack.searchParams.set('code', mcpAuthCode); redirectBack.searchParams.set('state', state); return Response.redirect(redirectBack.toString(), 302); } catch (error) { return new Response( ` <!DOCTYPE html> <html> <head> <title>Authentication Error</title> <style> body { font-family: system-ui; max-width: 600px; margin: 50px auto; padding: 20px; } .error { background: #fee; border: 2px solid #c33; padding: 15px; border-radius: 5px; } </style> </head> <body> <h1>Authentication Error</h1> <div class="error"> <strong>Error:</strong> ${error instanceof Error ? error.message : String(error)} </div> <p><a href="${url.pathname}${url.search}">Try again</a></p> </body> </html> `, { status: 400, headers: { 'Content-Type': 'text/html' } } ); } } // Token Exchange Endpoint (MCP OAuth spec) - Step 11 with PKCE validation if (url.pathname === '/token' && request.method === 'POST') { const formData = await request.formData(); const code = formData.get('code') as string; const codeVerifier = formData.get('code_verifier') as string; // PKCE verifier // Look up stored data from auth code const storedDataJson = await env.TOKENS.get(`code:${code}`); if (!storedDataJson) { return new Response(JSON.stringify({ error: 'invalid_grant', error_description: 'Invalid authorization code' }), { status: 400, headers: { 'Content-Type': 'application/json', ...corsHeaders } }); } const storedData = JSON.parse(storedDataJson); // ========== PKCE VALIDATION (CRITICAL FOR SECURITY) ========== if (storedData.code_challenge) { // PKCE was used, code_verifier is REQUIRED if (!codeVerifier) { await env.TOKENS.delete(`code:${code}`); // Clean up return new Response(JSON.stringify({ error: 'invalid_grant', error_description: 'code_verifier required for PKCE' }), { status: 400, headers: { 'Content-Type': 'application/json', ...corsHeaders } }); } // Calculate SHA256 hash of the code_verifier const encoder = new TextEncoder(); const data = encoder.encode(codeVerifier); const hashBuffer = await crypto.subtle.digest('SHA-256', data); // Convert to base64url encoding const hashArray = Array.from(new Uint8Array(hashBuffer)); const base64 = btoa(String.fromCharCode(...hashArray)); const calculatedChallenge = base64 .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); // Verify calculated challenge matches stored challenge if (calculatedChallenge !== storedData.code_challenge) { await env.TOKENS.delete(`code:${code}`); // Clean up return new Response(JSON.stringify({ error: 'invalid_grant', error_description: 'PKCE validation failed' }), { status: 400, headers: { 'Content-Type': 'application/json', ...corsHeaders } }); } // PKCE validation successful! } // ========== END PKCE VALIDATION ========== // Delete the one-time code await env.TOKENS.delete(`code:${code}`); // Return the session ID as the access token return new Response( JSON.stringify({ access_token: storedData.session_id, token_type: 'Bearer', expires_in: 86400 * 7, // 7 days }), { headers: { 'Content-Type': 'application/json', ...corsHeaders }, } ); } // Debug endpoint to test token refresh (remove in production) if (url.pathname === '/debug/test-refresh') { const authHeader = request.headers.get('Authorization'); if (!authHeader?.startsWith('Bearer ')) { return new Response(JSON.stringify({ error: 'Missing Bearer token' }), { status: 401, headers: { 'Content-Type': 'application/json', ...corsHeaders }, }); } const sessionId = authHeader.substring(7); const tokensJson = await env.TOKENS.get(sessionId); if (!tokensJson) { return new Response(JSON.stringify({ error: 'Session not found' }), { status: 404, headers: { 'Content-Type': 'application/json', ...corsHeaders }, }); } const tokens: GranolaTokens = JSON.parse(tokensJson); const client = new GranolaClient(env.GRANOLA_API_URL, env.GRANOLA_NOTES_URL, tokens); const isExpired = client.isTokenExpired(); const expiresIn = Math.round((tokens.expires_at - Date.now()) / 1000); // Force refresh if ?force=true if (url.searchParams.get('force') === 'true') { try { const newTokens = await client.refreshToken(env.WORKOS_REFRESH_URL); await env.TOKENS.put(sessionId, JSON.stringify(newTokens)); return new Response(JSON.stringify({ status: 'refreshed', old_expires_in: expiresIn, new_expires_in: Math.round((newTokens.expires_at - Date.now()) / 1000), has_refresh_token: !!newTokens.refresh_token, }), { headers: { 'Content-Type': 'application/json', ...corsHeaders }, }); } catch (error) { return new Response(JSON.stringify({ status: 'refresh_failed', error: error instanceof Error ? error.message : String(error), }), { status: 500, headers: { 'Content-Type': 'application/json', ...corsHeaders }, }); } } return new Response(JSON.stringify({ status: 'ok', is_expired: isExpired, expires_in_seconds: expiresIn, expires_at: new Date(tokens.expires_at).toISOString(), has_refresh_token: !!tokens.refresh_token, hint: 'Add ?force=true to force a token refresh', }), { headers: { 'Content-Type': 'application/json', ...corsHeaders }, }); } // MCP Server Handler if (url.pathname === '/mcp') { // MCP protocol only supports POST requests if (request.method !== 'POST') { return new Response( JSON.stringify({ error: { code: -32600, message: 'Invalid Request - MCP requires POST with JSON-RPC body', }, }), { status: 400, headers: { 'Content-Type': 'application/json', ...corsHeaders, }, } ); } // Handle MCP protocol - parse JSON-RPC request first let body: any; try { body = await request.json(); } catch (error) { // JSON parse error return new Response( JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error - Invalid JSON', }, }), { status: 400, headers: { 'Content-Type': 'application/json', ...corsHeaders, }, } ); } // Now check authentication try { // Extract and validate token AFTER parsing the request const authHeader = request.headers.get('Authorization'); if (!authHeader?.startsWith('Bearer ')) { // Return JSON-RPC error response with 401 for OAuth discovery return new Response( JSON.stringify({ jsonrpc: '2.0', id: body.id || null, error: { code: -32001, // Custom error code for authentication required message: 'Authentication required', }, }), { status: 401, headers: { 'WWW-Authenticate': `Bearer realm="${url.origin}", resource_metadata="${url.origin}/.well-known/oauth-protected-resource"`, 'Content-Type': 'application/json', ...corsHeaders, }, } ); } const sessionId = authHeader.substring(7); const tokensJson = await env.TOKENS.get(sessionId); if (!tokensJson) { // Return JSON-RPC error response for invalid token return new Response( JSON.stringify({ jsonrpc: '2.0', id: body.id || null, error: { code: -32001, message: 'Invalid token', }, }), { status: 401, headers: { 'WWW-Authenticate': `Bearer realm="${url.origin}", error="invalid_token"`, 'Content-Type': 'application/json', ...corsHeaders, }, } ); } let tokens: GranolaTokens = JSON.parse(tokensJson); // Create Granola API client const client = new GranolaClient(env.GRANOLA_API_URL, env.GRANOLA_NOTES_URL, tokens); // Optionally enable KV-based caching for document summaries if (env.ENABLE_SUMMARY_CACHE === 'true') { const summaryCache = new KVSummaryCache(env.TOKENS); client.setCache(summaryCache); } // Configure auto-refresh with callback to persist new tokens to KV client.setAutoRefresh(env.WORKOS_REFRESH_URL, async (newTokens: GranolaTokens) => { tokens = newTokens; // Update local reference await env.TOKENS.put(sessionId, JSON.stringify(newTokens)); }); const requestId = body.id ?? null; // Check if token needs refresh BEFORE making any API calls if (client.isTokenExpired()) { try { const newTokens = await client.refreshToken(env.WORKOS_REFRESH_URL); tokens = newTokens; await env.TOKENS.put(sessionId, JSON.stringify(newTokens)); } catch (error) { // Clear invalid session from KV so user must re-auth await env.TOKENS.delete(sessionId); return new Response( JSON.stringify({ jsonrpc: '2.0', id: body.id || null, error: { code: -32001, message: 'Session expired. Please re-authenticate.', }, }), { status: 401, headers: { 'WWW-Authenticate': `Bearer realm="${url.origin}", error="invalid_token", error_description="Session expired"`, 'Content-Type': 'application/json', ...corsHeaders, }, } ); } } // Handle MCP protocol methods // MCP Initialize - Step 13: Client's first request after auth if (body.method === 'initialize') { const fromArray = Array.isArray(body.params?.protocolVersions) ? body.params.protocolVersions.filter((v: unknown): v is string => typeof v === 'string') : []; const singleVersion = typeof body.params?.protocolVersion === 'string' ? [body.params.protocolVersion] : []; const requestedVersions = [...fromArray, ...singleVersion]; let negotiatedVersion: string | undefined; if (requestedVersions.length > 0) { // Try to find an exact match first negotiatedVersion = requestedVersions.find((version) => SUPPORTED_PROTOCOL_VERSIONS.includes(version as (typeof SUPPORTED_PROTOCOL_VERSIONS)[number]) ); // If no exact match, use the client's first requested version (be lenient) if (!negotiatedVersion) { negotiatedVersion = requestedVersions[0]; } } else { negotiatedVersion = DEFAULT_PROTOCOL_VERSION; } // Always respond with a version - never fail on protocol negotiation return new Response( JSON.stringify({ jsonrpc: '2.0', id: requestId, result: { protocolVersion: negotiatedVersion, capabilities: { tools: { list: true, call: true, }, }, serverInfo: SERVER_INFO, }, }), { headers: { 'Content-Type': 'application/json', ...corsHeaders }, } ); } if (body.method === 'initialized') { return new Response( JSON.stringify({ jsonrpc: '2.0', id: null, result: null, }), { status: 200, headers: { 'Content-Type': 'application/json', ...corsHeaders } } ); } if (body.method === 'tools/list') { // Tool annotations for ChatGPT visibility const publicAnnotations = { title: undefined as string | undefined, readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }; return new Response( JSON.stringify({ jsonrpc: '2.0', id: requestId, result: { tools: [ { name: 'get_all_meetings', description: 'Return the most recent meetings including title, summary, attendees, and start/end times. Use limit to control result size (default 20).', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Maximum number of meetings to return (default: 20, max: 50)', default: 20, }, }, }, annotations: { ...publicAnnotations, title: 'Get All Meetings' }, }, { name: 'get_meeting_by_id', description: 'Fetch a single meeting document by ID, returning the full structured summary and metadata.', inputSchema: { type: 'object', properties: { document_id: { type: 'string', description: 'The Granola document ID of the meeting to fetch', }, }, required: ['document_id'], }, annotations: { ...publicAnnotations, title: 'Get Meeting by ID' }, }, { name: 'get_recent_meetings', description: 'Return meetings that occurred within the last N days, useful for recaps and follow ups.', inputSchema: { type: 'object', properties: { days: { type: 'number', description: 'Number of days to look back from today (default: 7)', default: 7, }, limit: { type: 'number', description: 'Maximum number of meetings to return (default: 20, max: 50)', default: 20, }, }, }, annotations: { ...publicAnnotations, title: 'Get Recent Meetings' }, }, { name: 'search_meetings', description: 'Keyword search across meeting titles and summaries. Returns matching meetings with key metadata.', inputSchema: { type: 'object', properties: { keyword: { type: 'string', description: 'Keyword or phrase to search for (case-insensitive)', }, limit: { type: 'number', description: 'Maximum number of results (default: 20, max: 50)', default: 20, }, }, }, annotations: { ...publicAnnotations, title: 'Search Meetings' }, }, { name: 'get_document_summary', description: 'Retrieve the fully rendered summary for a specific Granola meeting document, including highlights and action items.', inputSchema: { type: 'object', properties: { document_id: { type: 'string', description: 'The Granola document ID to summarize', }, }, required: ['document_id'], }, annotations: { ...publicAnnotations, title: 'Get Document Summary' }, }, { name: 'get_todays_meetings', description: 'Return meetings scheduled for today (UTC) with summaries. Ideal for a daily digest.', inputSchema: { type: 'object', properties: {}, }, annotations: { ...publicAnnotations, title: "Get Today's Meetings" }, }, ], }, }), { headers: { 'Content-Type': 'application/json', ...corsHeaders } } ); } if (body.method === 'tools/call') { const params = body.params; if (!params || typeof params.name !== 'string') { return new Response( JSON.stringify({ jsonrpc: '2.0', id: requestId, error: { code: -32602, message: 'Tool name is required' }, }), { status: 200, headers: { 'Content-Type': 'application/json', ...corsHeaders } } ); } const toolName = params.name; const args = (params.arguments && typeof params.arguments === 'object') ? params.arguments : {}; let result: any; switch (toolName) { case 'get_all_meetings': const meetings = await client.getAllMeetingsWithSummaries(args.limit || 20); result = { content: [ { type: 'text', text: JSON.stringify(meetings, null, 2), }, ], }; break; case 'get_meeting_by_id': const summary = await client.getDocumentSummaryFromHTML(args.document_id); result = { content: [ { type: 'text', text: JSON.stringify(summary, null, 2), }, ], }; break; case 'get_recent_meetings': const recent = await client.getRecentMeetings(args.days || 7, args.limit || 20); result = { content: [ { type: 'text', text: JSON.stringify(recent, null, 2), }, ], }; break; case 'search_meetings': const searchResults = await client.searchMeetings(args.keyword, args.limit || 20); result = { content: [ { type: 'text', text: JSON.stringify(searchResults, null, 2), }, ], }; break; case 'get_document_summary': const docSummary = await client.getDocumentSummaryFromHTML(args.document_id); result = { content: [ { type: 'text', text: JSON.stringify(docSummary, null, 2), }, ], }; break; case 'get_todays_meetings': const today = await client.getRecentMeetings(1, 50); result = { content: [ { type: 'text', text: JSON.stringify(today, null, 2), }, ], }; break; default: return new Response( JSON.stringify({ jsonrpc: '2.0', id: requestId, error: { code: -32601, message: 'Tool not found' }, }), { status: 200, headers: { 'Content-Type': 'application/json', ...corsHeaders } } ); } return new Response( JSON.stringify({ jsonrpc: '2.0', id: requestId, result, }), { headers: { 'Content-Type': 'application/json', ...corsHeaders }, } ); } return new Response( JSON.stringify({ jsonrpc: '2.0', id: requestId, error: { code: -32601, message: 'Method not found' }, }), { status: 200, headers: { 'Content-Type': 'application/json', ...corsHeaders } } ); } catch (error) { return new Response( JSON.stringify({ jsonrpc: '2.0', id: body.id ?? null, error: { code: -32603, message: error instanceof Error ? error.message : String(error), }, }), { status: 500, headers: { 'Content-Type': 'application/json' } } ); } } return new Response('Not Found', { status: 404 }); }, }; function getAuthHTML(googleAuthUrl: string, state: string, redirectUri: string, codeChallenge: string, codeChallengeMethod: string, clientId: string, resource: string): string { return ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <title>Authenticate Granola MCP Server</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <script src="https://cdn.tailwindcss.com?plugins=forms"></script> <style> :root { color-scheme: light; --background: 250 250 250; --foreground: 17 17 17; --muted: 120 120 120; --border: 229 229 229; --accent: 22 163 74; --error: 220 38 38; } * { box-sizing: border-box; } body { margin: 0; font-family: "Inter", "SF Pro", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background-color: rgb(var(--background)); color: rgb(var(--foreground)); -webkit-font-smoothing: antialiased; line-height: 1.5; } .card { border: 1px solid rgba(var(--border), 1); border-radius: 1rem; background: white; } code { font-family: "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, monospace; font-size: 0.8rem; background: rgba(var(--border), 0.5); padding: 0.125rem 0.5rem; border-radius: 0.375rem; } .step-num { width: 1.75rem; height: 1.75rem; border-radius: 9999px; background: rgb(var(--foreground)); color: white; display: inline-flex; align-items: center; justify-content: center; font-size: 0.75rem; font-weight: 600; flex-shrink: 0; } .btn-primary { display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; padding: 0.625rem 1.25rem; border-radius: 0.5rem; font-size: 0.875rem; font-weight: 500; transition: all 0.15s; cursor: pointer; border: none; } .btn-google { background: rgb(var(--foreground)); color: white; } .btn-google:hover { opacity: 0.9; } .btn-submit { background: rgb(var(--accent)); color: white; } .btn-submit:hover:not(:disabled) { opacity: 0.9; } .btn-submit:disabled { background: rgb(var(--border)); color: rgb(var(--muted)); cursor: not-allowed; } .validation-msg { font-size: 0.75rem; margin-top: 0.5rem; display: flex; align-items: center; gap: 0.375rem; } .validation-msg.error { color: rgb(var(--error)); } .validation-msg.success { color: rgb(var(--accent)); } .screenshot-container { border-radius: 0.75rem; overflow: hidden; border: 1px solid rgba(var(--border), 1); box-shadow: 0 4px 12px rgba(0,0,0,0.08); } .screenshot-container img { width: 100%; height: auto; display: block; } </style> </head> <body class="min-h-screen"> <main class="flex min-h-screen items-start justify-center px-6 py-12"> <div class="w-full max-w-2xl space-y-8"> <!-- Header --> <header class="text-center space-y-2"> <div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-green-600 text-white text-xl font-bold mb-2">G</div> <h1 class="text-2xl font-semibold tracking-tight">Connect to Granola</h1> <p class="text-sm text-neutral-500 max-w-md mx-auto"> Authenticate with your Granola account to access your meeting notes. </p> </header> <!-- Instructions Card --> <section class="card p-6 space-y-5"> <div class="space-y-1"> <h2 class="text-base font-semibold">Before you begin</h2> <p class="text-sm text-neutral-500"> This process requires you to copy a URL from your browser. Here's what will happen: </p> </div> <ol class="space-y-3 text-sm"> <li class="flex gap-3"> <span class="step-num">1</span> <span class="text-neutral-700">Click <strong>Continue with Google</strong> below to open the sign-in popup.</span> </li> <li class="flex gap-3"> <span class="step-num">2</span> <span class="text-neutral-700">Complete the Google sign-in flow in the popup window.</span> </li> <li class="flex gap-3"> <span class="step-num">3</span> <div class="text-neutral-700"> <span>When you see this dialog, click <strong>"Cancel"</strong> — do NOT open the Granola app:</span> <div class="screenshot-container mt-3"> <img src="/instr-granola-open.png" alt="Click Cancel on the Open Granola dialog" /> </div> </div> </li> <li class="flex gap-3"> <span class="step-num">4</span> <span class="text-neutral-700">Copy the <strong>entire URL</strong> from your browser's address bar and paste it below.</span> </li> </ol> <div class="rounded-lg bg-amber-50 border border-amber-200 p-3 text-sm text-amber-800"> <strong>Important:</strong> The URL should look like: <code>https://www.granola.ai/app-redirect?code=...</code> </div> </section> <!-- Action Card --> <section class="card p-6 space-y-6"> <!-- Step 1: Google Button --> <div class="space-y-3"> <div class="flex items-center gap-2"> <span class="step-num">1</span> <span class="text-sm font-medium">Sign in with Google</span> </div> <a href="${googleAuthUrl}" onclick="event.preventDefault(); window.open('${googleAuthUrl}', 'granola-google-auth', 'width=560,height=760,resizable=yes,scrollbars=yes');" class="btn-primary btn-google" > <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"> <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/> <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/> <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/> <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/> </svg> Continue with Google </a> </div> <hr class="border-neutral-200" /> <!-- Step 2: Paste URL --> <form class="space-y-4" action="/complete-auth" method="POST" id="authForm"> <div class="space-y-3"> <div class="flex items-center gap-2"> <span class="step-num">2</span> <span class="text-sm font-medium">Paste the redirect URL</span> </div> <input type="text" name="redirect_url" id="redirectUrl" required placeholder="https://www.granola.ai/app-redirect?code=..." class="w-full rounded-lg border border-neutral-300 bg-white px-4 py-3 font-mono text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-green-600 focus:outline-none focus:ring-2 focus:ring-green-600/20 transition-all" /> <div id="validationMessage" class="validation-msg" style="display: none;"></div> </div> <input type="hidden" name="state" value="${state}" /> <input type="hidden" name="mcp_redirect_uri" value="${redirectUri}" /> <input type="hidden" name="code_challenge" value="${codeChallenge}" /> <input type="hidden" name="code_challenge_method" value="${codeChallengeMethod}" /> <input type="hidden" name="client_id" value="${clientId}" /> <input type="hidden" name="resource" value="${resource}" /> <button type="submit" id="submitBtn" disabled class="btn-primary btn-submit w-full" > Complete Authentication </button> </form> </section> <p class="text-center text-xs text-neutral-400"> Keep this page open until the authentication completes. </p> </div> </main> <script> const urlInput = document.getElementById('redirectUrl'); const submitBtn = document.getElementById('submitBtn'); const validationMsg = document.getElementById('validationMessage'); function validateUrl(url) { const trimmed = url.trim(); if (!trimmed) { return { valid: false, message: null }; } // Check if it looks like a URL if (!trimmed.startsWith('http')) { return { valid: false, message: 'URL must start with https://' }; } // Check for app-redirect if (!trimmed.includes('app-redirect')) { return { valid: false, message: 'Missing "app-redirect" — make sure you copied the full URL' }; } // Check for code parameter if (!trimmed.includes('code=')) { return { valid: false, message: 'Missing "code" parameter — the URL should contain code=...' }; } // Extract and check code value try { const parsed = new URL(trimmed); const code = parsed.searchParams.get('code'); if (!code || code.length < 10) { return { valid: false, message: 'Invalid code — make sure you copied the complete URL' }; } } catch (e) { return { valid: false, message: 'Invalid URL format' }; } return { valid: true, message: 'URL looks valid!' }; } urlInput.addEventListener('input', function() { const result = validateUrl(this.value); if (result.message) { validationMsg.style.display = 'flex'; validationMsg.className = 'validation-msg ' + (result.valid ? 'success' : 'error'); validationMsg.innerHTML = (result.valid ? '✓ ' : '✗ ') + result.message; } else { validationMsg.style.display = 'none'; } submitBtn.disabled = !result.valid; }); // Also validate on paste urlInput.addEventListener('paste', function() { setTimeout(() => { urlInput.dispatchEvent(new Event('input')); }, 0); }); </script> </body> </html> `; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/pavitarsaini/granola-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server