UNPKG

@gongrzhe/server-notion-mcp

Version:

Official MCP server for Notion API with session-aware multi-user support

381 lines (333 loc) • 13.8 kB
import path from 'node:path' import { fileURLToPath } from 'url' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js' import { randomUUID, randomBytes } from 'node:crypto' import express from 'express' import { initProxy, ValidationError } from '../src/init-server' import { SessionAwareTransportManager } from '../src/session-aware-transport' import fs from 'node:fs' import { OpenAPIV3 } from 'openapi-types' export async function startServer(args: string[] = process.argv) { const filename = fileURLToPath(import.meta.url) const directory = path.dirname(filename) const specPath = path.resolve(directory, '../scripts/notion-openapi.json') const baseUrl = process.env.BASE_URL ?? undefined // Parse command line arguments manually (similar to slack-mcp approach) function parseArgs() { const args = process.argv.slice(2); let transport = 'stdio'; // default let port = 3000; let authToken: string | undefined; let multiUser = false; for (let i = 0; i < args.length; i++) { if (args[i] === '--transport' && i + 1 < args.length) { transport = args[i + 1]; i++; // skip next argument } else if (args[i] === '--port' && i + 1 < args.length) { port = parseInt(args[i + 1], 10); i++; // skip next argument } else if (args[i] === '--auth-token' && i + 1 < args.length) { authToken = args[i + 1]; i++; // skip next argument } else if (args[i] === '--multi-user') { multiUser = true; } else if (args[i] === '--help' || args[i] === '-h') { console.log(` Usage: notion-mcp-server [options] Options: --transport <type> Transport type: 'stdio', 'http', or 'sse' (default: stdio) --port <number> Port for HTTP server when using HTTP/SSE transport (default: 3000) --auth-token <token> Bearer token for HTTP transport authentication (optional) --multi-user Enable multi-user session-aware mode (requires NOTION_TOKEN per connection) --help, -h Show this help message Environment Variables: NOTION_TOKEN Notion integration token (required for stdio mode, optional for multi-user mode) OPENAPI_MCP_HEADERS JSON string with Notion API headers (alternative) AUTH_TOKEN Bearer token for HTTP transport authentication (alternative to --auth-token) Examples: notion-mcp-server # Use stdio transport (default) notion-mcp-server --transport stdio # Use stdio transport explicitly notion-mcp-server --transport http # Use HTTP transport on port 3000 notion-mcp-server --transport http --port 8080 # Use HTTP transport on port 8080 notion-mcp-server --transport http --multi-user # Multi-user HTTP transport (no global NOTION_TOKEN required) notion-mcp-server --transport sse --multi-user --port 8080 # Multi-user SSE transport on port 8080 `); process.exit(0); } // Ignore unrecognized arguments (like command name passed by Docker) } return { transport: transport.toLowerCase(), port, authToken, multiUser }; } const options = parseArgs() const transport = options.transport if (transport === 'stdio') { // Use stdio transport (default) const proxy = await initProxy(specPath, baseUrl) await proxy.connect(new StdioServerTransport()) return proxy.getServer() } else if ((transport === 'http' || transport === 'sse') && options.multiUser) { // Multi-user session-aware transport with per-connection Notion tokens const app = express() app.use(express.json()) // CORS middleware for web clients app.use((req, res, next) => { res.header('Access-Control-Allow-Origin', '*') res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, mcp-session-id') if (req.method === 'OPTIONS') { res.sendStatus(200) return } next() }) // Load OpenAPI spec for session manager const rawSpec = fs.readFileSync(specPath, 'utf-8') const openApiSpec = JSON.parse(rawSpec) as OpenAPIV3.Document if (baseUrl) { openApiSpec.servers![0].url = baseUrl } // Create session-aware transport manager const transportManager = new SessionAwareTransportManager() // Health endpoint (no authentication required) app.get('/health', (req, res) => { const stats = transportManager.getSessionStats() res.status(200).json({ status: 'healthy', timestamp: new Date().toISOString(), transport: transport, port: options.port, mode: 'multi-user-session-aware', ...stats }) }) // Session stats endpoint (no authentication required) app.get('/sessions', (req, res) => { const stats = transportManager.getSessionStats() res.status(200).json(stats) }) // Extract Notion token from Authorization header for multi-user mode const extractNotionToken = (req: express.Request): string | undefined => { const authHeader = req.headers['authorization'] if (authHeader && authHeader.startsWith('Bearer ')) { return authHeader.substring(7) // Remove "Bearer " prefix } return undefined } // Handle all MCP requests app.all('/mcp', async (req, res) => { try { const sessionId = req.headers['mcp-session-id'] as string | undefined const isInitRequest = !sessionId && isInitializeRequest(req.body) const notionToken = extractNotionToken(req) // For multi-user mode, require Notion token in Authorization header if (!notionToken && (isInitRequest || !sessionId)) { res.status(401).json({ jsonrpc: '2.0', error: { code: -32001, message: 'Unauthorized: Notion token required in Authorization header (Bearer <token>)', }, id: null, }) return } console.log(`šŸ“” Notion MCP ${req.method} request - Session: ${sessionId || 'NEW'}, Token: ${notionToken ? notionToken.substring(0, 8) + '...' : 'NONE'}`) // Get or create session with complete isolation const { sessionData, authSessionId } = await transportManager.getOrCreateSession( sessionId, req, res, isInitRequest, openApiSpec, notionToken ) // Handle the request with session context await transportManager.handleSessionRequest(sessionData, req, res, req.body) } catch (error) { console.error('āŒ Error in Notion session-aware MCP request:', error) if (!res.headersSent) { res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error', }, id: null, }) } } }) // Cleanup expired sessions every 10 minutes setInterval(() => { transportManager.cleanupExpiredSessions(30 * 60 * 1000) // 30 minutes }, 10 * 60 * 1000) // Graceful shutdown process.on('SIGINT', async () => { console.log('\nšŸ”„ Gracefully shutting down Notion session-aware server...') await transportManager.destroy() process.exit(0) }) const port = options.port app.listen(port, '0.0.0.0', () => { console.log(`šŸš€ Notion Multi-User Session-Aware MCP Server listening on port ${port}`) console.log(`šŸ“” Transport: ${transport.toUpperCase()}`) console.log(`šŸ”— Endpoint: http://0.0.0.0:${port}/mcp`) console.log(`šŸ’š Health check: http://0.0.0.0:${port}/health`) console.log(`šŸ“Š Session stats: http://0.0.0.0:${port}/sessions`) console.log(`šŸ”‘ Authentication: Notion token required in Authorization header`) console.log(`šŸ‘„ Mode: Multi-user with session isolation`) console.log(`āš ļø No global NOTION_TOKEN required - provide per-connection`) }) return { close: () => transportManager.destroy() } } else if (transport === 'http') { // Use Streamable HTTP transport const app = express() app.use(express.json()) // Generate or use provided auth token (from CLI arg or env var) const authToken = options.authToken || process.env.AUTH_TOKEN || randomBytes(32).toString('hex') if (!options.authToken && !process.env.AUTH_TOKEN) { console.log(`Generated auth token: ${authToken}`) console.log(`Use this token in the Authorization header: Bearer ${authToken}`) } // Authorization middleware const authenticateToken = (req: express.Request, res: express.Response, next: express.NextFunction): void => { const authHeader = req.headers['authorization'] const token = authHeader && authHeader.split(' ')[1] // Bearer TOKEN if (!token) { res.status(401).json({ jsonrpc: '2.0', error: { code: -32001, message: 'Unauthorized: Missing bearer token', }, id: null, }) return } if (token !== authToken) { res.status(403).json({ jsonrpc: '2.0', error: { code: -32002, message: 'Forbidden: Invalid bearer token', }, id: null, }) return } next() } // Health endpoint (no authentication required) app.get('/health', (req, res) => { res.status(200).json({ status: 'healthy', timestamp: new Date().toISOString(), transport: 'http', port: options.port }) }) // Apply authentication to all /mcp routes app.use('/mcp', authenticateToken) // Map to store transports by session ID const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {} // Handle POST requests for client-to-server communication app.post('/mcp', async (req, res) => { try { // Check for existing session ID const sessionId = req.headers['mcp-session-id'] as string | undefined let transport: StreamableHTTPServerTransport if (sessionId && transports[sessionId]) { // Reuse existing transport transport = transports[sessionId] } else if (!sessionId && isInitializeRequest(req.body)) { // New initialization request transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sessionId) => { // Store the transport by session ID transports[sessionId] = transport } }) // Clean up transport when closed transport.onclose = () => { if (transport.sessionId) { delete transports[transport.sessionId] } } const proxy = await initProxy(specPath, baseUrl) await proxy.connect(transport) } else { // Invalid request res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: No valid session ID provided', }, id: null, }) return } // Handle the request await transport.handleRequest(req, res, req.body) } catch (error) { console.error('Error handling MCP request:', error) if (!res.headersSent) { res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error', }, id: null, }) } } }) // Handle GET requests for server-to-client notifications via Streamable HTTP app.get('/mcp', async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined if (!sessionId || !transports[sessionId]) { res.status(400).send('Invalid or missing session ID') return } const transport = transports[sessionId] await transport.handleRequest(req, res) }) // Handle DELETE requests for session termination app.delete('/mcp', async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined if (!sessionId || !transports[sessionId]) { res.status(400).send('Invalid or missing session ID') return } const transport = transports[sessionId] await transport.handleRequest(req, res) }) const port = options.port app.listen(port, '0.0.0.0', () => { console.log(`MCP Server listening on port ${port}`) console.log(`Endpoint: http://0.0.0.0:${port}/mcp`) console.log(`Health check: http://0.0.0.0:${port}/health`) console.log(`Authentication: Bearer token required`) if (options.authToken) { console.log(`Using provided auth token`) } }) // Return a dummy server for compatibility return { close: () => {} } } else { throw new Error(`Unsupported transport: ${transport}. Use 'stdio', 'http', or 'sse' with optional --multi-user flag.`) } } startServer(process.argv).catch(error => { if (error instanceof ValidationError) { console.error('Invalid OpenAPI 3.1 specification:') error.errors.forEach(err => console.error(err)) } else { console.error('Error:', error) } process.exit(1) })