Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
OPENAPI_MCP_HEADERS='{"Authorization": "Bearer ntn_***", "Notion-Version": "2022-06-28"}'
NOTION_TOKEN="ntn_***"
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@ bin/
.cursor

.DS_Store

.env

.vscode
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.13.3",
"axios": "^1.8.4",
"dotenv": "^17.2.1",
"express": "^4.21.2",
"form-data": "^4.0.1",
"mustache": "^4.2.0",
Expand Down
215 changes: 115 additions & 100 deletions scripts/start-server.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,39 @@
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 { randomBytes, randomUUID } from "node:crypto";
import path from "node:path";
import { fileURLToPath } from "node: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 express from "express";
import "dotenv/config";

import { initProxy, ValidationError } from '../src/init-server'
import { initProxy, ValidationError } from "../src/init-server";

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
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 transport = "stdio"; // default
let port = 3000;
let authToken: string | undefined;

for (let i = 0; i < args.length; i++) {
if (args[i] === '--transport' && i + 1 < args.length) {
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) {
} 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) {
} else if (args[i] === "--auth-token" && i + 1 < args.length) {
authToken = args[i + 1];
i++; // skip next argument
} else if (args[i] === '--help' || args[i] === '-h') {
} else if (args[i] === "--help" || args[i] === "-h") {
console.log(`
Usage: notion-mcp-server [options]

Expand Down Expand Up @@ -63,179 +64,193 @@ Examples:
return { transport: transport.toLowerCase(), port, authToken };
}

const options = parseArgs()
const transport = options.transport
const options = parseArgs();
const transport = options.transport;

if (transport === 'stdio') {
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') {
const proxy = await initProxy(specPath, baseUrl);
await proxy.connect(new StdioServerTransport());
return proxy.getServer();
} else if (transport === "http") {
// Use Streamable HTTP transport
const app = express()
app.use(express.json())
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')
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}`)
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
const authenticateToken = (
req: express.Request,
res: express.Response,
next: express.NextFunction,
): void => {
const authHeader = req.headers["authorization"];
const token = authHeader?.split(" ")[1]; // Bearer TOKEN

if (!token) {
res.status(401).json({
jsonrpc: '2.0',
jsonrpc: "2.0",
error: {
code: -32001,
message: 'Unauthorized: Missing bearer token',
message: "Unauthorized: Missing bearer token",
},
id: null,
})
return
});
return;
}

if (token !== authToken) {
res.status(403).json({
jsonrpc: '2.0',
jsonrpc: "2.0",
error: {
code: -32002,
message: 'Forbidden: Invalid bearer token',
message: "Forbidden: Invalid bearer token",
},
id: null,
})
return
});
return;
}

next()
}
next();
};

// Health endpoint (no authentication required)
app.get('/health', (req, res) => {
app.get("/health", (req, res) => {
res.status(200).json({
status: 'healthy',
status: "healthy",
timestamp: new Date().toISOString(),
transport: 'http',
port: options.port
})
})
transport: "http",
port: options.port,
});
});

// Apply authentication to all /mcp routes
app.use('/mcp', authenticateToken)
app.use("/mcp", authenticateToken);

// Map to store transports by session ID
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}
const transports: { [sessionId: string]: StreamableHTTPServerTransport } =
{};

// Handle POST requests for client-to-server communication
app.post('/mcp', async (req, res) => {
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
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)) {
transport = transports[sessionId];
} else if (isInitializeRequest(req.body)) {
// New initialization request
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sessionId) => {
// Store the transport by session ID
transports[sessionId] = transport
}
})
transports[sessionId] = transport;
},
});

// Clean up transport when closed
transport.onclose = () => {
if (transport.sessionId) {
delete transports[transport.sessionId]
delete transports[transport.sessionId];
}
}
};

const proxy = await initProxy(specPath, baseUrl)
await proxy.connect(transport)
const proxy = await initProxy(specPath, baseUrl);
await proxy.connect(transport);
} else {
// Invalid request
res.status(400).json({
jsonrpc: '2.0',
jsonrpc: "2.0",
error: {
code: -32000,
message: 'Bad Request: No valid session ID provided',
message: "Bad Request: No valid session ID provided",
},
id: null,
})
return
});
return;
}

// Handle the request
await transport.handleRequest(req, res, req.body)
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('Error handling MCP request:', error)
console.error("Error handling MCP request:", error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
jsonrpc: "2.0",
error: {
code: -32603,
message: 'Internal server error',
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
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
res.status(400).send("Invalid or missing session ID");
return;
}
const transport = transports[sessionId]
await transport.handleRequest(req, res)
})

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
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
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`)
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`)
console.log(`Using provided auth token`);
}
})
});

// Return a dummy server for compatibility
return { close: () => {} }
return { close: () => { } };
} else {
throw new Error(`Unsupported transport: ${transport}. Use 'stdio' or 'http'.`)
throw new Error(
`Unsupported transport: ${transport}. Use 'stdio' or 'http'.`,
);
}
}

startServer(process.argv).catch(error => {
if (error instanceof ValidationError) {
console.error('Invalid OpenAPI 3.1 specification:')
error.errors.forEach(err => console.error(err))
error.errors.forEach(err => {
console.error(err)
})
} else {
console.error('Error:', error)
}
Expand Down
Loading