@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,243 @@
1
+ import path from 'node:path'
2
+ import { fileURLToPath } from 'url'
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'
8
+
9
+ import { initProxy, ValidationError } from '../src/init-server'
10
+
11
+ export async function startServer(args: string[] = process.argv) {
12
+ const filename = fileURLToPath(import.meta.url)
13
+ const directory = path.dirname(filename)
14
+ const specPath = path.resolve(directory, '../scripts/notion-openapi.json')
15
+
16
+ const baseUrl = process.env.BASE_URL ?? undefined
17
+
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;
24
+
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
+ }
233
+ }
234
+
235
+ startServer(process.argv).catch(error => {
236
+ if (error instanceof ValidationError) {
237
+ console.error('Invalid OpenAPI 3.1 specification:')
238
+ error.errors.forEach(err => console.error(err))
239
+ } else {
240
+ console.error('Error:', error)
241
+ }
242
+ process.exit(1)
243
+ })
@@ -0,0 +1,50 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import { OpenAPIV3 } from 'openapi-types'
5
+ import OpenAPISchemaValidator from 'openapi-schema-validator'
6
+
7
+ import { MCPProxy } from './openapi-mcp-server/mcp/proxy'
8
+
9
+ export class ValidationError extends Error {
10
+ constructor(public errors: any[]) {
11
+ super('OpenAPI validation failed')
12
+ this.name = 'ValidationError'
13
+ }
14
+ }
15
+
16
+ async function loadOpenApiSpec(specPath: string, baseUrl: string | undefined): Promise<OpenAPIV3.Document> {
17
+ let rawSpec: string
18
+
19
+ try {
20
+ rawSpec = fs.readFileSync(path.resolve(process.cwd(), specPath), 'utf-8')
21
+ } catch (error) {
22
+ console.error('Failed to read OpenAPI specification file:', (error as Error).message)
23
+ process.exit(1)
24
+ }
25
+
26
+ // Parse and validate the OpenApi Spec
27
+ try {
28
+ const parsed = JSON.parse(rawSpec)
29
+
30
+ // Override baseUrl if specified.
31
+ if (baseUrl) {
32
+ parsed.servers[0].url = baseUrl
33
+ }
34
+
35
+ return parsed as OpenAPIV3.Document
36
+ } catch (error) {
37
+ if (error instanceof ValidationError) {
38
+ throw error
39
+ }
40
+ console.error('Failed to parse OpenAPI spec:', (error as Error).message)
41
+ process.exit(1)
42
+ }
43
+ }
44
+
45
+ export async function initProxy(specPath: string, baseUrl: string |undefined) {
46
+ const openApiSpec = await loadOpenApiSpec(specPath, baseUrl)
47
+ const proxy = new MCPProxy('Notion API', openApiSpec)
48
+
49
+ return proxy
50
+ }
@@ -0,0 +1,3 @@
1
+ Note: This is a fork from v1 of https://github.com/snaggle-ai/openapi-mcp-server. The library took a different direction with v2 which is not compatible with our development approach.
2
+
3
+ Forked to upgrade vulnerable dependencies and easier setup.
@@ -0,0 +1,2 @@
1
+ export * from './types'
2
+ export * from './template'
@@ -0,0 +1,24 @@
1
+ import Mustache from 'mustache'
2
+ import { AuthTemplate, TemplateContext } from './types'
3
+
4
+ export function renderAuthTemplate(template: AuthTemplate, context: TemplateContext): AuthTemplate {
5
+ // Disable HTML escaping for URLs
6
+ Mustache.escape = (text) => text
7
+
8
+ // Render URL with template variables
9
+ const renderedUrl = Mustache.render(template.url, context)
10
+
11
+ // Create a new template object with rendered values
12
+ const renderedTemplate: AuthTemplate = {
13
+ ...template,
14
+ url: renderedUrl,
15
+ headers: { ...template.headers }, // Create a new headers object to avoid modifying the original
16
+ }
17
+
18
+ // Render body if it exists
19
+ if (template.body) {
20
+ renderedTemplate.body = Mustache.render(template.body, context)
21
+ }
22
+
23
+ return renderedTemplate
24
+ }
@@ -0,0 +1,26 @@
1
+ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
2
+
3
+ export interface AuthTemplate {
4
+ url: string
5
+ method: HttpMethod
6
+ headers: Record<string, string>
7
+ body?: string
8
+ }
9
+
10
+ export interface SecurityScheme {
11
+ [key: string]: {
12
+ tokenUrl?: string
13
+ [key: string]: any
14
+ }
15
+ }
16
+
17
+ export interface Server {
18
+ url: string
19
+ description?: string
20
+ }
21
+
22
+ export interface TemplateContext {
23
+ securityScheme?: SecurityScheme
24
+ servers?: Server[]
25
+ args: Record<string, string>
26
+ }
@@ -0,0 +1,205 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { HttpClient } from '../http-client'
3
+ import { OpenAPIV3 } from 'openapi-types'
4
+ import fs from 'fs'
5
+ import FormData from 'form-data'
6
+
7
+ vi.mock('fs')
8
+ vi.mock('form-data')
9
+
10
+ describe('HttpClient File Upload', () => {
11
+ let client: HttpClient
12
+ const mockApiInstance = {
13
+ uploadFile: vi.fn(),
14
+ }
15
+
16
+ const baseConfig = {
17
+ baseUrl: 'http://test.com',
18
+ headers: {},
19
+ }
20
+
21
+ const mockOpenApiSpec: OpenAPIV3.Document = {
22
+ openapi: '3.0.0',
23
+ info: {
24
+ title: 'Test API',
25
+ version: '1.0.0',
26
+ },
27
+ paths: {
28
+ '/upload': {
29
+ post: {
30
+ operationId: 'uploadFile',
31
+ responses: {
32
+ '200': {
33
+ description: 'File uploaded successfully',
34
+ content: {
35
+ 'application/json': {
36
+ schema: {
37
+ type: 'object',
38
+ properties: {
39
+ success: {
40
+ type: 'boolean',
41
+ },
42
+ },
43
+ },
44
+ },
45
+ },
46
+ },
47
+ },
48
+ requestBody: {
49
+ content: {
50
+ 'multipart/form-data': {
51
+ schema: {
52
+ type: 'object',
53
+ properties: {
54
+ file: {
55
+ type: 'string',
56
+ format: 'binary',
57
+ },
58
+ description: {
59
+ type: 'string',
60
+ },
61
+ },
62
+ },
63
+ },
64
+ },
65
+ },
66
+ },
67
+ },
68
+ },
69
+ }
70
+
71
+ beforeEach(() => {
72
+ vi.clearAllMocks()
73
+ client = new HttpClient(baseConfig, mockOpenApiSpec)
74
+ // @ts-expect-error - Mock the private api property
75
+ client['api'] = Promise.resolve(mockApiInstance)
76
+ })
77
+
78
+ it('should handle file uploads with FormData', async () => {
79
+ const mockFormData = new FormData()
80
+ const mockFileStream = { pipe: vi.fn() }
81
+ const mockFormDataHeaders = { 'content-type': 'multipart/form-data; boundary=---123' }
82
+
83
+ vi.mocked(fs.createReadStream).mockReturnValue(mockFileStream as any)
84
+ vi.spyOn(FormData.prototype, 'append').mockImplementation(() => {})
85
+ vi.spyOn(FormData.prototype, 'getHeaders').mockReturnValue(mockFormDataHeaders)
86
+
87
+ const uploadPath = mockOpenApiSpec.paths['/upload']
88
+ if (!uploadPath?.post) {
89
+ throw new Error('Upload path not found in spec')
90
+ }
91
+ const operation = uploadPath.post as OpenAPIV3.OperationObject & { method: string; path: string }
92
+ const params = {
93
+ file: '/path/to/test.txt',
94
+ description: 'Test file',
95
+ }
96
+
97
+ mockApiInstance.uploadFile.mockResolvedValue({
98
+ data: { success: true },
99
+ status: 200,
100
+ headers: {},
101
+ })
102
+
103
+ await client.executeOperation(operation, params)
104
+
105
+ expect(fs.createReadStream).toHaveBeenCalledWith('/path/to/test.txt')
106
+ expect(FormData.prototype.append).toHaveBeenCalledWith('file', mockFileStream)
107
+ expect(FormData.prototype.append).toHaveBeenCalledWith('description', 'Test file')
108
+ expect(mockApiInstance.uploadFile).toHaveBeenCalledWith({}, expect.any(FormData), { headers: mockFormDataHeaders })
109
+ })
110
+
111
+ it('should throw error for invalid file path', async () => {
112
+ vi.mocked(fs.createReadStream).mockImplementation(() => {
113
+ throw new Error('File not found')
114
+ })
115
+
116
+ const uploadPath = mockOpenApiSpec.paths['/upload']
117
+ if (!uploadPath?.post) {
118
+ throw new Error('Upload path not found in spec')
119
+ }
120
+ const operation = uploadPath.post as OpenAPIV3.OperationObject & { method: string; path: string }
121
+ const params = {
122
+ file: '/nonexistent/file.txt',
123
+ description: 'Test file',
124
+ }
125
+
126
+ await expect(client.executeOperation(operation, params)).rejects.toThrow('Failed to read file at /nonexistent/file.txt')
127
+ })
128
+
129
+ it('should handle multiple file uploads', async () => {
130
+ const mockFormData = new FormData()
131
+ const mockFileStream1 = { pipe: vi.fn() }
132
+ const mockFileStream2 = { pipe: vi.fn() }
133
+ const mockFormDataHeaders = { 'content-type': 'multipart/form-data; boundary=---123' }
134
+
135
+ vi.mocked(fs.createReadStream)
136
+ .mockReturnValueOnce(mockFileStream1 as any)
137
+ .mockReturnValueOnce(mockFileStream2 as any)
138
+ vi.spyOn(FormData.prototype, 'append').mockImplementation(() => {})
139
+ vi.spyOn(FormData.prototype, 'getHeaders').mockReturnValue(mockFormDataHeaders)
140
+
141
+ const operation: OpenAPIV3.OperationObject = {
142
+ operationId: 'uploadFile',
143
+ responses: {
144
+ '200': {
145
+ description: 'Files uploaded successfully',
146
+ content: {
147
+ 'application/json': {
148
+ schema: {
149
+ type: 'object',
150
+ properties: {
151
+ success: {
152
+ type: 'boolean',
153
+ },
154
+ },
155
+ },
156
+ },
157
+ },
158
+ },
159
+ },
160
+ requestBody: {
161
+ content: {
162
+ 'multipart/form-data': {
163
+ schema: {
164
+ type: 'object',
165
+ properties: {
166
+ file1: {
167
+ type: 'string',
168
+ format: 'binary',
169
+ },
170
+ file2: {
171
+ type: 'string',
172
+ format: 'binary',
173
+ },
174
+ description: {
175
+ type: 'string',
176
+ },
177
+ },
178
+ },
179
+ },
180
+ },
181
+ },
182
+ }
183
+
184
+ const params = {
185
+ file1: '/path/to/test1.txt',
186
+ file2: '/path/to/test2.txt',
187
+ description: 'Test files',
188
+ }
189
+
190
+ mockApiInstance.uploadFile.mockResolvedValue({
191
+ data: { success: true },
192
+ status: 200,
193
+ headers: {},
194
+ })
195
+
196
+ await client.executeOperation(operation as OpenAPIV3.OperationObject & { method: string; path: string }, params)
197
+
198
+ expect(fs.createReadStream).toHaveBeenCalledWith('/path/to/test1.txt')
199
+ expect(fs.createReadStream).toHaveBeenCalledWith('/path/to/test2.txt')
200
+ expect(FormData.prototype.append).toHaveBeenCalledWith('file1', mockFileStream1)
201
+ expect(FormData.prototype.append).toHaveBeenCalledWith('file2', mockFileStream2)
202
+ expect(FormData.prototype.append).toHaveBeenCalledWith('description', 'Test files')
203
+ expect(mockApiInstance.uploadFile).toHaveBeenCalledWith({}, expect.any(FormData), { headers: mockFormDataHeaders })
204
+ })
205
+ })