@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.
- package/LICENSE +7 -0
- package/README.md +58 -0
- package/bin/cli.mjs +71 -0
- package/package.json +57 -0
- package/scripts/build-cli.js +30 -0
- package/scripts/notion-openapi.json +1032 -0
- package/scripts/start-server.ts +70 -0
- package/src/openapi-mcp-server/README.md +1 -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 +173 -0
- package/src/openapi-mcp-server/client/__tests__/http-client.test.ts +537 -0
- package/src/openapi-mcp-server/client/http-client.ts +192 -0
- package/src/openapi-mcp-server/index.ts +3 -0
- package/src/openapi-mcp-server/mcp/__tests__/proxy.test.ts +270 -0
- package/src/openapi-mcp-server/mcp/proxy.ts +170 -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 +519 -0
- package/tsconfig.json +26 -0
|
@@ -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,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
|
+
}
|