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>
`;
}