@notionhq/notion-mcp-server 1.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.
@@ -0,0 +1,192 @@
1
+ import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'
2
+ import OpenAPIClientAxios from 'openapi-client-axios'
3
+ import type { AxiosInstance } from 'axios'
4
+ import FormData from 'form-data'
5
+ import fs from 'fs'
6
+ import { isFileUploadParameter } from '../openapi/file-upload'
7
+
8
+ export type HttpClientConfig = {
9
+ baseUrl: string
10
+ headers?: Record<string, string>
11
+ }
12
+
13
+ export type HttpClientResponse<T = any> = {
14
+ data: T
15
+ status: number
16
+ headers: Headers
17
+ }
18
+
19
+ export class HttpClientError extends Error {
20
+ constructor(
21
+ message: string,
22
+ public status: number,
23
+ public data: any,
24
+ public headers?: Headers,
25
+ ) {
26
+ super(`${status} ${message}`)
27
+ this.name = 'HttpClientError'
28
+ }
29
+ }
30
+
31
+ export class HttpClient {
32
+ private api: Promise<AxiosInstance>
33
+ private client: OpenAPIClientAxios
34
+
35
+ constructor(config: HttpClientConfig, openApiSpec: OpenAPIV3.Document | OpenAPIV3_1.Document) {
36
+ // @ts-expect-error
37
+ this.client = new (OpenAPIClientAxios.default ?? OpenAPIClientAxios)({
38
+ definition: openApiSpec,
39
+ axiosConfigDefaults: {
40
+ baseURL: config.baseUrl,
41
+ headers: {
42
+ 'Content-Type': 'application/json',
43
+ ...config.headers,
44
+ },
45
+ },
46
+ })
47
+ this.api = this.client.init()
48
+ }
49
+
50
+ private async prepareFileUpload(operation: OpenAPIV3.OperationObject, params: Record<string, any>): Promise<FormData | null> {
51
+ console.error('prepareFileUpload', { operation, params })
52
+ const fileParams = isFileUploadParameter(operation)
53
+ if (fileParams.length === 0) return null
54
+
55
+ const formData = new FormData()
56
+
57
+ // Handle file uploads
58
+ for (const param of fileParams) {
59
+ console.error(`extracting ${param}`, {params})
60
+ const filePath = params[param]
61
+ if (!filePath) {
62
+ throw new Error(`File path must be provided for parameter: ${param}`)
63
+ }
64
+ switch (typeof filePath) {
65
+ case 'string':
66
+ addFile(param, filePath)
67
+ break
68
+ case 'object':
69
+ if(Array.isArray(filePath)) {
70
+ let fileCount = 0
71
+ for(const file of filePath) {
72
+ addFile(param, file)
73
+ fileCount++
74
+ }
75
+ break
76
+ }
77
+ //deliberate fallthrough
78
+ default:
79
+ throw new Error(`Unsupported file type: ${typeof filePath}`)
80
+ }
81
+ function addFile(name: string, filePath: string) {
82
+ try {
83
+ const fileStream = fs.createReadStream(filePath)
84
+ formData.append(name, fileStream)
85
+ } catch (error) {
86
+ throw new Error(`Failed to read file at ${filePath}: ${error}`)
87
+ }
88
+ }
89
+ }
90
+
91
+ // Add non-file parameters to form data
92
+ for (const [key, value] of Object.entries(params)) {
93
+ if (!fileParams.includes(key)) {
94
+ formData.append(key, value)
95
+ }
96
+ }
97
+
98
+ return formData
99
+ }
100
+
101
+ /**
102
+ * Execute an OpenAPI operation
103
+ */
104
+ async executeOperation<T = any>(
105
+ operation: OpenAPIV3.OperationObject & { method: string; path: string },
106
+ params: Record<string, any> = {},
107
+ ): Promise<HttpClientResponse<T>> {
108
+ const api = await this.api
109
+ const operationId = operation.operationId
110
+ if (!operationId) {
111
+ throw new Error('Operation ID is required')
112
+ }
113
+
114
+ // Handle file uploads if present
115
+ const formData = await this.prepareFileUpload(operation, params)
116
+
117
+ // Separate parameters based on their location
118
+ const urlParameters: Record<string, any> = {}
119
+ const bodyParams: Record<string, any> = formData || { ...params }
120
+
121
+ // Extract path and query parameters based on operation definition
122
+ if (operation.parameters) {
123
+ for (const param of operation.parameters) {
124
+ if ('name' in param && param.name && param.in) {
125
+ if (param.in === 'path' || param.in === 'query') {
126
+ if (params[param.name] !== undefined) {
127
+ urlParameters[param.name] = params[param.name]
128
+ if (!formData) {
129
+ delete bodyParams[param.name]
130
+ }
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ // Add all parameters as url parameters if there is no requestBody defined
138
+ if (!operation.requestBody && !formData) {
139
+ for (const key in bodyParams) {
140
+ if (bodyParams[key] !== undefined) {
141
+ urlParameters[key] = bodyParams[key]
142
+ delete bodyParams[key]
143
+ }
144
+ }
145
+ }
146
+
147
+ const operationFn = (api as any)[operationId]
148
+ if (!operationFn) {
149
+ throw new Error(`Operation ${operationId} not found`)
150
+ }
151
+
152
+ try {
153
+ // If we have form data, we need to set the correct headers
154
+ const hasBody = Object.keys(bodyParams).length > 0
155
+ const headers = formData
156
+ ? formData.getHeaders()
157
+ : { ...(hasBody ? { 'Content-Type': 'application/json' } : { 'Content-Type': null }) }
158
+ const requestConfig = {
159
+ headers: {
160
+ ...headers,
161
+ },
162
+ }
163
+
164
+ // first argument is url parameters, second is body parameters
165
+ console.error('calling operation', { operationId, urlParameters, bodyParams, requestConfig })
166
+ const response = await operationFn(urlParameters, hasBody ? bodyParams : undefined, requestConfig)
167
+
168
+ // Convert axios headers to Headers object
169
+ const responseHeaders = new Headers()
170
+ Object.entries(response.headers).forEach(([key, value]) => {
171
+ if (value) responseHeaders.append(key, value.toString())
172
+ })
173
+
174
+ return {
175
+ data: response.data,
176
+ status: response.status,
177
+ headers: responseHeaders,
178
+ }
179
+ } catch (error: any) {
180
+ if (error.response) {
181
+ console.error('Error in http client', error)
182
+ const headers = new Headers()
183
+ Object.entries(error.response.headers).forEach(([key, value]) => {
184
+ if (value) headers.append(key, value.toString())
185
+ })
186
+
187
+ throw new HttpClientError(error.response.statusText || 'Request failed', error.response.status, error.response.data, headers)
188
+ }
189
+ throw error
190
+ }
191
+ }
192
+ }
@@ -0,0 +1,3 @@
1
+ export { OpenAPIToMCPConverter } from './openapi/parser'
2
+ export { HttpClient } from './client/http-client'
3
+ export type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'
@@ -0,0 +1,270 @@
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')
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
+ })
246
+
247
+ it('should return empty object and warn on non-object JSON', () => {
248
+ const consoleSpy = vi.spyOn(console, 'warn')
249
+ process.env.OPENAPI_MCP_HEADERS = '"string"'
250
+
251
+ const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
252
+ expect(HttpClient).toHaveBeenCalledWith(
253
+ expect.objectContaining({
254
+ headers: {},
255
+ }),
256
+ expect.anything(),
257
+ )
258
+ expect(consoleSpy).toHaveBeenCalledWith('OPENAPI_MCP_HEADERS environment variable must be a JSON object, got:', 'string')
259
+ })
260
+ })
261
+ describe('connect', () => {
262
+ it('should connect to transport', async () => {
263
+ const mockTransport = {} as Transport
264
+ await proxy.connect(mockTransport)
265
+
266
+ const server = (proxy as any).server
267
+ expect(server.connect).toHaveBeenCalledWith(mockTransport)
268
+ })
269
+ })
270
+ })
@@ -0,0 +1,170 @@
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
+ // import this class, extend and return server
27
+ export class MCPProxy {
28
+ private server: Server
29
+ private httpClient: HttpClient
30
+ private tools: Record<string, NewToolDefinition>
31
+ private openApiLookup: Record<string, OpenAPIV3.OperationObject & { method: string; path: string }>
32
+
33
+ constructor(name: string, openApiSpec: OpenAPIV3.Document) {
34
+ this.server = new Server({ name, version: '1.0.0' }, { capabilities: { tools: {} } })
35
+ const baseUrl = openApiSpec.servers?.[0].url
36
+ if (!baseUrl) {
37
+ throw new Error('No base URL found in OpenAPI spec')
38
+ }
39
+ this.httpClient = new HttpClient(
40
+ {
41
+ baseUrl,
42
+ headers: this.parseHeadersFromEnv(),
43
+ },
44
+ openApiSpec,
45
+ )
46
+
47
+ // Convert OpenAPI spec to MCP tools
48
+ const converter = new OpenAPIToMCPConverter(openApiSpec)
49
+ const { tools, openApiLookup } = converter.convertToMCPTools()
50
+ this.tools = tools
51
+ this.openApiLookup = openApiLookup
52
+
53
+ this.setupHandlers()
54
+ }
55
+
56
+ private setupHandlers() {
57
+ // Handle tool listing
58
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => {
59
+ const tools: Tool[] = []
60
+
61
+ // Add methods as separate tools to match the MCP format
62
+ Object.entries(this.tools).forEach(([toolName, def]) => {
63
+ def.methods.forEach(method => {
64
+ const toolNameWithMethod = `${toolName}-${method.name}`;
65
+ const truncatedToolName = this.truncateToolName(toolNameWithMethod);
66
+ tools.push({
67
+ name: truncatedToolName,
68
+ description: method.description,
69
+ inputSchema: method.inputSchema as Tool['inputSchema'],
70
+ })
71
+ })
72
+ })
73
+
74
+ return { tools }
75
+ })
76
+
77
+ // Handle tool calling
78
+ this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
79
+ console.error('calling tool', request.params)
80
+ const { name, arguments: params } = request.params
81
+
82
+ // Find the operation in OpenAPI spec
83
+ const operation = this.findOperation(name)
84
+ console.error('operations', this.openApiLookup)
85
+ if (!operation) {
86
+ throw new Error(`Method ${name} not found`)
87
+ }
88
+
89
+ try {
90
+ // Execute the operation
91
+ const response = await this.httpClient.executeOperation(operation, params)
92
+
93
+ // Convert response to MCP format
94
+ return {
95
+ content: [
96
+ {
97
+ type: 'text', // currently this is the only type that seems to be used by mcp server
98
+ text: JSON.stringify(response.data), // TODO: pass through the http status code text?
99
+ },
100
+ ],
101
+ }
102
+ } catch (error) {
103
+ console.error('Error in tool call', error)
104
+ if (error instanceof HttpClientError) {
105
+ console.error('HttpClientError encountered, returning structured error', error)
106
+ const data = error.data?.response?.data ?? error.data ?? {}
107
+ return {
108
+ content: [
109
+ {
110
+ type: 'text',
111
+ text: JSON.stringify({
112
+ status: 'error', // TODO: get this from http status code?
113
+ ...(typeof data === 'object' ? data : { data: data }),
114
+ }),
115
+ },
116
+ ],
117
+ }
118
+ }
119
+ throw error
120
+ }
121
+ })
122
+ }
123
+
124
+ private findOperation(operationId: string): (OpenAPIV3.OperationObject & { method: string; path: string }) | null {
125
+ return this.openApiLookup[operationId] ?? null
126
+ }
127
+
128
+ private parseHeadersFromEnv(): Record<string, string> {
129
+ const headersJson = process.env.OPENAPI_MCP_HEADERS
130
+ if (!headersJson) {
131
+ return {}
132
+ }
133
+
134
+ try {
135
+ const headers = JSON.parse(headersJson)
136
+ if (typeof headers !== 'object' || headers === null) {
137
+ console.warn('OPENAPI_MCP_HEADERS environment variable must be a JSON object, got:', typeof headers)
138
+ return {}
139
+ }
140
+ return headers
141
+ } catch (error) {
142
+ console.warn('Failed to parse OPENAPI_MCP_HEADERS environment variable:', error)
143
+ return {}
144
+ }
145
+ }
146
+
147
+ private getContentType(headers: Headers): 'text' | 'image' | 'binary' {
148
+ const contentType = headers.get('content-type')
149
+ if (!contentType) return 'binary'
150
+
151
+ if (contentType.includes('text') || contentType.includes('json')) {
152
+ return 'text'
153
+ } else if (contentType.includes('image')) {
154
+ return 'image'
155
+ }
156
+ return 'binary'
157
+ }
158
+
159
+ private truncateToolName(name: string): string {
160
+ if (name.length <= 64) {
161
+ return name;
162
+ }
163
+ return name.slice(0, 64);
164
+ }
165
+
166
+ async connect(transport: Transport) {
167
+ // The SDK will handle stdio communication
168
+ await this.server.connect(transport)
169
+ }
170
+ }