UNPKG

@notionhq/notion-mcp-server

Version:
185 lines (162 loc) 6 kB
import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { CallToolRequestSchema, JSONRPCResponse, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js' import { JSONSchema7 as IJsonSchema } from 'json-schema' import { OpenAPIToMCPConverter } from '../openapi/parser' import { HttpClient, HttpClientError } from '../client/http-client' import { OpenAPIV3 } from 'openapi-types' import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' type PathItemObject = OpenAPIV3.PathItemObject & { get?: OpenAPIV3.OperationObject put?: OpenAPIV3.OperationObject post?: OpenAPIV3.OperationObject delete?: OpenAPIV3.OperationObject patch?: OpenAPIV3.OperationObject } type NewToolDefinition = { methods: Array<{ name: string description: string inputSchema: IJsonSchema & { type: 'object' } returnSchema?: IJsonSchema }> } // import this class, extend and return server export class MCPProxy { private server: Server private httpClient: HttpClient private tools: Record<string, NewToolDefinition> private openApiLookup: Record<string, OpenAPIV3.OperationObject & { method: string; path: string }> constructor(name: string, openApiSpec: OpenAPIV3.Document) { this.server = new Server({ name, version: '1.0.0' }, { capabilities: { tools: {} } }) const baseUrl = openApiSpec.servers?.[0].url if (!baseUrl) { throw new Error('No base URL found in OpenAPI spec') } this.httpClient = new HttpClient( { baseUrl, headers: this.parseHeadersFromEnv(), }, openApiSpec, ) // Convert OpenAPI spec to MCP tools const converter = new OpenAPIToMCPConverter(openApiSpec) const { tools, openApiLookup } = converter.convertToMCPTools() this.tools = tools this.openApiLookup = openApiLookup this.setupHandlers() } private setupHandlers() { // Handle tool listing this.server.setRequestHandler(ListToolsRequestSchema, async () => { const tools: Tool[] = [] // Add methods as separate tools to match the MCP format Object.entries(this.tools).forEach(([toolName, def]) => { def.methods.forEach(method => { const toolNameWithMethod = `${toolName}-${method.name}`; const truncatedToolName = this.truncateToolName(toolNameWithMethod); tools.push({ name: truncatedToolName, description: method.description, inputSchema: method.inputSchema as Tool['inputSchema'], }) }) }) return { tools } }) // Handle tool calling this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: params } = request.params // Find the operation in OpenAPI spec const operation = this.findOperation(name) if (!operation) { throw new Error(`Method ${name} not found`) } try { // Execute the operation const response = await this.httpClient.executeOperation(operation, params) // Convert response to MCP format return { content: [ { type: 'text', // currently this is the only type that seems to be used by mcp server text: JSON.stringify(response.data), // TODO: pass through the http status code text? }, ], } } catch (error) { console.error('Error in tool call', error) if (error instanceof HttpClientError) { console.error('HttpClientError encountered, returning structured error', error) const data = error.data?.response?.data ?? error.data ?? {} return { content: [ { type: 'text', text: JSON.stringify({ status: 'error', // TODO: get this from http status code? ...(typeof data === 'object' ? data : { data: data }), }), }, ], } } throw error } }) } private findOperation(operationId: string): (OpenAPIV3.OperationObject & { method: string; path: string }) | null { return this.openApiLookup[operationId] ?? null } private parseHeadersFromEnv(): Record<string, string> { // First try OPENAPI_MCP_HEADERS (existing behavior) const headersJson = process.env.OPENAPI_MCP_HEADERS if (headersJson) { try { const headers = JSON.parse(headersJson) if (typeof headers !== 'object' || headers === null) { console.warn('OPENAPI_MCP_HEADERS environment variable must be a JSON object, got:', typeof headers) } else if (Object.keys(headers).length > 0) { // Only use OPENAPI_MCP_HEADERS if it contains actual headers return headers } // If OPENAPI_MCP_HEADERS is empty object, fall through to try NOTION_TOKEN } catch (error) { console.warn('Failed to parse OPENAPI_MCP_HEADERS environment variable:', error) // Fall through to try NOTION_TOKEN } } // Alternative: try NOTION_TOKEN const notionToken = process.env.NOTION_TOKEN if (notionToken) { return { 'Authorization': `Bearer ${notionToken}`, 'Notion-Version': '2022-06-28' } } return {} } private getContentType(headers: Headers): 'text' | 'image' | 'binary' { const contentType = headers.get('content-type') if (!contentType) return 'binary' if (contentType.includes('text') || contentType.includes('json')) { return 'text' } else if (contentType.includes('image')) { return 'image' } return 'binary' } private truncateToolName(name: string): string { if (name.length <= 64) { return name; } return name.slice(0, 64); } async connect(transport: Transport) { // The SDK will handle stdio communication await this.server.connect(transport) } getServer() { return this.server } }