Spaces:
Sleeping
Sleeping
| // src/index.ts | |
| // | |
| // Single entry point for the Google Docs MCP Server. | |
| // | |
| // Usage: | |
| // @a-bonus/google-docs-mcp Start the MCP server (default) | |
| // @a-bonus/google-docs-mcp auth Run the interactive OAuth flow | |
| // | |
| // Remote mode (env vars): | |
| // MCP_TRANSPORT=httpStream Use Streamable HTTP instead of stdio | |
| // BASE_URL=https://... Public URL for OAuth redirects | |
| // ALLOWED_DOMAINS=scio.cz,... Restrict to specific Google Workspace domains | |
| import { FastMCP } from 'fastmcp'; | |
| import { OAuthProxy } from 'fastmcp/auth'; | |
| import { | |
| buildCachedToolsListPayload, | |
| collectToolsWhileRegistering, | |
| installCachedToolsListHandler, | |
| } from './cachedToolsList.js'; | |
| import { initializeGoogleClient } from './clients.js'; | |
| import { parseEnabledToolGroups, registerAllTools } from './tools/index.js'; | |
| import { wrapServerForRemote } from './remoteWrapper.js'; | |
| import { registerLandingPage } from './landingPage.js'; | |
| import { registerDownloadRoute } from './downloadProxy.js'; | |
| import { FirestoreTokenStorage } from './firestoreTokenStorage.js'; | |
| import { logger } from './logger.js'; | |
| // --- Auth subcommand --- | |
| if (process.argv[2] === 'auth') { | |
| const { runAuthFlow } = await import('./auth.js'); | |
| try { | |
| await runAuthFlow(); | |
| logger.info('Authorization complete. You can now start the MCP server.'); | |
| process.exit(0); | |
| } catch (error: any) { | |
| logger.error('Authorization failed:', error.message || error); | |
| process.exit(1); | |
| } | |
| } | |
| // --- Server startup --- | |
| process.on('uncaughtException', (error: NodeJS.ErrnoException) => { | |
| logger.error('Uncaught Exception:', error); | |
| if (error.code === 'EPIPE' || error.code === 'ERR_STREAM_DESTROYED') { | |
| process.exit(1); | |
| } | |
| }); | |
| process.on('unhandledRejection', (reason, _promise) => { | |
| logger.error('Unhandled Promise Rejection:', reason); | |
| }); | |
| process.stdin.on('end', () => { | |
| logger.info('stdin closed — MCP host disconnected. Shutting down.'); | |
| process.exit(0); | |
| }); | |
| process.stdin.on('error', () => { | |
| process.exit(0); | |
| }); | |
| // Graceful shutdown on termination signals. | |
| // Without these handlers, a host that sends SIGTERM (rather than closing | |
| // stdin) leaves the server running and accumulating in the background. | |
| const cleanShutdown = (signal: string) => { | |
| logger.info(`Received ${signal} — shutting down.`); | |
| process.exit(0); | |
| }; | |
| process.on('SIGTERM', () => cleanShutdown('SIGTERM')); | |
| process.on('SIGINT', () => cleanShutdown('SIGINT')); | |
| process.on('SIGHUP', () => cleanShutdown('SIGHUP')); | |
| // Orphan-process watchdog (stdio mode only). | |
| // In practice, some MCP clients exit without sending SIGTERM, and the | |
| // stdin 'end' event can be swallowed by the transport's internal read | |
| // loop — leaving the server running as a zombie that consumes CPU and | |
| // memory indefinitely. As a backstop, detect reparenting to init | |
| // (PID 1) and exit. The check runs every 10 s and is unref()'d so it | |
| // does not keep the event loop alive on its own. | |
| if (process.env.MCP_TRANSPORT !== 'httpStream') { | |
| const initialPpid = process.ppid; | |
| const watchdog = setInterval(() => { | |
| if (process.ppid !== initialPpid && process.ppid === 1) { | |
| logger.info( | |
| `Parent process (was PID ${initialPpid}) exited; reparented to init. Shutting down.` | |
| ); | |
| process.exit(0); | |
| } | |
| }, 10_000); | |
| watchdog.unref(); | |
| } | |
| const isRemote = process.env.MCP_TRANSPORT === 'httpStream'; | |
| if (isRemote) { | |
| const missing = ['BASE_URL', 'GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET'].filter( | |
| (k) => !process.env[k] | |
| ); | |
| if (missing.length > 0) { | |
| logger.error(`FATAL: Missing required env vars for httpStream mode: ${missing.join(', ')}`); | |
| process.exit(1); | |
| } | |
| } | |
| const GOOGLE_API_SCOPES = [ | |
| 'openid', | |
| 'email', | |
| 'https://www.googleapis.com/auth/documents', | |
| 'https://www.googleapis.com/auth/spreadsheets', | |
| 'https://www.googleapis.com/auth/drive', | |
| 'https://www.googleapis.com/auth/script.external_request', | |
| 'https://www.googleapis.com/auth/gmail.modify', | |
| 'https://www.googleapis.com/auth/calendar.events', | |
| ]; | |
| const oauthProxy = isRemote | |
| ? new OAuthProxy({ | |
| upstreamAuthorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', | |
| upstreamTokenEndpoint: 'https://oauth2.googleapis.com/token', | |
| upstreamClientId: process.env.GOOGLE_CLIENT_ID!, | |
| upstreamClientSecret: process.env.GOOGLE_CLIENT_SECRET!, | |
| baseUrl: process.env.BASE_URL!, | |
| scopes: GOOGLE_API_SCOPES, | |
| allowedRedirectUriPatterns: [ | |
| 'http://localhost:*', | |
| `${process.env.BASE_URL}/*`, | |
| 'cursor://*', | |
| 'https://claude.ai/*', | |
| 'https://claude.com/*', | |
| 'claude://*', | |
| ], | |
| jwtSigningKey: process.env.JWT_SIGNING_KEY, | |
| encryptionKey: process.env.TOKEN_ENCRYPTION_KEY, | |
| consentRequired: false, | |
| accessTokenTtl: 2592000, | |
| refreshTokenTtl: 2592000, | |
| ...(process.env.TOKEN_STORE === 'firestore' && { | |
| tokenStorage: new FirestoreTokenStorage(process.env.GCLOUD_PROJECT), | |
| }), | |
| }) | |
| : undefined; | |
| if (oauthProxy) { | |
| // issueSwappedTokens() inlines the TTL logic: it checks | |
| // upstreamTokens.expiresIn > 0 BEFORE config.accessTokenTtl. | |
| // Google always returns expiresIn=3600, so our 30-day config is | |
| // never reached. Zero it out so the config fallback is used. | |
| const origIssue = (oauthProxy as any).issueSwappedTokens.bind(oauthProxy); | |
| (oauthProxy as any).issueSwappedTokens = async function (clientId: string, upstreamTokens: any) { | |
| if (this.config.accessTokenTtl) { | |
| return origIssue(clientId, { ...upstreamTokens, expiresIn: 0 }); | |
| } | |
| return origIssue(clientId, upstreamTokens); | |
| }; | |
| } | |
| const server = new FastMCP({ | |
| name: 'Ultimate Google Docs & Sheets MCP Server', | |
| version: '1.0.0', | |
| ...(isRemote && | |
| oauthProxy && { | |
| authenticate: async (request: any) => { | |
| if (!request) return undefined; | |
| const authHeader = request.headers?.authorization; | |
| if (!authHeader?.startsWith('Bearer ')) return undefined; | |
| const token = authHeader.slice(7); | |
| const upstreamTokens = await oauthProxy.loadUpstreamTokens(token); | |
| if (!upstreamTokens) return undefined; | |
| return { | |
| accessToken: upstreamTokens.accessToken, | |
| refreshToken: upstreamTokens.refreshToken, | |
| expiresAt: upstreamTokens.expiresIn | |
| ? Math.floor(Date.now() / 1000) + upstreamTokens.expiresIn | |
| : undefined, | |
| idToken: upstreamTokens.idToken, | |
| scopes: upstreamTokens.scope, | |
| }; | |
| }, | |
| oauth: { | |
| enabled: true, | |
| authorizationServer: oauthProxy.getAuthorizationServerMetadata(), | |
| protectedResource: { | |
| authorizationServers: [process.env.BASE_URL!], | |
| resource: process.env.BASE_URL!, | |
| scopesSupported: GOOGLE_API_SCOPES, | |
| }, | |
| proxy: oauthProxy, | |
| }, | |
| }), | |
| }); | |
| const registeredTools: Parameters<FastMCP['addTool']>[0][] = []; | |
| collectToolsWhileRegistering(server, registeredTools); | |
| if (isRemote) wrapServerForRemote(server); | |
| let enabledToolGroups: ReturnType<typeof parseEnabledToolGroups>; | |
| try { | |
| enabledToolGroups = parseEnabledToolGroups(); | |
| registerAllTools(server, enabledToolGroups); | |
| logger.info(`Registered tool groups: ${enabledToolGroups.join(', ')}.`); | |
| } catch (error: any) { | |
| logger.error(`FATAL: Invalid MCP_TOOL_GROUPS: ${error.message || error}`); | |
| process.exit(1); | |
| } | |
| try { | |
| if (isRemote) { | |
| logger.info('Starting in remote mode (httpStream + MCP OAuth 2.1)...'); | |
| registerLandingPage(server, registeredTools.length); | |
| registerDownloadRoute(server); | |
| const port = parseInt(process.env.PORT || '8080'); | |
| await server.start({ | |
| transportType: 'httpStream', | |
| httpStream: { | |
| port, | |
| host: '0.0.0.0', | |
| }, | |
| }); | |
| logger.info(`MCP Server running at ${process.env.BASE_URL || `http://0.0.0.0:${port}`}/mcp`); | |
| } else { | |
| await initializeGoogleClient(); | |
| logger.info('Starting Ultimate Google Docs & Sheets MCP server...'); | |
| const cachedToolsList = await buildCachedToolsListPayload(registeredTools); | |
| await server.start({ transportType: 'stdio' as const }); | |
| installCachedToolsListHandler(server, cachedToolsList); | |
| logger.info('MCP Server running using stdio. Awaiting client connection...'); | |
| } | |
| logger.info('Process-level error handling configured to prevent crashes from timeout errors.'); | |
| } catch (startError: any) { | |
| logger.error('FATAL: Server failed to start:', startError.message || startError); | |
| process.exit(1); | |
| } | |