#!/usr/bin/env node
import express from 'express';
import cors from 'cors';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { AiDDMCPServer } from './aidd-mcp-server.js';
const app = express();
const PORT = process.env.PORT || 8080;
const BASE_URL = process.env.BASE_URL || 'https://mcp.aidd.app';
type TokenResponse = {
access_token: string;
refresh_token?: string;
token_type: 'Bearer';
expires_in: number;
scope?: string;
};
// Cache auth-code exchanges briefly to dedupe client retries and avoid backend rate limits.
const TOKEN_CODE_CACHE_TTL_MS = 5 * 60 * 1000;
const tokenCodeCache = new Map<string, { data: TokenResponse; expiresAt: number }>();
const tokenCodeInFlight = new Map<string, Promise<TokenResponse>>();
const REFRESH_TOKEN_CACHE_TTL_MS = 60 * 60 * 1000;
const REFRESH_TOKEN_FAILURE_TTL_MS = 5 * 60 * 1000;
const refreshTokenCache = new Map<string, { data: TokenResponse; expiresAt: number; tokenExpiresAt: number }>();
const refreshTokenFailures = new Map<string, { status: number; data: any; expiresAt: number }>();
const refreshTokenInFlight = new Map<string, Promise<TokenResponse>>();
const knownRefreshTokens = new Map<string, number>();
const normalizeExpiresIn = (value: unknown, fallbackSeconds = 3600): number => {
const parsed = typeof value === 'number' ? value : Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallbackSeconds;
};
const getCachedTokenResponse = (code: string): TokenResponse | null => {
const cached = tokenCodeCache.get(code);
if (!cached) return null;
if (cached.expiresAt < Date.now()) {
tokenCodeCache.delete(code);
return null;
}
return cached.data;
};
const getCachedRefreshResponse = (refreshToken: string): TokenResponse | null => {
const cached = refreshTokenCache.get(refreshToken);
if (!cached) return null;
const now = Date.now();
if (cached.expiresAt < now || cached.tokenExpiresAt < now) {
refreshTokenCache.delete(refreshToken);
return null;
}
const remainingSeconds = Math.max(0, Math.floor((cached.tokenExpiresAt - now) / 1000));
return { ...cached.data, expires_in: remainingSeconds };
};
const getRefreshFailure = (refreshToken: string): { status: number; data: any } | null => {
const cached = refreshTokenFailures.get(refreshToken);
if (!cached) return null;
if (cached.expiresAt < Date.now()) {
refreshTokenFailures.delete(refreshToken);
return null;
}
return { status: cached.status, data: cached.data };
};
const trackKnownRefreshToken = (refreshToken: string, ttlMs = REFRESH_TOKEN_CACHE_TTL_MS) => {
knownRefreshTokens.set(refreshToken, Date.now() + ttlMs);
};
const isKnownRefreshToken = (refreshToken: string): boolean => {
const expiresAt = knownRefreshTokens.get(refreshToken);
if (!expiresAt) return false;
if (expiresAt < Date.now()) {
knownRefreshTokens.delete(refreshToken);
return false;
}
return true;
};
type TokenMetrics = {
total: number;
authCode: number;
refresh: number;
authCodeCacheHit: number;
refreshCacheHit: number;
refreshFailureCacheHit: number;
authCodeInFlightHit: number;
refreshInFlightHit: number;
refreshKnown: number;
refreshUnknown: number;
backendErrors: number;
backend429: number;
backend429AuthCode: number;
backend429Refresh: number;
};
const TOKEN_METRICS_WINDOW_MS = 5 * 60 * 1000;
const TOKEN_429_LOG_INTERVAL_MS = 60 * 1000;
let tokenMetricsWindowStart = Date.now();
let last429LogAt = 0;
const createTokenMetrics = (): TokenMetrics => ({
total: 0,
authCode: 0,
refresh: 0,
authCodeCacheHit: 0,
refreshCacheHit: 0,
refreshFailureCacheHit: 0,
authCodeInFlightHit: 0,
refreshInFlightHit: 0,
refreshKnown: 0,
refreshUnknown: 0,
backendErrors: 0,
backend429: 0,
backend429AuthCode: 0,
backend429Refresh: 0,
});
let tokenMetrics = createTokenMetrics();
const logTokenMetrics = (reason: string) => {
console.log('📈 Token metrics:', {
reason,
window_start: new Date(tokenMetricsWindowStart).toISOString(),
window_ms: TOKEN_METRICS_WINDOW_MS,
...tokenMetrics,
});
};
const rotateTokenMetricsIfNeeded = (reason: string) => {
const now = Date.now();
if (now - tokenMetricsWindowStart >= TOKEN_METRICS_WINDOW_MS) {
logTokenMetrics(reason);
tokenMetricsWindowStart = now;
tokenMetrics = createTokenMetrics();
}
};
const recordTokenMetric = (key: keyof TokenMetrics, amount = 1) => {
rotateTokenMetricsIfNeeded('window');
tokenMetrics[key] += amount;
};
const recordBackend429 = (
grantType: 'authorization_code' | 'refresh_token' | 'unknown',
retryAfterSeconds?: number
) => {
recordTokenMetric('backend429');
if (grantType === 'authorization_code') {
recordTokenMetric('backend429AuthCode');
} else if (grantType === 'refresh_token') {
recordTokenMetric('backend429Refresh');
}
const now = Date.now();
if (now - last429LogAt >= TOKEN_429_LOG_INTERVAL_MS) {
console.warn('⚠️ Backend rate limit:', {
grant_type: grantType,
retry_after: retryAfterSeconds,
window_start: new Date(tokenMetricsWindowStart).toISOString(),
window_ms: TOKEN_METRICS_WINDOW_MS,
backend_429: tokenMetrics.backend429,
backend_errors: tokenMetrics.backendErrors,
});
last429LogAt = now;
}
};
const CACHE_SWEEP_INTERVAL_MS = 10 * 60 * 1000;
const sweepCaches = () => {
const now = Date.now();
for (const [key, value] of tokenCodeCache) {
if (value.expiresAt < now) tokenCodeCache.delete(key);
}
for (const [key, value] of refreshTokenCache) {
if (value.expiresAt < now || value.tokenExpiresAt < now) {
refreshTokenCache.delete(key);
}
}
for (const [key, value] of refreshTokenFailures) {
if (value.expiresAt < now) refreshTokenFailures.delete(key);
}
for (const [key, expiresAt] of knownRefreshTokens) {
if (expiresAt < now) knownRefreshTokens.delete(key);
}
};
setInterval(sweepCaches, CACHE_SWEEP_INTERVAL_MS).unref();
// Middleware
app.use(cors({
origin: [
// Claude/Anthropic
'https://claude.ai',
'https://*.claude.ai',
'https://*.anthropic.com',
/^https:\/\/claude\.ai/,
/^https:\/\/.*\.claude\.ai/,
// ChatGPT/OpenAI
'https://chat.openai.com',
'https://chatgpt.com',
'https://*.openai.com',
'https://*.chatgpt.com',
/^https:\/\/chat\.openai\.com/,
/^https:\/\/chatgpt\.com/,
/^https:\/\/.*\.openai\.com/,
/^https:\/\/.*\.chatgpt\.com/,
],
credentials: true,
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true })); // Support form-encoded OAuth requests
// ============================================================================
// OAUTH 2.0 DISCOVERY ENDPOINTS (for Claude.ai and ChatGPT integration)
// ============================================================================
// OAuth 2.0 Authorization Server Metadata
// RFC 8414: https://datatracker.ietf.org/doc/html/rfc8414
app.get('/.well-known/oauth-authorization-server', (req, res) => {
res.json({
issuer: BASE_URL,
authorization_endpoint: `${BASE_URL}/oauth/authorize`,
token_endpoint: `${BASE_URL}/oauth/token`,
registration_endpoint: `${BASE_URL}/register`,
scopes_supported: ['profile', 'email', 'tasks', 'notes', 'action_items'],
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
code_challenge_methods_supported: ['S256'],
// Icon/logo fields for connector display
logo_uri: `${BASE_URL}/icon.png`,
service_documentation: `${BASE_URL}`,
});
});
// OpenID Connect Discovery (for ChatGPT App Store)
// https://openid.net/specs/openid-connect-discovery-1_0.html
app.get('/.well-known/openid-configuration', (req, res) => {
res.json({
issuer: BASE_URL,
authorization_endpoint: `${BASE_URL}/oauth/authorize`,
token_endpoint: `${BASE_URL}/oauth/token`,
userinfo_endpoint: `${BASE_URL}/oauth/userinfo`,
registration_endpoint: `${BASE_URL}/register`,
jwks_uri: `${BASE_URL}/.well-known/jwks.json`,
scopes_supported: ['openid', 'profile', 'email', 'tasks', 'notes', 'action_items'],
response_types_supported: ['code'],
response_modes_supported: ['query'],
grant_types_supported: ['authorization_code', 'refresh_token'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
code_challenge_methods_supported: ['S256', 'plain'],
claims_supported: ['sub', 'name', 'email', 'email_verified'],
});
});
// JWKS endpoint (placeholder - actual JWT signing would need real keys)
app.get('/.well-known/jwks.json', (req, res) => {
res.json({
keys: [] // In production, this would contain the public keys used for JWT signing
});
});
// OpenAI/ChatGPT domain verification
app.get('/.well-known/openai-apps-challenge', (req, res) => {
res.type('text/plain').send('IbNVZb4u87p8B3JwlKb6S1OiRYNBSXIEspE2p88Wcyk');
});
// UserInfo endpoint for OpenID Connect
app.get('/oauth/userinfo', async (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'unauthorized' });
}
const token = authHeader.substring(7);
try {
// Validate token with backend and get user info
const response = await fetch(
'https://aidd-backend-prod-739193356129.us-central1.run.app/api/auth/me',
{
headers: { 'Authorization': `Bearer ${token}` }
}
);
if (!response.ok) {
return res.status(401).json({ error: 'invalid_token' });
}
const userData = await response.json() as any;
res.json({
sub: userData.id || userData.userId,
name: userData.displayName || userData.name,
email: userData.email,
email_verified: userData.emailVerified || false,
});
} catch (error) {
console.error('UserInfo error:', error);
res.status(500).json({ error: 'server_error' });
}
});
// OAuth 2.0 Protected Resource Metadata
// RFC 9068: https://datatracker.ietf.org/doc/html/rfc9068
app.get('/.well-known/oauth-protected-resource', (req, res) => {
res.json({
resource: BASE_URL,
authorization_servers: [BASE_URL],
scopes_supported: ['profile', 'email', 'tasks', 'notes', 'action_items'],
bearer_methods_supported: ['header'],
resource_signing_alg_values_supported: ['RS256'],
});
});
// Alternative paths with /mcp suffix (Claude tries both)
app.get('/.well-known/oauth-authorization-server/mcp', (req, res) => {
res.redirect('/.well-known/oauth-authorization-server');
});
app.get('/.well-known/oauth-protected-resource/mcp', (req, res) => {
res.redirect('/.well-known/oauth-protected-resource');
});
// OAuth 2.0 Dynamic Client Registration
// RFC 7591: https://datatracker.ietf.org/doc/html/rfc7591
app.post('/register', (req, res) => {
const { redirect_uris, client_name } = req.body;
// For Claude.ai, we auto-approve the registration
// In production, you'd validate and store this
const clientId = `claude_${Date.now()}`;
const clientSecret = Buffer.from(`secret_${Date.now()}`).toString('base64');
res.json({
client_id: clientId,
client_secret: clientSecret,
client_name: client_name || 'MCP Client',
redirect_uris: redirect_uris || [],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'client_secret_post',
});
});
// OAuth signin redirect (Claude.ai sometimes uses /oauth/signin)
app.get('/oauth/signin', (req, res) => {
console.log('🔄 Redirecting /oauth/signin to /oauth/authorize');
// Preserve all query parameters
const queryString = req.url.split('?')[1] || '';
res.redirect(`/oauth/authorize${queryString ? '?' + queryString : ''}`);
});
// OAuth Authorization Endpoint
app.get('/oauth/authorize', (req, res) => {
const {
client_id,
redirect_uri,
state,
response_type,
scope,
code_challenge,
code_challenge_method,
} = req.query;
console.log('📝 OAuth authorize request:', { client_id, redirect_uri, state });
// Redirect to AiDD backend OAuth with Claude's callback in state
const backendAuthUrl = new URL('https://aidd-backend-prod-739193356129.us-central1.run.app/oauth/authorize');
// Store Claude's callback info in state for later redirect
const stateData = {
claude_redirect: redirect_uri,
claude_state: state,
claude_client_id: client_id,
code_challenge,
code_challenge_method,
};
const encodedState = Buffer.from(JSON.stringify(stateData)).toString('base64url');
backendAuthUrl.searchParams.append('client_id', 'aidd-mcp-client'); // Use the client_id that backend expects
backendAuthUrl.searchParams.append('redirect_uri', `${BASE_URL}/oauth/callback`);
backendAuthUrl.searchParams.append('state', encodedState);
backendAuthUrl.searchParams.append('response_type', 'code');
if (scope) {
backendAuthUrl.searchParams.append('scope', scope as string);
}
// Forward PKCE parameters to backend for proper OAuth 2.0 PKCE validation
if (code_challenge) {
backendAuthUrl.searchParams.append('code_challenge', code_challenge as string);
}
if (code_challenge_method) {
backendAuthUrl.searchParams.append('code_challenge_method', code_challenge_method as string);
}
console.log('🔐 OAuth authorize: forwarding PKCE params:', {
code_challenge: code_challenge ? 'present' : 'missing',
code_challenge_method
});
res.redirect(backendAuthUrl.toString());
});
// OAuth Callback Endpoint (receives code from AiDD backend)
app.get('/oauth/callback', (req, res) => {
const { code, state } = req.query;
console.log('🔄 OAuth callback received:', { code: code ? 'present' : 'missing', state });
try {
// Decode state to get Claude's original redirect URI
const stateData = JSON.parse(Buffer.from(state as string, 'base64url').toString());
const { claude_redirect, claude_state } = stateData;
// Redirect back to the MCP client with the authorization code
const claudeCallbackUrl = new URL(claude_redirect);
claudeCallbackUrl.searchParams.append('code', code as string);
claudeCallbackUrl.searchParams.append('state', claude_state);
console.log('↩️ Redirecting to MCP client:', claudeCallbackUrl.toString());
res.redirect(claudeCallbackUrl.toString());
} catch (error) {
console.error('❌ OAuth callback error:', error);
res.status(400).send('Invalid state parameter');
}
});
// OAuth Token Endpoint
app.post('/oauth/token', async (req, res) => {
const { grant_type, code, refresh_token, redirect_uri, client_id, code_verifier } = req.body;
const refreshKnown = typeof refresh_token === 'string'
? (isKnownRefreshToken(refresh_token) ? 'yes' : 'no')
: 'no';
rotateTokenMetricsIfNeeded('request');
recordTokenMetric('total');
if (grant_type === 'authorization_code') {
recordTokenMetric('authCode');
} else if (grant_type === 'refresh_token') {
recordTokenMetric('refresh');
recordTokenMetric(refreshKnown === 'yes' ? 'refreshKnown' : 'refreshUnknown');
}
console.log('🔑 Token request:', {
grant_type,
code: code ? 'present' : 'missing',
refresh_token: refresh_token ? 'present' : 'missing',
refresh_known: refreshKnown,
});
try {
if (grant_type === 'authorization_code') {
if (!code || typeof code !== 'string') {
return res.status(400).json({ error: 'invalid_request', error_description: 'code is required' });
}
const cached = getCachedTokenResponse(code);
if (cached) {
recordTokenMetric('authCodeCacheHit');
return res.json(cached);
}
let inFlight = tokenCodeInFlight.get(code);
if (!inFlight) {
inFlight = (async () => {
const response = await fetch(
'https://aidd-backend-prod-739193356129.us-central1.run.app/oauth/token',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code,
client_id: 'aidd-mcp-client', // Backend expects this specific client_id
redirect_uri: `${BASE_URL}/oauth/callback`,
code_verifier
}),
}
);
if (!response.ok) {
const errorData = await response.json() as any;
recordTokenMetric('backendErrors');
if (response.status === 429) {
recordBackend429('authorization_code', Number(errorData?.retryAfter));
}
const error = { status: response.status, data: errorData };
throw error;
}
const data = await response.json() as any;
const expiresIn = normalizeExpiresIn(data.expires_in);
const tokenResponse: TokenResponse = {
access_token: data.access_token,
refresh_token: data.refresh_token,
token_type: 'Bearer',
expires_in: expiresIn, // 1 hour (industry standard - refresh token handles session continuity)
scope: 'profile email tasks notes action_items',
};
const now = Date.now();
const tokenExpiresAt = now + expiresIn * 1000;
tokenCodeCache.set(code, { data: tokenResponse, expiresAt: now + TOKEN_CODE_CACHE_TTL_MS });
if (tokenResponse.refresh_token) {
const cacheExpiresAt = Math.min(now + REFRESH_TOKEN_CACHE_TTL_MS, tokenExpiresAt);
refreshTokenCache.set(tokenResponse.refresh_token, { data: tokenResponse, expiresAt: cacheExpiresAt, tokenExpiresAt });
refreshTokenFailures.delete(tokenResponse.refresh_token);
trackKnownRefreshToken(tokenResponse.refresh_token, cacheExpiresAt - now);
}
return tokenResponse;
})();
tokenCodeInFlight.set(code, inFlight);
} else {
recordTokenMetric('authCodeInFlightHit');
}
try {
const tokenResponse = await inFlight;
console.log('✅ Token exchange successful');
return res.json(tokenResponse);
} catch (error: any) {
const status = typeof error?.status === 'number' ? error.status : 500;
const errorData = error?.data;
if (status === 429 && errorData?.retryAfter) {
res.setHeader('Retry-After', String(errorData.retryAfter));
}
console.error('❌ Token exchange failed:', status, errorData || error);
return res.status(status).json(errorData || { error: 'invalid_grant' });
} finally {
tokenCodeInFlight.delete(code);
}
} else if (grant_type === 'refresh_token') {
if (!refresh_token || typeof refresh_token !== 'string') {
return res.status(400).json({ error: 'invalid_request', error_description: 'refresh_token is required' });
}
const cachedResponse = getCachedRefreshResponse(refresh_token);
if (cachedResponse) {
recordTokenMetric('refreshCacheHit');
return res.json(cachedResponse);
}
const cachedFailure = getRefreshFailure(refresh_token);
if (cachedFailure) {
recordTokenMetric('refreshFailureCacheHit');
const errorPayload = cachedFailure.data || { error: 'invalid_grant' };
if (cachedFailure.status === 429 && errorPayload?.retryAfter) {
res.setHeader('Retry-After', String(errorPayload.retryAfter));
}
return res.status(cachedFailure.status).json(errorPayload);
}
let inFlight = refreshTokenInFlight.get(refresh_token);
if (!inFlight) {
inFlight = (async () => {
const response = await fetch(
'https://aidd-backend-prod-739193356129.us-central1.run.app/oauth/token',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token,
client_id: 'aidd-mcp-client' // Backend expects this specific client_id
}),
}
);
if (!response.ok) {
const errorData = await response.json() as any;
recordTokenMetric('backendErrors');
if (response.status === 429) {
recordBackend429('refresh_token', Number(errorData?.retryAfter));
}
const error = { status: response.status, data: errorData };
throw error;
}
const data = await response.json() as any;
const expiresIn = normalizeExpiresIn(data.expires_in);
const tokenResponse: TokenResponse = {
access_token: data.access_token,
refresh_token: data.refresh_token ?? refresh_token,
token_type: 'Bearer',
expires_in: expiresIn, // 1 hour (industry standard)
};
const now = Date.now();
const tokenExpiresAt = now + expiresIn * 1000;
const cacheExpiresAt = Math.min(now + REFRESH_TOKEN_CACHE_TTL_MS, tokenExpiresAt);
refreshTokenCache.set(refresh_token, { data: tokenResponse, expiresAt: cacheExpiresAt, tokenExpiresAt });
refreshTokenFailures.delete(refresh_token);
trackKnownRefreshToken(refresh_token, cacheExpiresAt - now);
if (tokenResponse.refresh_token && tokenResponse.refresh_token !== refresh_token) {
refreshTokenCache.set(tokenResponse.refresh_token, { data: tokenResponse, expiresAt: cacheExpiresAt, tokenExpiresAt });
refreshTokenFailures.delete(tokenResponse.refresh_token);
trackKnownRefreshToken(tokenResponse.refresh_token, cacheExpiresAt - now);
}
return tokenResponse;
})();
refreshTokenInFlight.set(refresh_token, inFlight);
} else {
recordTokenMetric('refreshInFlightHit');
}
try {
const tokenResponse = await inFlight;
return res.json(tokenResponse);
} catch (error: any) {
const status = typeof error?.status === 'number' ? error.status : 500;
const errorData = error?.data;
const errorPayload = errorData || { error: 'invalid_grant' };
const retryAfterSeconds = Number(errorPayload?.retryAfter);
if (status === 429 && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
res.setHeader('Retry-After', String(retryAfterSeconds));
}
console.error('❌ Refresh token failed:', status, errorPayload);
if (status === 429 || status === 400 || status === 401 || status === 403) {
const failureTtlMs = Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
? retryAfterSeconds * 1000
: REFRESH_TOKEN_FAILURE_TTL_MS;
refreshTokenFailures.set(refresh_token, { status, data: errorPayload, expiresAt: Date.now() + failureTtlMs });
}
return res.status(status).json(errorPayload);
} finally {
refreshTokenInFlight.delete(refresh_token);
}
} else {
res.status(400).json({ error: 'unsupported_grant_type' });
}
} catch (error) {
console.error('❌ Token endpoint error:', error);
res.status(500).json({ error: 'server_error' });
}
});
// ============================================================================
// STANDARD ENDPOINTS
// ============================================================================
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
service: 'AiDD MCP Web Connector',
version: '4.4.0',
buildTimestamp: process.env.BUILD_TIMESTAMP || 'unknown',
toolCount: 28,
timestamp: new Date().toISOString(),
});
});
// Debug endpoint to inspect resources structure (for CSP debugging)
app.get('/debug/resources', (req, res) => {
// Import WIDGET_RESOURCES and WIDGET_CSP_CONFIG to show actual structure
const WIDGET_CSP_CONFIG = {
'openai/widgetCSP': {
connect_domains: [
'https://aidd-backend-prod-739193356129.us-central1.run.app',
'https://aidd-mcp-webconnector-739193356129.us-central1.run.app',
'https://mcp.aidd.app',
],
resource_domains: [],
redirect_domains: [],
frame_domains: [],
},
'openai/widgetDomain': 'https://mcp.aidd.app',
};
const widgetResources = [
{ uri: 'ui://widget/notes-list.html', name: 'Notes List', mimeType: 'text/html+skybridge' },
{ uri: 'ui://widget/action-items.html', name: 'Action Items', mimeType: 'text/html+skybridge' },
{ uri: 'ui://widget/task-dashboard.html', name: 'Task Dashboard', mimeType: 'text/html+skybridge' },
{ uri: 'ui://widget/ai-scoring.html', name: 'AI Scoring', mimeType: 'text/html+skybridge' },
].map(w => ({
uri: w.uri,
name: w.name,
mimeType: w.mimeType,
_meta: {
'openai/outputTemplate': w.uri,
'openai/widgetAccessible': true,
...WIDGET_CSP_CONFIG,
},
}));
res.json({
description: 'This shows what resources/list would return',
resources: widgetResources,
});
});
// Icon endpoint - serve optimized PNG (64x64 for better UI display)
app.get('/icon.png', (req, res) => {
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 1 day
res.setHeader('Access-Control-Allow-Origin', '*'); // Allow cross-origin for Claude
res.sendFile('icon-64.png', { root: '.' });
});
// Larger 128x128 icon
app.get('/icon-128.png', (req, res) => {
res.setHeader('Cache-Control', 'public, max-age=86400');
res.setHeader('Access-Control-Allow-Origin', '*');
res.sendFile('icon.png', { root: '.' });
});
// Alternative paths Claude might check
app.get('/logo.png', (req, res) => {
res.setHeader('Cache-Control', 'public, max-age=86400');
res.setHeader('Access-Control-Allow-Origin', '*');
res.sendFile('icon-64.png', { root: '.' });
});
app.get('/.well-known/logo', (req, res) => {
res.setHeader('Cache-Control', 'public, max-age=86400');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Type', 'image/png');
res.sendFile('icon-64.png', { root: '.' });
});
// Favicon endpoint (Claude may look for this)
app.get('/favicon.ico', (req, res) => {
res.setHeader('Cache-Control', 'public, max-age=86400');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Type', 'image/png'); // Serve PNG as favicon
res.sendFile('favicon-32.png', { root: '.' });
});
// Legacy icon endpoint (redirect to new path)
app.get('/icon', (req, res) => {
res.redirect('/icon.png');
});
// ============================================================================
// CHATGPT APP STORE METADATA
// ============================================================================
// App Store Metadata endpoint (for ChatGPT App Store submission)
app.get('/app-metadata', (req, res) => {
res.json({
name: 'AiDD',
display_name: 'AiDD - ADHD Task Manager',
version: '4.4.0',
description: 'ADHD-optimized productivity platform with AI-powered task management, action item extraction, and smart prioritization.',
long_description: 'AiDD helps people with ADHD manage tasks more effectively by breaking down overwhelming projects into manageable pieces. Features include: AI-powered action item extraction from notes, ADHD-optimized task breakdown with time estimates and energy requirements, smart task prioritization that factors in urgency, importance, and your current energy level, and notes management with full-text search.',
homepage_url: 'https://web.aidd.app',
icon_url: `${BASE_URL}/icon.png`,
logo_url: `${BASE_URL}/icon.png`,
categories: ['productivity', 'task-management', 'ai-assistant', 'adhd', 'organization'],
author: {
name: 'AiDD Team',
email: 'support@aidd.app',
website: 'https://aidd.app',
},
legal: {
privacy_policy_url: 'https://aidd.app/privacy',
terms_of_service_url: 'https://aidd.app/terms',
support_url: 'https://aidd.app/support',
},
pricing: {
model: 'freemium',
free_tier: 'Limited AI operations (1 scoring/month, 3 extractions/week)',
pro_tier: '$4.99/month - Unlimited daily scoring, 200 extractions/week',
},
authentication: {
type: 'oauth2',
methods: ['google', 'microsoft', 'apple', 'email'],
oauth_discovery: `${BASE_URL}/.well-known/oauth-authorization-server`,
openid_discovery: `${BASE_URL}/.well-known/openid-configuration`,
},
mcp: {
endpoint: `${BASE_URL}/mcp`,
transport: 'http+sse',
protocol_version: '2024-11-05',
tool_count: 21,
},
security: {
encryption: 'AES-256 at rest',
e2e_encryption: 'Optional end-to-end encryption available',
compliance: ['GDPR', 'CCPA', 'SOC2'],
data_policy: 'User data is never used for AI training or sold to third parties',
},
platforms: ['chatgpt', 'claude', 'web', 'ios'],
});
});
// ============================================================================
// STANDARD ENDPOINTS
// ============================================================================
// Root endpoint - HEAD support for protocol discovery
app.head('/', (req, res) => {
res.setHeader('X-MCP-Version', '2024-11-05');
res.setHeader('X-MCP-Transport', 'sse');
res.status(200).end();
});
// Root endpoint - redirect browser users to web.aidd.app, serve JSON for API clients
app.get('/', (req, res) => {
// Check if request is from a browser (Accept header includes text/html)
const acceptHeader = req.headers.accept || '';
if (acceptHeader.includes('text/html') && !acceptHeader.includes('application/json')) {
// Browser request - redirect to web app
return res.redirect(302, 'https://web.aidd.app');
}
// API/MCP client request - serve JSON
res.setHeader('X-MCP-Version', '2024-11-05');
res.setHeader('X-MCP-Transport', 'sse');
res.json({
name: 'AiDD MCP Web Connector',
version: '4.4.0',
description: 'ADHD-optimized productivity platform with AI-powered task management',
homepage_url: 'https://web.aidd.app',
icon: `${BASE_URL}/icon.png`,
platforms: ['chatgpt', 'claude', 'web', 'ios'],
endpoints: {
health: '/health',
mcp: '/mcp (POST with SSE)',
icon: '/icon.png',
metadata: '/app-metadata',
oauth: {
discovery: '/.well-known/oauth-authorization-server',
openid: '/.well-known/openid-configuration',
register: '/register (POST)',
authorize: '/oauth/authorize',
token: '/oauth/token (POST)',
userinfo: '/oauth/userinfo',
},
},
capabilities: [
'Notes Management',
'Action Items Extraction',
'ADHD-Optimized Task Breakdown',
'AI-Powered Task Prioritization',
'Multi-Service Sync',
],
legal: {
privacy_policy: 'https://aidd.app/privacy',
terms_of_service: 'https://aidd.app/terms',
},
});
});
// MCP endpoint - HEAD support for protocol discovery
app.head('/mcp', (req, res) => {
res.setHeader('X-MCP-Version', '2024-11-05');
res.setHeader('X-MCP-Transport', 'sse');
res.status(200).end();
});
// MCP endpoint - GET support for endpoint verification
app.get('/mcp', (req, res) => {
res.setHeader('X-MCP-Version', '2024-11-05');
res.setHeader('X-MCP-Transport', 'sse');
res.json({
name: 'AiDD',
version: '4.4.0',
protocol: 'mcp',
protocolVersion: '2024-11-05',
transport: 'sse',
description: 'ADHD-optimized productivity platform with AI-powered task management',
icon: `${BASE_URL}/icon.png`,
capabilities: [
'notes',
'action-items',
'tasks',
'ai-extraction',
'ai-conversion',
'ai-scoring',
],
authentication: {
type: 'oauth2',
methods: ['google', 'microsoft', 'apple', 'email'],
discoveryUrl: `${BASE_URL}/.well-known/oauth-authorization-server`,
},
instructions: 'Use POST to establish SSE connection for MCP protocol communication',
});
});
// MCP Streamable HTTP endpoint
app.post('/mcp', async (req, res) => {
console.log('📡 New MCP connection request');
console.log('📋 Request method:', req.body?.method);
console.log('📋 Request ID:', req.body?.id);
const requestMethod = typeof req.body?.method === 'string' ? req.body.method : undefined;
const allowUnauthenticatedMethods = new Set(['initialize', 'tools/list', 'resources/list']);
const isDiscoveryRequest = requestMethod ? allowUnauthenticatedMethods.has(requestMethod) : false;
// Extract OAuth token from Authorization header
const authHeader = req.headers.authorization;
let accessToken: string | undefined;
if (authHeader && authHeader.startsWith('Bearer ')) {
accessToken = authHeader.substring(7);
console.log('🔑 OAuth token detected in request');
} else if (!isDiscoveryRequest) {
console.log('❌ No OAuth token in request - OAuth is required for web connector');
// Return 401 to force OAuth authentication
res.status(401).json({
error: 'unauthorized',
error_description: 'OAuth authentication required. Please authenticate via the OAuth flow.',
authentication_required: true,
authorization_endpoint: `${BASE_URL}/oauth/authorize`,
token_endpoint: `${BASE_URL}/oauth/token`,
});
return;
} else {
console.log('ℹ️ Allowing unauthenticated discovery request:', requestMethod);
}
try {
// Create MCP server instance with OAuth token
console.log('📦 Creating MCP server instance...');
const mcpServer = new AiDDMCPServer(accessToken);
console.log('✅ MCP server instance created');
// Create Streamable HTTP transport (stateless mode for Cloud Run)
console.log('🔌 Creating Streamable HTTP transport...');
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // Stateless mode for Cloud Run
enableJsonResponse: true, // Support both JSON and SSE responses
});
console.log('✅ Streamable HTTP transport created');
// Connect server to transport
console.log('🔗 Connecting MCP server to transport...');
await mcpServer.connect(transport);
console.log('✅ MCP server connected');
// Handle the request using StreamableHTTPServerTransport
console.log('📨 Processing MCP request via transport.handleRequest()...');
await transport.handleRequest(req, res, req.body);
console.log('✅ MCP request processed successfully');
// Handle client disconnect
req.on('close', () => {
console.log('🔌 Client disconnected');
try {
transport.close();
mcpServer.close();
} catch (error) {
console.error('❌ Error closing MCP server:', error);
}
});
// Handle server errors
req.on('error', (error) => {
console.error('❌ Request error:', error);
try {
transport.close();
mcpServer.close();
} catch (err) {
console.error('❌ Error closing MCP server after request error:', err);
}
});
} catch (error) {
console.error('❌ MCP error:', error);
console.error('❌ Error stack:', error instanceof Error ? error.stack : 'No stack trace');
// Close the response if not already sent
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: error instanceof Error ? error.message : 'Internal server error',
},
id: req.body?.id,
});
}
}
});
// Error handling middleware
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error('❌ Server error:', err);
res.status(500).json({
error: 'Internal server error',
message: process.env.NODE_ENV === 'development' ? err.message : undefined,
});
});
// Start server
app.listen(PORT, () => {
console.log(`
╔════════════════════════════════════════════════════════════╗
║ ║
║ 🚀 AiDD MCP Web Connector ║
║ ║
║ Version: 4.0.2 ║
║ Port: ${PORT} ║
║ Mode: Web Connector (HTTP/SSE + OAuth) ║
║ ║
║ Endpoints: ║
║ • Health: http://localhost:${PORT}/health ║
║ • MCP: http://localhost:${PORT}/mcp ║
║ • OAuth Discovery: /.well-known/oauth-authorization-server ║
║ • Client Registration: /register ║
║ ║
║ Status: ✅ Ready for MCP clients ║
║ ║
╚════════════════════════════════════════════════════════════╝
`);
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('📴 SIGTERM received, shutting down gracefully...');
process.exit(0);
});
process.on('SIGINT', () => {
console.log('📴 SIGINT received, shutting down gracefully...');
process.exit(0);
});