@notionhq/notion-mcp-server 1.8.1 → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +1 -1
- package/README.md +168 -2
- package/bin/cli.mjs +462 -46
- package/docs/images/integration-access.png +0 -0
- package/docs/images/page-access-edit.png +0 -0
- package/package.json +4 -2
- package/scripts/start-server.ts +220 -5
- package/src/openapi-mcp-server/mcp/__tests__/proxy.test.ts +64 -0
- package/src/openapi-mcp-server/mcp/proxy.ts +23 -11
- package/src/openapi-mcp-server/openapi/parser.ts +7 -2
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"mcp",
|
|
7
7
|
"server"
|
|
8
8
|
],
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.9.1",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"type": "module",
|
|
12
12
|
"scripts": {
|
|
@@ -17,15 +17,17 @@
|
|
|
17
17
|
"notion-mcp-server": "bin/cli.mjs"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.13.3",
|
|
21
21
|
"axios": "^1.8.4",
|
|
22
22
|
"express": "^4.21.2",
|
|
23
23
|
"form-data": "^4.0.1",
|
|
24
24
|
"mustache": "^4.2.0",
|
|
25
|
+
"node-fetch": "^3.3.2",
|
|
25
26
|
"openapi-client-axios": "^7.5.5",
|
|
26
27
|
"openapi-schema-validator": "^12.1.3",
|
|
27
28
|
"openapi-types": "^12.1.3",
|
|
28
29
|
"which": "^5.0.0",
|
|
30
|
+
"yargs": "^17.7.2",
|
|
29
31
|
"zod": "3.24.1"
|
|
30
32
|
},
|
|
31
33
|
"devDependencies": {
|
package/scripts/start-server.ts
CHANGED
|
@@ -1,23 +1,238 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import { fileURLToPath } from 'url'
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
4
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
5
|
+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'
|
|
6
|
+
import { randomUUID, randomBytes } from 'node:crypto'
|
|
7
|
+
import express from 'express'
|
|
4
8
|
|
|
5
9
|
import { initProxy, ValidationError } from '../src/init-server'
|
|
6
10
|
|
|
7
|
-
export async function startServer(args: string[] = process.argv
|
|
11
|
+
export async function startServer(args: string[] = process.argv) {
|
|
8
12
|
const filename = fileURLToPath(import.meta.url)
|
|
9
13
|
const directory = path.dirname(filename)
|
|
10
14
|
const specPath = path.resolve(directory, '../scripts/notion-openapi.json')
|
|
11
15
|
|
|
12
16
|
const baseUrl = process.env.BASE_URL ?? undefined
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
// Parse command line arguments manually (similar to slack-mcp approach)
|
|
19
|
+
function parseArgs() {
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
let transport = 'stdio'; // default
|
|
22
|
+
let port = 3000;
|
|
23
|
+
let authToken: string | undefined;
|
|
16
24
|
|
|
17
|
-
|
|
25
|
+
for (let i = 0; i < args.length; i++) {
|
|
26
|
+
if (args[i] === '--transport' && i + 1 < args.length) {
|
|
27
|
+
transport = args[i + 1];
|
|
28
|
+
i++; // skip next argument
|
|
29
|
+
} else if (args[i] === '--port' && i + 1 < args.length) {
|
|
30
|
+
port = parseInt(args[i + 1], 10);
|
|
31
|
+
i++; // skip next argument
|
|
32
|
+
} else if (args[i] === '--auth-token' && i + 1 < args.length) {
|
|
33
|
+
authToken = args[i + 1];
|
|
34
|
+
i++; // skip next argument
|
|
35
|
+
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
36
|
+
console.log(`
|
|
37
|
+
Usage: notion-mcp-server [options]
|
|
38
|
+
|
|
39
|
+
Options:
|
|
40
|
+
--transport <type> Transport type: 'stdio' or 'http' (default: stdio)
|
|
41
|
+
--port <number> Port for HTTP server when using Streamable HTTP transport (default: 3000)
|
|
42
|
+
--auth-token <token> Bearer token for HTTP transport authentication (optional)
|
|
43
|
+
--help, -h Show this help message
|
|
44
|
+
|
|
45
|
+
Environment Variables:
|
|
46
|
+
NOTION_TOKEN Notion integration token (recommended)
|
|
47
|
+
OPENAPI_MCP_HEADERS JSON string with Notion API headers (alternative)
|
|
48
|
+
AUTH_TOKEN Bearer token for HTTP transport authentication (alternative to --auth-token)
|
|
49
|
+
|
|
50
|
+
Examples:
|
|
51
|
+
notion-mcp-server # Use stdio transport (default)
|
|
52
|
+
notion-mcp-server --transport stdio # Use stdio transport explicitly
|
|
53
|
+
notion-mcp-server --transport http # Use Streamable HTTP transport on port 3000
|
|
54
|
+
notion-mcp-server --transport http --port 8080 # Use Streamable HTTP transport on port 8080
|
|
55
|
+
notion-mcp-server --transport http --auth-token mytoken # Use Streamable HTTP transport with custom auth token
|
|
56
|
+
AUTH_TOKEN=mytoken notion-mcp-server --transport http # Use Streamable HTTP transport with auth token from env var
|
|
57
|
+
`);
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
// Ignore unrecognized arguments (like command name passed by Docker)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { transport: transport.toLowerCase(), port, authToken };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const options = parseArgs()
|
|
67
|
+
const transport = options.transport
|
|
68
|
+
|
|
69
|
+
if (transport === 'stdio') {
|
|
70
|
+
// Use stdio transport (default)
|
|
71
|
+
const proxy = await initProxy(specPath, baseUrl)
|
|
72
|
+
await proxy.connect(new StdioServerTransport())
|
|
73
|
+
return proxy.getServer()
|
|
74
|
+
} else if (transport === 'http') {
|
|
75
|
+
// Use Streamable HTTP transport
|
|
76
|
+
const app = express()
|
|
77
|
+
app.use(express.json())
|
|
78
|
+
|
|
79
|
+
// Generate or use provided auth token (from CLI arg or env var)
|
|
80
|
+
const authToken = options.authToken || process.env.AUTH_TOKEN || randomBytes(32).toString('hex')
|
|
81
|
+
if (!options.authToken && !process.env.AUTH_TOKEN) {
|
|
82
|
+
console.log(`Generated auth token: ${authToken}`)
|
|
83
|
+
console.log(`Use this token in the Authorization header: Bearer ${authToken}`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Authorization middleware
|
|
87
|
+
const authenticateToken = (req: express.Request, res: express.Response, next: express.NextFunction): void => {
|
|
88
|
+
const authHeader = req.headers['authorization']
|
|
89
|
+
const token = authHeader && authHeader.split(' ')[1] // Bearer TOKEN
|
|
90
|
+
|
|
91
|
+
if (!token) {
|
|
92
|
+
res.status(401).json({
|
|
93
|
+
jsonrpc: '2.0',
|
|
94
|
+
error: {
|
|
95
|
+
code: -32001,
|
|
96
|
+
message: 'Unauthorized: Missing bearer token',
|
|
97
|
+
},
|
|
98
|
+
id: null,
|
|
99
|
+
})
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (token !== authToken) {
|
|
104
|
+
res.status(403).json({
|
|
105
|
+
jsonrpc: '2.0',
|
|
106
|
+
error: {
|
|
107
|
+
code: -32002,
|
|
108
|
+
message: 'Forbidden: Invalid bearer token',
|
|
109
|
+
},
|
|
110
|
+
id: null,
|
|
111
|
+
})
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
next()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Health endpoint (no authentication required)
|
|
119
|
+
app.get('/health', (req, res) => {
|
|
120
|
+
res.status(200).json({
|
|
121
|
+
status: 'healthy',
|
|
122
|
+
timestamp: new Date().toISOString(),
|
|
123
|
+
transport: 'http',
|
|
124
|
+
port: options.port
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// Apply authentication to all /mcp routes
|
|
129
|
+
app.use('/mcp', authenticateToken)
|
|
130
|
+
|
|
131
|
+
// Map to store transports by session ID
|
|
132
|
+
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}
|
|
133
|
+
|
|
134
|
+
// Handle POST requests for client-to-server communication
|
|
135
|
+
app.post('/mcp', async (req, res) => {
|
|
136
|
+
try {
|
|
137
|
+
// Check for existing session ID
|
|
138
|
+
const sessionId = req.headers['mcp-session-id'] as string | undefined
|
|
139
|
+
let transport: StreamableHTTPServerTransport
|
|
140
|
+
|
|
141
|
+
if (sessionId && transports[sessionId]) {
|
|
142
|
+
// Reuse existing transport
|
|
143
|
+
transport = transports[sessionId]
|
|
144
|
+
} else if (!sessionId && isInitializeRequest(req.body)) {
|
|
145
|
+
// New initialization request
|
|
146
|
+
transport = new StreamableHTTPServerTransport({
|
|
147
|
+
sessionIdGenerator: () => randomUUID(),
|
|
148
|
+
onsessioninitialized: (sessionId) => {
|
|
149
|
+
// Store the transport by session ID
|
|
150
|
+
transports[sessionId] = transport
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// Clean up transport when closed
|
|
155
|
+
transport.onclose = () => {
|
|
156
|
+
if (transport.sessionId) {
|
|
157
|
+
delete transports[transport.sessionId]
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const proxy = await initProxy(specPath, baseUrl)
|
|
162
|
+
await proxy.connect(transport)
|
|
163
|
+
} else {
|
|
164
|
+
// Invalid request
|
|
165
|
+
res.status(400).json({
|
|
166
|
+
jsonrpc: '2.0',
|
|
167
|
+
error: {
|
|
168
|
+
code: -32000,
|
|
169
|
+
message: 'Bad Request: No valid session ID provided',
|
|
170
|
+
},
|
|
171
|
+
id: null,
|
|
172
|
+
})
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Handle the request
|
|
177
|
+
await transport.handleRequest(req, res, req.body)
|
|
178
|
+
} catch (error) {
|
|
179
|
+
console.error('Error handling MCP request:', error)
|
|
180
|
+
if (!res.headersSent) {
|
|
181
|
+
res.status(500).json({
|
|
182
|
+
jsonrpc: '2.0',
|
|
183
|
+
error: {
|
|
184
|
+
code: -32603,
|
|
185
|
+
message: 'Internal server error',
|
|
186
|
+
},
|
|
187
|
+
id: null,
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// Handle GET requests for server-to-client notifications via Streamable HTTP
|
|
194
|
+
app.get('/mcp', async (req, res) => {
|
|
195
|
+
const sessionId = req.headers['mcp-session-id'] as string | undefined
|
|
196
|
+
if (!sessionId || !transports[sessionId]) {
|
|
197
|
+
res.status(400).send('Invalid or missing session ID')
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const transport = transports[sessionId]
|
|
202
|
+
await transport.handleRequest(req, res)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
// Handle DELETE requests for session termination
|
|
206
|
+
app.delete('/mcp', async (req, res) => {
|
|
207
|
+
const sessionId = req.headers['mcp-session-id'] as string | undefined
|
|
208
|
+
if (!sessionId || !transports[sessionId]) {
|
|
209
|
+
res.status(400).send('Invalid or missing session ID')
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const transport = transports[sessionId]
|
|
214
|
+
await transport.handleRequest(req, res)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
const port = options.port
|
|
218
|
+
app.listen(port, '0.0.0.0', () => {
|
|
219
|
+
console.log(`MCP Server listening on port ${port}`)
|
|
220
|
+
console.log(`Endpoint: http://0.0.0.0:${port}/mcp`)
|
|
221
|
+
console.log(`Health check: http://0.0.0.0:${port}/health`)
|
|
222
|
+
console.log(`Authentication: Bearer token required`)
|
|
223
|
+
if (options.authToken) {
|
|
224
|
+
console.log(`Using provided auth token`)
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// Return a dummy server for compatibility
|
|
229
|
+
return { close: () => {} }
|
|
230
|
+
} else {
|
|
231
|
+
throw new Error(`Unsupported transport: ${transport}. Use 'stdio' or 'http'.`)
|
|
232
|
+
}
|
|
18
233
|
}
|
|
19
234
|
|
|
20
|
-
startServer().catch(error => {
|
|
235
|
+
startServer(process.argv).catch(error => {
|
|
21
236
|
if (error instanceof ValidationError) {
|
|
22
237
|
console.error('Invalid OpenAPI 3.1 specification:')
|
|
23
238
|
error.errors.forEach(err => console.error(err))
|
|
@@ -257,6 +257,70 @@ describe('MCPProxy', () => {
|
|
|
257
257
|
)
|
|
258
258
|
expect(consoleSpy).toHaveBeenCalledWith('OPENAPI_MCP_HEADERS environment variable must be a JSON object, got:', 'string')
|
|
259
259
|
})
|
|
260
|
+
|
|
261
|
+
it('should use NOTION_TOKEN when OPENAPI_MCP_HEADERS is not set', () => {
|
|
262
|
+
delete process.env.OPENAPI_MCP_HEADERS
|
|
263
|
+
process.env.NOTION_TOKEN = 'ntn_test_token_123'
|
|
264
|
+
|
|
265
|
+
const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
|
|
266
|
+
expect(HttpClient).toHaveBeenCalledWith(
|
|
267
|
+
expect.objectContaining({
|
|
268
|
+
headers: {
|
|
269
|
+
'Authorization': 'Bearer ntn_test_token_123',
|
|
270
|
+
'Notion-Version': '2022-06-28'
|
|
271
|
+
},
|
|
272
|
+
}),
|
|
273
|
+
expect.anything(),
|
|
274
|
+
)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('should prioritize OPENAPI_MCP_HEADERS over NOTION_TOKEN when both are set', () => {
|
|
278
|
+
process.env.OPENAPI_MCP_HEADERS = JSON.stringify({
|
|
279
|
+
Authorization: 'Bearer custom_token',
|
|
280
|
+
'Custom-Header': 'custom_value',
|
|
281
|
+
})
|
|
282
|
+
process.env.NOTION_TOKEN = 'ntn_test_token_123'
|
|
283
|
+
|
|
284
|
+
const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
|
|
285
|
+
expect(HttpClient).toHaveBeenCalledWith(
|
|
286
|
+
expect.objectContaining({
|
|
287
|
+
headers: {
|
|
288
|
+
Authorization: 'Bearer custom_token',
|
|
289
|
+
'Custom-Header': 'custom_value',
|
|
290
|
+
},
|
|
291
|
+
}),
|
|
292
|
+
expect.anything(),
|
|
293
|
+
)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('should return empty object when neither OPENAPI_MCP_HEADERS nor NOTION_TOKEN are set', () => {
|
|
297
|
+
delete process.env.OPENAPI_MCP_HEADERS
|
|
298
|
+
delete process.env.NOTION_TOKEN
|
|
299
|
+
|
|
300
|
+
const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
|
|
301
|
+
expect(HttpClient).toHaveBeenCalledWith(
|
|
302
|
+
expect.objectContaining({
|
|
303
|
+
headers: {},
|
|
304
|
+
}),
|
|
305
|
+
expect.anything(),
|
|
306
|
+
)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('should use NOTION_TOKEN when OPENAPI_MCP_HEADERS is empty object', () => {
|
|
310
|
+
process.env.OPENAPI_MCP_HEADERS = '{}'
|
|
311
|
+
process.env.NOTION_TOKEN = 'ntn_test_token_123'
|
|
312
|
+
|
|
313
|
+
const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
|
|
314
|
+
expect(HttpClient).toHaveBeenCalledWith(
|
|
315
|
+
expect.objectContaining({
|
|
316
|
+
headers: {
|
|
317
|
+
'Authorization': 'Bearer ntn_test_token_123',
|
|
318
|
+
'Notion-Version': '2022-06-28'
|
|
319
|
+
},
|
|
320
|
+
}),
|
|
321
|
+
expect.anything(),
|
|
322
|
+
)
|
|
323
|
+
})
|
|
260
324
|
})
|
|
261
325
|
describe('connect', () => {
|
|
262
326
|
it('should connect to transport', async () => {
|
|
@@ -124,22 +124,34 @@ export class MCPProxy {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
private parseHeadersFromEnv(): Record<string, string> {
|
|
127
|
+
// First try OPENAPI_MCP_HEADERS (existing behavior)
|
|
127
128
|
const headersJson = process.env.OPENAPI_MCP_HEADERS
|
|
128
|
-
if (
|
|
129
|
-
|
|
129
|
+
if (headersJson) {
|
|
130
|
+
try {
|
|
131
|
+
const headers = JSON.parse(headersJson)
|
|
132
|
+
if (typeof headers !== 'object' || headers === null) {
|
|
133
|
+
console.warn('OPENAPI_MCP_HEADERS environment variable must be a JSON object, got:', typeof headers)
|
|
134
|
+
} else if (Object.keys(headers).length > 0) {
|
|
135
|
+
// Only use OPENAPI_MCP_HEADERS if it contains actual headers
|
|
136
|
+
return headers
|
|
137
|
+
}
|
|
138
|
+
// If OPENAPI_MCP_HEADERS is empty object, fall through to try NOTION_TOKEN
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.warn('Failed to parse OPENAPI_MCP_HEADERS environment variable:', error)
|
|
141
|
+
// Fall through to try NOTION_TOKEN
|
|
142
|
+
}
|
|
130
143
|
}
|
|
131
144
|
|
|
132
|
-
try
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
145
|
+
// Alternative: try NOTION_TOKEN
|
|
146
|
+
const notionToken = process.env.NOTION_TOKEN
|
|
147
|
+
if (notionToken) {
|
|
148
|
+
return {
|
|
149
|
+
'Authorization': `Bearer ${notionToken}`,
|
|
150
|
+
'Notion-Version': '2022-06-28'
|
|
137
151
|
}
|
|
138
|
-
return headers
|
|
139
|
-
} catch (error) {
|
|
140
|
-
console.warn('Failed to parse OPENAPI_MCP_HEADERS environment variable:', error)
|
|
141
|
-
return {}
|
|
142
152
|
}
|
|
153
|
+
|
|
154
|
+
return {}
|
|
143
155
|
}
|
|
144
156
|
|
|
145
157
|
private getContentType(headers: Headers): 'text' | 'image' | 'binary' {
|
|
@@ -185,6 +185,7 @@ export class OpenAPIToMCPConverter {
|
|
|
185
185
|
if (mcpMethod) {
|
|
186
186
|
const uniqueName = this.ensureUniqueName(mcpMethod.name)
|
|
187
187
|
mcpMethod.name = uniqueName
|
|
188
|
+
mcpMethod.description = this.getDescription(operation.summary || operation.description || '')
|
|
188
189
|
tools[apiName]!.methods.push(mcpMethod)
|
|
189
190
|
openApiLookup[apiName + '-' + uniqueName] = { ...operation, method, path }
|
|
190
191
|
zip[apiName + '-' + uniqueName] = { openApi: { ...operation, method, path }, mcp: mcpMethod }
|
|
@@ -212,7 +213,7 @@ export class OpenAPIToMCPConverter {
|
|
|
212
213
|
type: 'function',
|
|
213
214
|
function: {
|
|
214
215
|
name: operation.operationId!,
|
|
215
|
-
description: operation.summary || operation.description || '',
|
|
216
|
+
description: this.getDescription(operation.summary || operation.description || ''),
|
|
216
217
|
parameters: parameters as FunctionParameters,
|
|
217
218
|
},
|
|
218
219
|
}
|
|
@@ -238,7 +239,7 @@ export class OpenAPIToMCPConverter {
|
|
|
238
239
|
const parameters = this.convertOperationToJsonSchema(operation, method, path)
|
|
239
240
|
const tool: Tool = {
|
|
240
241
|
name: operation.operationId!,
|
|
241
|
-
description: operation.summary || operation.description || '',
|
|
242
|
+
description: this.getDescription(operation.summary || operation.description || ''),
|
|
242
243
|
input_schema: parameters as Tool['input_schema'],
|
|
243
244
|
}
|
|
244
245
|
tools.push(tool)
|
|
@@ -516,4 +517,8 @@ export class OpenAPIToMCPConverter {
|
|
|
516
517
|
this.nameCounter += 1
|
|
517
518
|
return this.nameCounter.toString().padStart(4, '0')
|
|
518
519
|
}
|
|
520
|
+
|
|
521
|
+
private getDescription(description: string): string {
|
|
522
|
+
return "Notion | " + description
|
|
523
|
+
}
|
|
519
524
|
}
|