@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,250 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
2
+ import { CallToolRequestSchema, JSONRPCResponse, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js'
3
+ import { JSONSchema7 as IJsonSchema } from 'json-schema'
4
+ import { OpenAPIToMCPConverter } from '../openapi/parser'
5
+ import { HttpClient, HttpClientError } from '../client/http-client'
6
+ import { OpenAPIV3 } from 'openapi-types'
7
+ import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
8
+
9
+ type PathItemObject = OpenAPIV3.PathItemObject & {
10
+ get?: OpenAPIV3.OperationObject
11
+ put?: OpenAPIV3.OperationObject
12
+ post?: OpenAPIV3.OperationObject
13
+ delete?: OpenAPIV3.OperationObject
14
+ patch?: OpenAPIV3.OperationObject
15
+ }
16
+
17
+ type NewToolDefinition = {
18
+ methods: Array<{
19
+ name: string
20
+ description: string
21
+ inputSchema: IJsonSchema & { type: 'object' }
22
+ returnSchema?: IJsonSchema
23
+ }>
24
+ }
25
+
26
+ /**
27
+ * Recursively deserialize stringified JSON values in parameters.
28
+ * This handles the case where MCP clients (like Cursor, Claude Code) double-serialize
29
+ * nested object parameters, sending them as JSON strings instead of objects.
30
+ *
31
+ * @see https://github.com/makenotion/notion-mcp-server/issues/176
32
+ */
33
+ function deserializeParams(params: Record<string, unknown>): Record<string, unknown> {
34
+ const result: Record<string, unknown> = {}
35
+
36
+ for (const [key, value] of Object.entries(params)) {
37
+ if (typeof value === 'string') {
38
+ // Check if the string looks like a JSON object or array
39
+ const trimmed = value.trim()
40
+ if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
41
+ (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
42
+ try {
43
+ const parsed = JSON.parse(value)
44
+ // Only use parsed value if it's an object or array
45
+ if (typeof parsed === 'object' && parsed !== null) {
46
+ // Recursively deserialize nested objects
47
+ result[key] = Array.isArray(parsed)
48
+ ? parsed
49
+ : deserializeParams(parsed as Record<string, unknown>)
50
+ continue
51
+ }
52
+ } catch {
53
+ // If parsing fails, keep the original string value
54
+ }
55
+ }
56
+ }
57
+ result[key] = value
58
+ }
59
+
60
+ return result
61
+ }
62
+
63
+ // import this class, extend and return server
64
+ export class MCPProxy {
65
+ private server: Server
66
+ private httpClient: HttpClient
67
+ private tools: Record<string, NewToolDefinition>
68
+ private openApiLookup: Record<string, OpenAPIV3.OperationObject & { method: string; path: string }>
69
+
70
+ constructor(name: string, openApiSpec: OpenAPIV3.Document) {
71
+ this.server = new Server({ name, version: '1.0.0' }, { capabilities: { tools: {} } })
72
+ const baseUrl = openApiSpec.servers?.[0].url
73
+ if (!baseUrl) {
74
+ throw new Error('No base URL found in OpenAPI spec')
75
+ }
76
+ this.httpClient = new HttpClient(
77
+ {
78
+ baseUrl,
79
+ headers: this.parseHeadersFromEnv(),
80
+ },
81
+ openApiSpec,
82
+ )
83
+
84
+ // Convert OpenAPI spec to MCP tools
85
+ const converter = new OpenAPIToMCPConverter(openApiSpec)
86
+ const { tools, openApiLookup } = converter.convertToMCPTools()
87
+ this.tools = tools
88
+ this.openApiLookup = openApiLookup
89
+
90
+ this.setupHandlers()
91
+ }
92
+
93
+ private setupHandlers() {
94
+ // Handle tool listing
95
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => {
96
+ const tools: Tool[] = []
97
+
98
+ // Add methods as separate tools to match the MCP format
99
+ Object.entries(this.tools).forEach(([toolName, def]) => {
100
+ def.methods.forEach(method => {
101
+ const toolNameWithMethod = `${toolName}-${method.name}`;
102
+ const truncatedToolName = this.truncateToolName(toolNameWithMethod);
103
+
104
+ // Look up the HTTP method to determine annotations
105
+ const operation = this.openApiLookup[toolNameWithMethod];
106
+ const httpMethod = operation?.method?.toLowerCase();
107
+ const isReadOnly = httpMethod === 'get';
108
+
109
+ tools.push({
110
+ name: truncatedToolName,
111
+ description: method.description,
112
+ inputSchema: method.inputSchema as Tool['inputSchema'],
113
+ annotations: {
114
+ title: this.operationIdToTitle(method.name),
115
+ ...(isReadOnly
116
+ ? { readOnlyHint: true }
117
+ : { destructiveHint: true }),
118
+ },
119
+ })
120
+ })
121
+ })
122
+
123
+ return { tools }
124
+ })
125
+
126
+ // Handle tool calling
127
+ this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
128
+ const { name, arguments: params } = request.params
129
+
130
+ // Find the operation in OpenAPI spec
131
+ const operation = this.findOperation(name)
132
+ if (!operation) {
133
+ throw new Error(`Method ${name} not found`)
134
+ }
135
+
136
+ // Deserialize any stringified JSON parameters (fixes double-serialization bug)
137
+ // See: https://github.com/makenotion/notion-mcp-server/issues/176
138
+ const deserializedParams = params ? deserializeParams(params as Record<string, unknown>) : {}
139
+
140
+ try {
141
+ // Execute the operation
142
+ const response = await this.httpClient.executeOperation(operation, deserializedParams)
143
+
144
+ // Convert response to MCP format
145
+ return {
146
+ content: [
147
+ {
148
+ type: 'text', // currently this is the only type that seems to be used by mcp server
149
+ text: JSON.stringify(response.data), // TODO: pass through the http status code text?
150
+ },
151
+ ],
152
+ }
153
+ } catch (error) {
154
+ console.error('Error in tool call', error)
155
+ if (error instanceof HttpClientError) {
156
+ console.error('HttpClientError encountered, returning structured error', error)
157
+ const data = error.data?.response?.data ?? error.data ?? {}
158
+ return {
159
+ content: [
160
+ {
161
+ type: 'text',
162
+ text: JSON.stringify({
163
+ status: 'error', // TODO: get this from http status code?
164
+ ...(typeof data === 'object' ? data : { data: data }),
165
+ }),
166
+ },
167
+ ],
168
+ }
169
+ }
170
+ throw error
171
+ }
172
+ })
173
+ }
174
+
175
+ private findOperation(operationId: string): (OpenAPIV3.OperationObject & { method: string; path: string }) | null {
176
+ return this.openApiLookup[operationId] ?? null
177
+ }
178
+
179
+ private parseHeadersFromEnv(): Record<string, string> {
180
+ // First try OPENAPI_MCP_HEADERS (existing behavior)
181
+ const headersJson = process.env.OPENAPI_MCP_HEADERS
182
+ if (headersJson) {
183
+ try {
184
+ const headers = JSON.parse(headersJson)
185
+ if (typeof headers !== 'object' || headers === null) {
186
+ console.warn('OPENAPI_MCP_HEADERS environment variable must be a JSON object, got:', typeof headers)
187
+ } else if (Object.keys(headers).length > 0) {
188
+ // Only use OPENAPI_MCP_HEADERS if it contains actual headers
189
+ return headers
190
+ }
191
+ // If OPENAPI_MCP_HEADERS is empty object, fall through to try NOTION_TOKEN
192
+ } catch (error) {
193
+ console.warn('Failed to parse OPENAPI_MCP_HEADERS environment variable:', error)
194
+ // Fall through to try NOTION_TOKEN
195
+ }
196
+ }
197
+
198
+ // Alternative: try NOTION_TOKEN
199
+ const notionToken = process.env.NOTION_TOKEN
200
+ if (notionToken) {
201
+ return {
202
+ 'Authorization': `Bearer ${notionToken}`,
203
+ 'Notion-Version': '2025-09-03'
204
+ }
205
+ }
206
+
207
+ return {}
208
+ }
209
+
210
+ private getContentType(headers: Headers): 'text' | 'image' | 'binary' {
211
+ const contentType = headers.get('content-type')
212
+ if (!contentType) return 'binary'
213
+
214
+ if (contentType.includes('text') || contentType.includes('json')) {
215
+ return 'text'
216
+ } else if (contentType.includes('image')) {
217
+ return 'image'
218
+ }
219
+ return 'binary'
220
+ }
221
+
222
+ private truncateToolName(name: string): string {
223
+ if (name.length <= 64) {
224
+ return name;
225
+ }
226
+ return name.slice(0, 64);
227
+ }
228
+
229
+ /**
230
+ * Convert an operationId like "createDatabase" to a human-readable title like "Create Database"
231
+ */
232
+ private operationIdToTitle(operationId: string): string {
233
+ // Split on camelCase boundaries and capitalize each word
234
+ return operationId
235
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
236
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
237
+ .split(/[\s_-]+/)
238
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
239
+ .join(' ');
240
+ }
241
+
242
+ async connect(transport: Transport) {
243
+ // The SDK will handle stdio communication
244
+ await this.server.connect(transport)
245
+ }
246
+
247
+ getServer() {
248
+ return this.server
249
+ }
250
+ }
@@ -0,0 +1,100 @@
1
+ import { OpenAPIV3 } from 'openapi-types'
2
+ import { describe, it, expect } from 'vitest'
3
+ import { isFileUploadParameter } from '../file-upload'
4
+
5
+ describe('File Upload Detection', () => {
6
+ it('identifies file upload parameters in request bodies', () => {
7
+ const operation: OpenAPIV3.OperationObject = {
8
+ operationId: 'uploadFile',
9
+ responses: {
10
+ '200': {
11
+ description: 'File uploaded successfully',
12
+ },
13
+ },
14
+ requestBody: {
15
+ content: {
16
+ 'multipart/form-data': {
17
+ schema: {
18
+ type: 'object',
19
+ properties: {
20
+ file: {
21
+ type: 'string',
22
+ format: 'binary',
23
+ },
24
+ additionalInfo: {
25
+ type: 'string',
26
+ },
27
+ },
28
+ },
29
+ },
30
+ },
31
+ },
32
+ }
33
+
34
+ const fileParams = isFileUploadParameter(operation)
35
+ expect(fileParams).toEqual(['file'])
36
+ })
37
+
38
+ it('returns empty array for non-file upload operations', () => {
39
+ const operation: OpenAPIV3.OperationObject = {
40
+ operationId: 'createUser',
41
+ responses: {
42
+ '200': {
43
+ description: 'User created successfully',
44
+ },
45
+ },
46
+ requestBody: {
47
+ content: {
48
+ 'application/json': {
49
+ schema: {
50
+ type: 'object',
51
+ properties: {
52
+ name: {
53
+ type: 'string',
54
+ },
55
+ },
56
+ },
57
+ },
58
+ },
59
+ },
60
+ }
61
+
62
+ const fileParams = isFileUploadParameter(operation)
63
+ expect(fileParams).toEqual([])
64
+ })
65
+
66
+ it('identifies array-based file upload parameters', () => {
67
+ const operation: OpenAPIV3.OperationObject = {
68
+ operationId: 'uploadFiles',
69
+ responses: {
70
+ '200': {
71
+ description: 'Files uploaded successfully',
72
+ },
73
+ },
74
+ requestBody: {
75
+ content: {
76
+ 'multipart/form-data': {
77
+ schema: {
78
+ type: 'object',
79
+ properties: {
80
+ files: {
81
+ type: 'array',
82
+ items: {
83
+ type: 'string',
84
+ format: 'binary',
85
+ },
86
+ },
87
+ description: {
88
+ type: 'string',
89
+ },
90
+ },
91
+ },
92
+ },
93
+ },
94
+ },
95
+ }
96
+
97
+ const fileParams = isFileUploadParameter(operation)
98
+ expect(fileParams).toEqual(['files'])
99
+ })
100
+ })