Skip to content

Commit a1efdea

Browse files
authored
fix OOM for notion-toolathlon. (#1332)
1 parent 9b86de7 commit a1efdea

File tree

5 files changed

+108
-30
lines changed

5 files changed

+108
-30
lines changed

mcp_servers/notion_toolathlon/scripts/start-server.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
55
import { randomBytes } from 'node:crypto'
66
import express from 'express'
77

8-
import { initProxy, ValidationError } from '../src/init-server.js'
8+
import { initProxy, preloadSpec, ValidationError } from '../src/init-server.js'
99

1010
/**
1111
* Extract Notion token from request header.
@@ -134,6 +134,10 @@ Examples:
134134
await proxy.connect(new StdioServerTransport())
135135
return proxy.getServer()
136136
} else if (transport === 'http') {
137+
// Pre-load and pre-compute the OpenAPI spec and tools once at startup
138+
// to avoid re-parsing on every request (major memory optimization)
139+
await preloadSpec(specPath, baseUrl)
140+
137141
// Use Streamable HTTP transport
138142
const app = express()
139143
app.use(express.json())

mcp_servers/notion_toolathlon/src/init-server.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import path from 'node:path'
44
import { OpenAPIV3 } from 'openapi-types'
55
import OpenAPISchemaValidator from 'openapi-schema-validator'
66

7-
import { MCPProxy } from './openapi-mcp-server/mcp/proxy.js'
7+
import { MCPProxy, precomputeTools, type PrecomputedTools } from './openapi-mcp-server/mcp/proxy.js'
88

99
export class ValidationError extends Error {
1010
constructor(public errors: any[]) {
@@ -42,9 +42,21 @@ async function loadOpenApiSpec(specPath: string, baseUrl: string | undefined): P
4242
}
4343
}
4444

45-
export async function initProxy(specPath: string, baseUrl: string | undefined, options: { pageIds?: string[]; pageUrls?: string[]; notionToken?: string } = {}) {
46-
const openApiSpec = await loadOpenApiSpec(specPath, baseUrl)
47-
const proxy = new MCPProxy('Notion API', openApiSpec, options)
45+
let cachedSpec: OpenAPIV3.Document | null = null
46+
let cachedPrecomputed: PrecomputedTools | null = null
47+
48+
/**
49+
* Pre-load the OpenAPI spec and precompute tools once at startup.
50+
* This avoids re-parsing and re-converting on every request.
51+
*/
52+
export async function preloadSpec(specPath: string, baseUrl: string | undefined): Promise<void> {
53+
cachedSpec = await loadOpenApiSpec(specPath, baseUrl)
54+
cachedPrecomputed = precomputeTools(cachedSpec)
55+
console.log('OpenAPI spec and tools pre-computed successfully')
56+
}
4857

58+
export async function initProxy(specPath: string, baseUrl: string | undefined, options: { pageIds?: string[]; pageUrls?: string[]; notionToken?: string } = {}) {
59+
const openApiSpec = cachedSpec ?? await loadOpenApiSpec(specPath, baseUrl)
60+
const proxy = new MCPProxy('Notion API', openApiSpec, options, cachedPrecomputed ?? undefined)
4961
return proxy
5062
}

mcp_servers/notion_toolathlon/src/openapi-mcp-server/client/http-client.ts

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,24 +29,44 @@ export class HttpClientError extends Error {
2929
}
3030
}
3131

32+
/**
33+
* Initialize the OpenAPI client from a spec. This is expensive (parses and
34+
* validates the entire spec) so the result should be cached and reused.
35+
*/
36+
export function initApiClient(
37+
baseUrl: string,
38+
openApiSpec: OpenAPIV3.Document | OpenAPIV3_1.Document,
39+
): Promise<AxiosInstance> {
40+
// @ts-expect-error
41+
const client = new (OpenAPIClientAxios.default ?? OpenAPIClientAxios)({
42+
definition: openApiSpec,
43+
axiosConfigDefaults: {
44+
baseURL: baseUrl,
45+
headers: {
46+
'Content-Type': 'application/json',
47+
'User-Agent': 'notion-mcp-server',
48+
},
49+
},
50+
})
51+
return client.init()
52+
}
53+
3254
export class HttpClient {
3355
private api: Promise<AxiosInstance>
34-
private client: OpenAPIClientAxios
35-
36-
constructor(config: HttpClientConfig, openApiSpec: OpenAPIV3.Document | OpenAPIV3_1.Document) {
37-
// @ts-expect-error
38-
this.client = new (OpenAPIClientAxios.default ?? OpenAPIClientAxios)({
39-
definition: openApiSpec,
40-
axiosConfigDefaults: {
41-
baseURL: config.baseUrl,
42-
headers: {
43-
'Content-Type': 'application/json',
44-
'User-Agent': 'notion-mcp-server',
45-
...config.headers,
46-
},
47-
},
48-
})
49-
this.api = this.client.init()
56+
private perRequestHeaders: Record<string, string>
57+
58+
/**
59+
* Create an HttpClient. If a cachedApi is provided, reuses the already-initialized
60+
* axios instance (avoids expensive re-parsing of the spec on every request).
61+
*/
62+
constructor(config: HttpClientConfig, openApiSpec: OpenAPIV3.Document | OpenAPIV3_1.Document, cachedApi?: Promise<AxiosInstance>) {
63+
this.perRequestHeaders = config.headers || {}
64+
65+
if (cachedApi) {
66+
this.api = cachedApi
67+
} else {
68+
this.api = initApiClient(config.baseUrl, openApiSpec)
69+
}
5070
}
5171

5272
private async prepareFileUpload(operation: OpenAPIV3.OperationObject, params: Record<string, any>): Promise<FormData | null> {
@@ -152,12 +172,14 @@ export class HttpClient {
152172
try {
153173
// If we have form data, we need to set the correct headers
154174
const hasBody = Object.keys(bodyParams).length > 0
155-
const headers = formData
175+
const contentHeaders = formData
156176
? formData.getHeaders()
157177
: { ...(hasBody ? { 'Content-Type': 'application/json' } : { 'Content-Type': null }) }
158178
const requestConfig = {
159179
headers: {
160-
...headers,
180+
...contentHeaders,
181+
// Inject per-request auth headers (e.g. per-user Notion token)
182+
...this.perRequestHeaders,
161183
},
162184
}
163185

mcp_servers/notion_toolathlon/src/openapi-mcp-server/mcp/proxy.ts

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
22
import { CallToolRequestSchema, JSONRPCResponse, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js'
33
import { JSONSchema7 as IJsonSchema } from 'json-schema'
44
import { OpenAPIToMCPConverter } from '../openapi/parser.js'
5-
import { HttpClient, HttpClientError } from '../client/http-client.js'
5+
import { HttpClient, HttpClientError, initApiClient } from '../client/http-client.js'
6+
import type { AxiosInstance } from 'axios'
67
import { OpenAPIV3 } from 'openapi-types'
78
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
89
import { PageAccessController } from '../auth/page-access-control.js'
@@ -30,6 +31,34 @@ export interface MCPProxyOptions {
3031
notionToken?: string
3132
}
3233

34+
/**
35+
* Pre-computed tools, lookup, and cached API client from OpenAPI spec.
36+
* These are expensive to compute and should be done once at startup.
37+
*/
38+
export interface PrecomputedTools {
39+
tools: Record<string, NewToolDefinition>
40+
openApiLookup: Record<string, OpenAPIV3.OperationObject & { method: string; path: string }>
41+
cachedApi: Promise<AxiosInstance>
42+
}
43+
44+
/**
45+
* Pre-compute tools and initialize the API client from an OpenAPI spec.
46+
* Call once at startup to avoid expensive re-parsing on every request.
47+
*/
48+
export function precomputeTools(openApiSpec: OpenAPIV3.Document): PrecomputedTools {
49+
const converter = new OpenAPIToMCPConverter(openApiSpec)
50+
const { tools, openApiLookup } = converter.convertToMCPTools()
51+
52+
const baseUrl = openApiSpec.servers?.[0]?.url
53+
if (!baseUrl) {
54+
throw new Error('No base URL found in OpenAPI spec')
55+
}
56+
57+
const cachedApi = initApiClient(baseUrl, openApiSpec)
58+
59+
return { tools, openApiLookup, cachedApi }
60+
}
61+
3362
// import this class, extend and return server
3463
export class MCPProxy {
3564
private server: Server
@@ -38,7 +67,7 @@ export class MCPProxy {
3867
private openApiLookup: Record<string, OpenAPIV3.OperationObject & { method: string; path: string }>
3968
private pageAccessController: PageAccessController | null = null
4069

41-
constructor(name: string, openApiSpec: OpenAPIV3.Document, options: MCPProxyOptions = {}) {
70+
constructor(name: string, openApiSpec: OpenAPIV3.Document, options: MCPProxyOptions = {}, precomputed?: PrecomputedTools) {
4271
this.server = new Server({ name, version: '1.0.0' }, { capabilities: { tools: {} } })
4372
const baseUrl = openApiSpec.servers?.[0].url
4473
if (!baseUrl) {
@@ -50,6 +79,7 @@ export class MCPProxy {
5079
headers: this.parseHeadersFromEnv(options.notionToken),
5180
},
5281
openApiSpec,
82+
precomputed?.cachedApi,
5383
)
5484

5585
// Initialize page access control if needed
@@ -61,11 +91,16 @@ export class MCPProxy {
6191
})
6292
}
6393

64-
// Convert OpenAPI spec to MCP tools
65-
const converter = new OpenAPIToMCPConverter(openApiSpec)
66-
const { tools, openApiLookup } = converter.convertToMCPTools()
67-
this.tools = tools
68-
this.openApiLookup = openApiLookup
94+
// Use pre-computed tools if available, otherwise compute them
95+
if (precomputed) {
96+
this.tools = precomputed.tools
97+
this.openApiLookup = precomputed.openApiLookup
98+
} else {
99+
const converter = new OpenAPIToMCPConverter(openApiSpec)
100+
const { tools, openApiLookup } = converter.convertToMCPTools()
101+
this.tools = tools
102+
this.openApiLookup = openApiLookup
103+
}
69104

70105
this.setupHandlers()
71106
}

mcp_servers/notion_toolathlon/src/openapi-mcp-server/openapi/parser.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type FunctionParameters = {
2020
export class OpenAPIToMCPConverter {
2121
private schemaCache: Record<string, IJsonSchema> = {}
2222
private nameCounter: number = 0
23+
private componentSchemaCache: Record<string, IJsonSchema> | null = null
2324

2425
constructor(private openApiSpec: OpenAPIV3.Document | OpenAPIV3_1.Document) {}
2526

@@ -250,11 +251,15 @@ export class OpenAPIToMCPConverter {
250251
}
251252

252253
private convertComponentsToJsonSchema(): Record<string, IJsonSchema> {
254+
if (this.componentSchemaCache) {
255+
return this.componentSchemaCache
256+
}
253257
const components = this.openApiSpec.components || {}
254258
const schema: Record<string, IJsonSchema> = {}
255259
for (const [key, value] of Object.entries(components.schemas || {})) {
256260
schema[key] = this.convertOpenApiSchemaToJsonSchema(value, new Set())
257261
}
262+
this.componentSchemaCache = schema
258263
return schema
259264
}
260265
/**

0 commit comments

Comments
 (0)