@notionhq/notion-mcp-server 1.8.1 → 1.9.0

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.
Binary file
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "mcp",
7
7
  "server"
8
8
  ],
9
- "version": "1.8.1",
9
+ "version": "1.9.0",
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.8.0",
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": {
@@ -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.slice(2)) {
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
- const proxy = await initProxy(specPath, baseUrl)
15
- await proxy.connect(new StdioServerTransport())
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
- return proxy.getServer()
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))
package/smithery.yaml ADDED
@@ -0,0 +1,38 @@
1
+ # Smithery configuration file: https://smithery.ai/docs/build/project-config
2
+
3
+ startCommand:
4
+ type: stdio
5
+ commandFunction:
6
+ # A JS function that produces the CLI command based on the given config to start the MCP on stdio.
7
+ |-
8
+ (config) => {
9
+ const env = {};
10
+ if (config.notionToken) {
11
+ env.NOTION_TOKEN = config.notionToken;
12
+ } else if (config.openapiMcpHeaders) {
13
+ env.OPENAPI_MCP_HEADERS = config.openapiMcpHeaders;
14
+ }
15
+ if (config.baseUrl) env.BASE_URL = config.baseUrl;
16
+ return { command: 'notion-mcp-server', args: [], env };
17
+ }
18
+ configSchema:
19
+ # JSON Schema defining the configuration options for the MCP.
20
+ type: object
21
+ anyOf:
22
+ - required: [notionToken]
23
+ - required: [openapiMcpHeaders]
24
+ properties:
25
+ notionToken:
26
+ type: string
27
+ description: Notion integration token (recommended)
28
+ openapiMcpHeaders:
29
+ type: string
30
+ default: "{}"
31
+ description: JSON string for HTTP headers, must include Authorization and
32
+ Notion-Version (alternative to notionToken)
33
+ baseUrl:
34
+ type: string
35
+ description: Optional override for Notion API base URL
36
+ exampleConfig:
37
+ notionToken: 'ntn_abcdef'
38
+ baseUrl: https://api.notion.com
@@ -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 (!headersJson) {
129
- return {}
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
- const headers = JSON.parse(headersJson)
134
- if (typeof headers !== 'object' || headers === null) {
135
- console.warn('OPENAPI_MCP_HEADERS environment variable must be a JSON object, got:', typeof headers)
136
- return {}
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
  }