@mieubrisse/notion-mcp-server 2.0.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.
Files changed (36) hide show
  1. package/.devcontainer/devcontainer.json +4 -0
  2. package/.dockerignore +3 -0
  3. package/.github/pull_request_template.md +8 -0
  4. package/.github/workflows/ci.yml +42 -0
  5. package/Dockerfile +36 -0
  6. package/LICENSE +7 -0
  7. package/README.md +412 -0
  8. package/docker-compose.yml +6 -0
  9. package/docs/images/connections.png +0 -0
  10. package/docs/images/integration-access.png +0 -0
  11. package/docs/images/integrations-capabilities.png +0 -0
  12. package/docs/images/integrations-creation.png +0 -0
  13. package/docs/images/page-access-edit.png +0 -0
  14. package/package.json +63 -0
  15. package/scripts/build-cli.js +30 -0
  16. package/scripts/notion-openapi.json +2238 -0
  17. package/scripts/start-server.ts +243 -0
  18. package/src/init-server.ts +50 -0
  19. package/src/openapi-mcp-server/README.md +3 -0
  20. package/src/openapi-mcp-server/auth/index.ts +2 -0
  21. package/src/openapi-mcp-server/auth/template.ts +24 -0
  22. package/src/openapi-mcp-server/auth/types.ts +26 -0
  23. package/src/openapi-mcp-server/client/__tests__/http-client-upload.test.ts +205 -0
  24. package/src/openapi-mcp-server/client/__tests__/http-client.integration.test.ts +282 -0
  25. package/src/openapi-mcp-server/client/__tests__/http-client.test.ts +537 -0
  26. package/src/openapi-mcp-server/client/http-client.ts +198 -0
  27. package/src/openapi-mcp-server/client/polyfill-headers.ts +42 -0
  28. package/src/openapi-mcp-server/index.ts +3 -0
  29. package/src/openapi-mcp-server/mcp/__tests__/proxy.test.ts +479 -0
  30. package/src/openapi-mcp-server/mcp/proxy.ts +250 -0
  31. package/src/openapi-mcp-server/openapi/__tests__/file-upload.test.ts +100 -0
  32. package/src/openapi-mcp-server/openapi/__tests__/parser-multipart.test.ts +602 -0
  33. package/src/openapi-mcp-server/openapi/__tests__/parser.test.ts +1448 -0
  34. package/src/openapi-mcp-server/openapi/file-upload.ts +40 -0
  35. package/src/openapi-mcp-server/openapi/parser.ts +529 -0
  36. package/tsconfig.json +26 -0
@@ -0,0 +1,479 @@
1
+ import { MCPProxy } from '../proxy'
2
+ import { OpenAPIV3 } from 'openapi-types'
3
+ import { HttpClient } from '../../client/http-client'
4
+ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
5
+ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
6
+
7
+ // Mock the dependencies
8
+ vi.mock('../../client/http-client')
9
+ vi.mock('@modelcontextprotocol/sdk/server/index.js')
10
+
11
+ describe('MCPProxy', () => {
12
+ let proxy: MCPProxy
13
+ let mockOpenApiSpec: OpenAPIV3.Document
14
+
15
+ beforeEach(() => {
16
+ // Reset all mocks
17
+ vi.clearAllMocks()
18
+
19
+ // Setup minimal OpenAPI spec for testing
20
+ mockOpenApiSpec = {
21
+ openapi: '3.0.0',
22
+ servers: [{ url: 'http://localhost:3000' }],
23
+ info: {
24
+ title: 'Test API',
25
+ version: '1.0.0',
26
+ },
27
+ paths: {
28
+ '/test': {
29
+ get: {
30
+ operationId: 'getTest',
31
+ responses: {
32
+ '200': {
33
+ description: 'Success',
34
+ },
35
+ },
36
+ },
37
+ },
38
+ },
39
+ }
40
+
41
+ proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
42
+ })
43
+
44
+ describe('listTools handler', () => {
45
+ it('should return converted tools from OpenAPI spec', async () => {
46
+ const server = (proxy as any).server
47
+ const listToolsHandler = server.setRequestHandler.mock.calls[0].filter((x: unknown) => typeof x === 'function')[0]
48
+ const result = await listToolsHandler()
49
+
50
+ expect(result).toHaveProperty('tools')
51
+ expect(Array.isArray(result.tools)).toBe(true)
52
+ })
53
+
54
+ it('should truncate tool names exceeding 64 characters', async () => {
55
+ // Setup OpenAPI spec with long tool names
56
+ mockOpenApiSpec.paths = {
57
+ '/test': {
58
+ get: {
59
+ operationId: 'a'.repeat(65),
60
+ responses: {
61
+ '200': {
62
+ description: 'Success'
63
+ }
64
+ }
65
+ }
66
+ }
67
+ }
68
+ proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
69
+ const server = (proxy as any).server
70
+ const listToolsHandler = server.setRequestHandler.mock.calls[0].filter((x: unknown) => typeof x === 'function')[0];
71
+ const result = await listToolsHandler()
72
+
73
+ expect(result.tools[0].name.length).toBeLessThanOrEqual(64)
74
+ })
75
+ })
76
+
77
+ describe('callTool handler', () => {
78
+ it('should execute operation and return formatted response', async () => {
79
+ // Mock HttpClient response
80
+ const mockResponse = {
81
+ data: { message: 'success' },
82
+ status: 200,
83
+ headers: new Headers({
84
+ 'content-type': 'application/json',
85
+ }),
86
+ }
87
+ ;(HttpClient.prototype.executeOperation as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse)
88
+
89
+ // Set up the openApiLookup with our test operation
90
+ ;(proxy as any).openApiLookup = {
91
+ 'API-getTest': {
92
+ operationId: 'getTest',
93
+ responses: { '200': { description: 'Success' } },
94
+ method: 'get',
95
+ path: '/test',
96
+ },
97
+ }
98
+
99
+ const server = (proxy as any).server
100
+ const handlers = server.setRequestHandler.mock.calls.flatMap((x: unknown[]) => x).filter((x: unknown) => typeof x === 'function')
101
+ const callToolHandler = handlers[1]
102
+
103
+ const result = await callToolHandler({
104
+ params: {
105
+ name: 'API-getTest',
106
+ arguments: {},
107
+ },
108
+ })
109
+
110
+ expect(result).toEqual({
111
+ content: [
112
+ {
113
+ type: 'text',
114
+ text: JSON.stringify({ message: 'success' }),
115
+ },
116
+ ],
117
+ })
118
+ })
119
+
120
+ it('should throw error for non-existent operation', async () => {
121
+ const server = (proxy as any).server
122
+ const handlers = server.setRequestHandler.mock.calls.flatMap((x: unknown[]) => x).filter((x: unknown) => typeof x === 'function')
123
+ const callToolHandler = handlers[1]
124
+
125
+ await expect(
126
+ callToolHandler({
127
+ params: {
128
+ name: 'nonExistentMethod',
129
+ arguments: {},
130
+ },
131
+ }),
132
+ ).rejects.toThrow('Method nonExistentMethod not found')
133
+ })
134
+
135
+ it('should handle tool names exceeding 64 characters', async () => {
136
+ // Mock HttpClient response
137
+ const mockResponse = {
138
+ data: { message: 'success' },
139
+ status: 200,
140
+ headers: new Headers({
141
+ 'content-type': 'application/json'
142
+ })
143
+ };
144
+ (HttpClient.prototype.executeOperation as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse);
145
+
146
+ // Set up the openApiLookup with a long tool name
147
+ const longToolName = 'a'.repeat(65)
148
+ const truncatedToolName = longToolName.slice(0, 64)
149
+ ;(proxy as any).openApiLookup = {
150
+ [truncatedToolName]: {
151
+ operationId: longToolName,
152
+ responses: { '200': { description: 'Success' } },
153
+ method: 'get',
154
+ path: '/test'
155
+ }
156
+ };
157
+
158
+ const server = (proxy as any).server;
159
+ const handlers = server.setRequestHandler.mock.calls.flatMap((x: unknown[]) => x).filter((x: unknown) => typeof x === 'function');
160
+ const callToolHandler = handlers[1];
161
+
162
+ const result = await callToolHandler({
163
+ params: {
164
+ name: truncatedToolName,
165
+ arguments: {}
166
+ }
167
+ })
168
+
169
+ expect(result).toEqual({
170
+ content: [
171
+ {
172
+ type: 'text',
173
+ text: JSON.stringify({ message: 'success' })
174
+ }
175
+ ]
176
+ })
177
+ })
178
+ })
179
+
180
+ describe('getContentType', () => {
181
+ it('should return correct content type for different headers', () => {
182
+ const getContentType = (proxy as any).getContentType.bind(proxy)
183
+
184
+ expect(getContentType(new Headers({ 'content-type': 'text/plain' }))).toBe('text')
185
+ expect(getContentType(new Headers({ 'content-type': 'application/json' }))).toBe('text')
186
+ expect(getContentType(new Headers({ 'content-type': 'image/jpeg' }))).toBe('image')
187
+ expect(getContentType(new Headers({ 'content-type': 'application/octet-stream' }))).toBe('binary')
188
+ expect(getContentType(new Headers())).toBe('binary')
189
+ })
190
+ })
191
+
192
+ describe('parseHeadersFromEnv', () => {
193
+ const originalEnv = process.env
194
+
195
+ beforeEach(() => {
196
+ process.env = { ...originalEnv }
197
+ })
198
+
199
+ afterEach(() => {
200
+ process.env = originalEnv
201
+ })
202
+
203
+ it('should parse valid JSON headers from env', () => {
204
+ process.env.OPENAPI_MCP_HEADERS = JSON.stringify({
205
+ Authorization: 'Bearer token123',
206
+ 'X-Custom-Header': 'test',
207
+ })
208
+
209
+ const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
210
+ expect(HttpClient).toHaveBeenCalledWith(
211
+ expect.objectContaining({
212
+ headers: {
213
+ Authorization: 'Bearer token123',
214
+ 'X-Custom-Header': 'test',
215
+ },
216
+ }),
217
+ expect.anything(),
218
+ )
219
+ })
220
+
221
+ it('should return empty object when env var is not set', () => {
222
+ delete process.env.OPENAPI_MCP_HEADERS
223
+
224
+ const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
225
+ expect(HttpClient).toHaveBeenCalledWith(
226
+ expect.objectContaining({
227
+ headers: {},
228
+ }),
229
+ expect.anything(),
230
+ )
231
+ })
232
+
233
+ it('should return empty object and warn on invalid JSON', () => {
234
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
235
+ process.env.OPENAPI_MCP_HEADERS = 'invalid json'
236
+
237
+ const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
238
+ expect(HttpClient).toHaveBeenCalledWith(
239
+ expect.objectContaining({
240
+ headers: {},
241
+ }),
242
+ expect.anything(),
243
+ )
244
+ expect(consoleSpy).toHaveBeenCalledWith('Failed to parse OPENAPI_MCP_HEADERS environment variable:', expect.any(Error))
245
+ consoleSpy.mockRestore()
246
+ })
247
+
248
+ it('should return empty object and warn on non-object JSON', () => {
249
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
250
+ process.env.OPENAPI_MCP_HEADERS = '"string"'
251
+
252
+ const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
253
+ expect(HttpClient).toHaveBeenCalledWith(
254
+ expect.objectContaining({
255
+ headers: {},
256
+ }),
257
+ expect.anything(),
258
+ )
259
+ expect(consoleSpy).toHaveBeenCalledWith('OPENAPI_MCP_HEADERS environment variable must be a JSON object, got:', 'string')
260
+ consoleSpy.mockRestore()
261
+ })
262
+
263
+ it('should use NOTION_TOKEN when OPENAPI_MCP_HEADERS is not set', () => {
264
+ delete process.env.OPENAPI_MCP_HEADERS
265
+ process.env.NOTION_TOKEN = 'ntn_test_token_123'
266
+
267
+ const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
268
+ expect(HttpClient).toHaveBeenCalledWith(
269
+ expect.objectContaining({
270
+ headers: {
271
+ 'Authorization': 'Bearer ntn_test_token_123',
272
+ 'Notion-Version': '2025-09-03'
273
+ },
274
+ }),
275
+ expect.anything(),
276
+ )
277
+ })
278
+
279
+ it('should prioritize OPENAPI_MCP_HEADERS over NOTION_TOKEN when both are set', () => {
280
+ process.env.OPENAPI_MCP_HEADERS = JSON.stringify({
281
+ Authorization: 'Bearer custom_token',
282
+ 'Custom-Header': 'custom_value',
283
+ })
284
+ process.env.NOTION_TOKEN = 'ntn_test_token_123'
285
+
286
+ const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
287
+ expect(HttpClient).toHaveBeenCalledWith(
288
+ expect.objectContaining({
289
+ headers: {
290
+ Authorization: 'Bearer custom_token',
291
+ 'Custom-Header': 'custom_value',
292
+ },
293
+ }),
294
+ expect.anything(),
295
+ )
296
+ })
297
+
298
+ it('should return empty object when neither OPENAPI_MCP_HEADERS nor NOTION_TOKEN are set', () => {
299
+ delete process.env.OPENAPI_MCP_HEADERS
300
+ delete process.env.NOTION_TOKEN
301
+
302
+ const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
303
+ expect(HttpClient).toHaveBeenCalledWith(
304
+ expect.objectContaining({
305
+ headers: {},
306
+ }),
307
+ expect.anything(),
308
+ )
309
+ })
310
+
311
+ it('should use NOTION_TOKEN when OPENAPI_MCP_HEADERS is empty object', () => {
312
+ process.env.OPENAPI_MCP_HEADERS = '{}'
313
+ process.env.NOTION_TOKEN = 'ntn_test_token_123'
314
+
315
+ const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
316
+ expect(HttpClient).toHaveBeenCalledWith(
317
+ expect.objectContaining({
318
+ headers: {
319
+ 'Authorization': 'Bearer ntn_test_token_123',
320
+ 'Notion-Version': '2025-09-03'
321
+ },
322
+ }),
323
+ expect.anything(),
324
+ )
325
+ })
326
+ })
327
+ describe('connect', () => {
328
+ it('should connect to transport', async () => {
329
+ const mockTransport = {} as Transport
330
+ await proxy.connect(mockTransport)
331
+
332
+ const server = (proxy as any).server
333
+ expect(server.connect).toHaveBeenCalledWith(mockTransport)
334
+ })
335
+ })
336
+
337
+ describe('double-serialization fix (issue #176)', () => {
338
+ it('should deserialize stringified JSON object parameters', async () => {
339
+ // Mock HttpClient response
340
+ const mockResponse = {
341
+ data: { message: 'success' },
342
+ status: 200,
343
+ headers: new Headers({
344
+ 'content-type': 'application/json',
345
+ }),
346
+ }
347
+ ;(HttpClient.prototype.executeOperation as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse)
348
+
349
+ // Set up the openApiLookup with our test operation
350
+ ;(proxy as any).openApiLookup = {
351
+ 'API-updatePage': {
352
+ operationId: 'updatePage',
353
+ responses: { '200': { description: 'Success' } },
354
+ method: 'patch',
355
+ path: '/pages/{page_id}',
356
+ },
357
+ }
358
+
359
+ const server = (proxy as any).server
360
+ const handlers = server.setRequestHandler.mock.calls.flatMap((x: unknown[]) => x).filter((x: unknown) => typeof x === 'function')
361
+ const callToolHandler = handlers[1]
362
+
363
+ // Simulate double-serialized parameters (the bug from issue #176)
364
+ const stringifiedData = JSON.stringify({
365
+ page_id: 'test-page-id',
366
+ command: 'update_properties',
367
+ properties: { Status: 'Done' },
368
+ })
369
+
370
+ await callToolHandler({
371
+ params: {
372
+ name: 'API-updatePage',
373
+ arguments: {
374
+ data: stringifiedData, // This would normally fail with "Expected object, received string"
375
+ },
376
+ },
377
+ })
378
+
379
+ // Verify that the parameters were deserialized before being passed to executeOperation
380
+ expect(HttpClient.prototype.executeOperation).toHaveBeenCalledWith(
381
+ expect.anything(),
382
+ {
383
+ data: {
384
+ page_id: 'test-page-id',
385
+ command: 'update_properties',
386
+ properties: { Status: 'Done' },
387
+ },
388
+ },
389
+ )
390
+ })
391
+
392
+ it('should handle nested stringified JSON parameters', async () => {
393
+ const mockResponse = {
394
+ data: { success: true },
395
+ status: 200,
396
+ headers: new Headers({ 'content-type': 'application/json' }),
397
+ }
398
+ ;(HttpClient.prototype.executeOperation as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse)
399
+
400
+ ;(proxy as any).openApiLookup = {
401
+ 'API-createPage': {
402
+ operationId: 'createPage',
403
+ responses: { '200': { description: 'Success' } },
404
+ method: 'post',
405
+ path: '/pages',
406
+ },
407
+ }
408
+
409
+ const server = (proxy as any).server
410
+ const handlers = server.setRequestHandler.mock.calls.flatMap((x: unknown[]) => x).filter((x: unknown) => typeof x === 'function')
411
+ const callToolHandler = handlers[1]
412
+
413
+ // Nested stringified object
414
+ const nestedData = JSON.stringify({
415
+ parent: JSON.stringify({ page_id: 'parent-page-id' }),
416
+ })
417
+
418
+ await callToolHandler({
419
+ params: {
420
+ name: 'API-createPage',
421
+ arguments: {
422
+ data: nestedData,
423
+ },
424
+ },
425
+ })
426
+
427
+ // Verify nested objects were also deserialized
428
+ expect(HttpClient.prototype.executeOperation).toHaveBeenCalledWith(
429
+ expect.anything(),
430
+ {
431
+ data: {
432
+ parent: { page_id: 'parent-page-id' },
433
+ },
434
+ },
435
+ )
436
+ })
437
+
438
+ it('should preserve non-JSON string parameters', async () => {
439
+ const mockResponse = {
440
+ data: { success: true },
441
+ status: 200,
442
+ headers: new Headers({ 'content-type': 'application/json' }),
443
+ }
444
+ ;(HttpClient.prototype.executeOperation as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse)
445
+
446
+ ;(proxy as any).openApiLookup = {
447
+ 'API-search': {
448
+ operationId: 'search',
449
+ responses: { '200': { description: 'Success' } },
450
+ method: 'post',
451
+ path: '/search',
452
+ },
453
+ }
454
+
455
+ const server = (proxy as any).server
456
+ const handlers = server.setRequestHandler.mock.calls.flatMap((x: unknown[]) => x).filter((x: unknown) => typeof x === 'function')
457
+ const callToolHandler = handlers[1]
458
+
459
+ await callToolHandler({
460
+ params: {
461
+ name: 'API-search',
462
+ arguments: {
463
+ query: 'hello world', // Regular string, should NOT be parsed
464
+ filter: '{ not valid json }', // Looks like JSON but isn't valid
465
+ },
466
+ },
467
+ })
468
+
469
+ // Verify that non-JSON strings are preserved as-is
470
+ expect(HttpClient.prototype.executeOperation).toHaveBeenCalledWith(
471
+ expect.anything(),
472
+ {
473
+ query: 'hello world',
474
+ filter: '{ not valid json }',
475
+ },
476
+ )
477
+ })
478
+ })
479
+ })