@gongrzhe/server-notion-mcp
Version:
Official MCP server for Notion API with session-aware multi-user support
381 lines (333 loc) ⢠13.8 kB
text/typescript
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)
})