diff --git a/.changeset/calm-mcp-streams.md b/.changeset/calm-mcp-streams.md new file mode 100644 index 0000000..e63ba22 --- /dev/null +++ b/.changeset/calm-mcp-streams.md @@ -0,0 +1,5 @@ +--- +'incur': patch +--- + +Prevented completed MCP JSON responses from retaining transport stream state. diff --git a/package.json b/package.json index 0406d29..684d076 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ ], "dependencies": { "@cfworker/json-schema": "^4.1.1", - "@modelcontextprotocol/server": "^2.0.0-alpha.2", + "@modelcontextprotocol/server": "2.0.0-alpha.4", "@scalar/openapi-types": "^0.8.0", "@toon-format/toon": "^2.1.0", "tokenx": "^1.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f890daf..8f0ef90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^4.1.1 version: 4.1.1 '@modelcontextprotocol/server': - specifier: ^2.0.0-alpha.2 - version: 2.0.0-alpha.2(@cfworker/json-schema@4.1.1) + specifier: 2.0.0-alpha.4 + version: 2.0.0-alpha.4 '@scalar/openapi-types': specifier: ^0.8.0 version: 0.8.0 @@ -370,14 +370,9 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@modelcontextprotocol/server@2.0.0-alpha.2': - resolution: {integrity: sha512-gmLgdHzlYM8L7Aw/+VE0kxjT25WKamtUSLNhdOgrJq5CrESvqVSoAfWSJJeNPUXNTluQ+dYDGFbKVitdsJtbPA==} + '@modelcontextprotocol/server@2.0.0-alpha.4': + resolution: {integrity: sha512-/KEo3ZJ50HlagHp0lz2vPgfBZFtXHu6zTBXT9XqPc+4O9i4+IbBVWKq/a9yAfv/ifp0u3+mRo8SUk5kMKzhN8A==} engines: {node: '>=20'} - peerDependencies: - '@cfworker/json-schema': ^4.1.1 - peerDependenciesMeta: - '@cfworker/json-schema': - optional: true '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -1784,11 +1779,9 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@modelcontextprotocol/server@2.0.0-alpha.2(@cfworker/json-schema@4.1.1)': + '@modelcontextprotocol/server@2.0.0-alpha.4': dependencies: zod: 4.3.6 - optionalDependencies: - '@cfworker/json-schema': 4.1.1 '@nodelib/fs.scandir@2.1.5': dependencies: diff --git a/src/Cli.test.ts b/src/Cli.test.ts index c3a4e32..35ff72d 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -1,4 +1,5 @@ import { Cli, Errors, Mcp, z } from 'incur' +import { execFile } from 'node:child_process' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' @@ -57,6 +58,20 @@ function mockMcpServeResponses(responses: unknown[]) { }) } +function countRetainedMcpResponses() { + return new Promise((resolve, reject) => { + execFile( + process.execPath, + ['--expose-gc', '--import', 'tsx', 'test/fixtures/mcp-memory.ts'], + { cwd: join(import.meta.dirname, '..'), timeout: 30_000 }, + (error, stdout, stderr) => { + if (error) reject(new Error(stderr.trim() || stdout.trim() || error.message)) + else resolve(Number(stdout.trim())) + }, + ) + }) +} + function createConfigCli(flag?: string) { const project = Cli.create('project').command('list', { options: z.object({ @@ -5788,6 +5803,10 @@ describe('fetch', () => { `) }) + test('completed JSON responses are released', async () => { + expect(await countRetainedMcpResponses()).toBe(0) + }) + test('POST /mcp with tools/list → returns registered tools', async () => { const cli = mcpCli() const { sessionId } = await initSession(cli) diff --git a/src/McpSource.test.ts b/src/McpSource.test.ts index a9dda45..3ef8dad 100644 --- a/src/McpSource.test.ts +++ b/src/McpSource.test.ts @@ -160,9 +160,6 @@ describe('remote MCP command sources', () => { "result": { "tools": [ { - "execution": { - "taskSupport": "forbidden", - }, "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -179,9 +176,6 @@ describe('remote MCP command sources', () => { }, { "description": "Search docs", - "execution": { - "taskSupport": "forbidden", - }, "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { diff --git a/test/fixtures/mcp-memory.ts b/test/fixtures/mcp-memory.ts new file mode 100644 index 0000000..30dd769 --- /dev/null +++ b/test/fixtures/mcp-memory.ts @@ -0,0 +1,50 @@ +import { Cli } from '../../src/index.js' + +const cli = Cli.create('memory-test', { + mcp: { tools: { discovery: 'direct' } }, + version: '1.0.0', +}).command('ping', { run: () => ({ pong: true }) }) + +let id = 0 + +function request(method: string, params: Record = {}) { + return cli.fetch( + new Request('http://localhost/mcp', { + body: JSON.stringify({ id: ++id, jsonrpc: '2.0', method, params }), + headers: { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + }, + method: 'POST', + }), + ) +} + +async function consume(method: string, params: Record = {}) { + const response = await request(method, params) + await response.text() +} + +async function weakResponse() { + const response = await request('tools/list') + await response.text() + return new WeakRef(response) +} + +await consume('initialize', { + capabilities: {}, + clientInfo: { name: 'memory-test', version: '1.0.0' }, + protocolVersion: '2025-03-26', +}) + +const responses: WeakRef[] = [] +for (let index = 0; index < 100; index++) responses.push(await weakResponse()) + +const gc = globalThis.gc +if (!gc) throw new Error('garbage collection is unavailable') +for (let index = 0; index < 5; index++) { + await new Promise(setImmediate) + gc() +} + +console.log(responses.filter((response) => response.deref()).length)