@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.
- package/.devcontainer/devcontainer.json +4 -0
- package/.dockerignore +3 -0
- package/.github/pull_request_template.md +8 -0
- package/.github/workflows/ci.yml +42 -0
- package/Dockerfile +36 -0
- package/LICENSE +7 -0
- package/README.md +412 -0
- package/docker-compose.yml +6 -0
- package/docs/images/connections.png +0 -0
- package/docs/images/integration-access.png +0 -0
- package/docs/images/integrations-capabilities.png +0 -0
- package/docs/images/integrations-creation.png +0 -0
- package/docs/images/page-access-edit.png +0 -0
- package/package.json +63 -0
- package/scripts/build-cli.js +30 -0
- package/scripts/notion-openapi.json +2238 -0
- package/scripts/start-server.ts +243 -0
- package/src/init-server.ts +50 -0
- package/src/openapi-mcp-server/README.md +3 -0
- package/src/openapi-mcp-server/auth/index.ts +2 -0
- package/src/openapi-mcp-server/auth/template.ts +24 -0
- package/src/openapi-mcp-server/auth/types.ts +26 -0
- package/src/openapi-mcp-server/client/__tests__/http-client-upload.test.ts +205 -0
- package/src/openapi-mcp-server/client/__tests__/http-client.integration.test.ts +282 -0
- package/src/openapi-mcp-server/client/__tests__/http-client.test.ts +537 -0
- package/src/openapi-mcp-server/client/http-client.ts +198 -0
- package/src/openapi-mcp-server/client/polyfill-headers.ts +42 -0
- package/src/openapi-mcp-server/index.ts +3 -0
- package/src/openapi-mcp-server/mcp/__tests__/proxy.test.ts +479 -0
- package/src/openapi-mcp-server/mcp/proxy.ts +250 -0
- package/src/openapi-mcp-server/openapi/__tests__/file-upload.test.ts +100 -0
- package/src/openapi-mcp-server/openapi/__tests__/parser-multipart.test.ts +602 -0
- package/src/openapi-mcp-server/openapi/__tests__/parser.test.ts +1448 -0
- package/src/openapi-mcp-server/openapi/file-upload.ts +40 -0
- package/src/openapi-mcp-server/openapi/parser.ts +529 -0
- 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
|
+
})
|