From 56be50f763547378e8963554ac46be7909dce4cc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 29 May 2026 05:18:32 +0100 Subject: [PATCH] fix(daemon): keep stdio list requests warm --- CHANGELOG.md | 1 + src/daemon/host.ts | 13 +++++++++- tests/daemon-host.test.ts | 42 ++++++++++++++++++++++++++++++++ tests/daemon.integration.test.ts | 15 ++++++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb09773..d19ccb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### CLI - Reconcile keep-alive daemon metadata with the responding process and serialize daemon startup across parallel clients, preventing duplicate orphaned daemons. (Issue #191, thanks @dtmsyi) +- Keep daemon-managed stdio servers warm across repeated `mcporter list` requests instead of treating non-interactive tool listing as a throwaway process. (Issue #188, thanks @robertoronderosjr) ## [0.11.3] - 2026-05-21 diff --git a/src/daemon/host.ts b/src/daemon/host.ts index b9ceb77..c59a6f2 100644 --- a/src/daemon/host.ts +++ b/src/daemon/host.ts @@ -343,6 +343,7 @@ async function processRequest( case 'listTools': { const params = request.params as ListToolsParams; ensureManaged(params.server, managedServers); + const definition = managedServers.get(params.server)!; const loggable = shouldLogServer(logContext, params.server); if (loggable) { logEvent(logContext, `listTools start server=${params.server}`); @@ -350,7 +351,7 @@ async function processRequest( try { const result = await runtime.listTools(params.server, { includeSchema: params.includeSchema, - autoAuthorize: params.autoAuthorize, + autoAuthorize: resolveDaemonListToolsAutoAuthorize(params, definition), allowCachedAuth: params.allowCachedAuth ?? true, }); markActivity(params.server, activity); @@ -476,6 +477,16 @@ async function processRequest( } } +function resolveDaemonListToolsAutoAuthorize( + params: ListToolsParams, + definition: ServerDefinition +): boolean | undefined { + if (params.autoAuthorize === false && definition.command.kind === 'stdio') { + return undefined; + } + return params.autoAuthorize; +} + export async function __testProcessRequest( rawPayload: string, runtime: Runtime, diff --git a/tests/daemon-host.test.ts b/tests/daemon-host.test.ts index a87a2ad..f1dd463 100644 --- a/tests/daemon-host.test.ts +++ b/tests/daemon-host.test.ts @@ -59,6 +59,40 @@ describe('daemon host request handling', () => { }); }); + it('keeps stdio keep-alive listTools requests reusable when callers disable auto auth', async () => { + const runtime = createRuntimeDouble(); + const managedServers = createManagedServers(); + + await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, { + id: 'list', + method: 'listTools', + params: { server: 'local', includeSchema: true, autoAuthorize: false, allowCachedAuth: true }, + }); + + expect(runtime.listTools).toHaveBeenCalledWith('local', { + includeSchema: true, + autoAuthorize: undefined, + allowCachedAuth: true, + }); + }); + + it('preserves HTTP listTools auto-auth opt out on daemon requests', async () => { + const runtime = createRuntimeDouble(); + const managedServers = createManagedServers(); + + await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, { + id: 'list', + method: 'listTools', + params: { server: 'oauth', includeSchema: true, autoAuthorize: false, allowCachedAuth: true }, + }); + + expect(runtime.listTools).toHaveBeenCalledWith('oauth', { + includeSchema: true, + autoAuthorize: false, + allowCachedAuth: true, + }); + }); + it('preserves explicit listTools cached-auth opt out on daemon requests', async () => { const runtime = createRuntimeDouble(); const managedServers = createManagedServers(); @@ -86,6 +120,14 @@ function createRuntimeDouble(): Pick { function createManagedServers(): Map { return new Map([ + [ + 'local', + { + name: 'local', + command: { kind: 'stdio', command: 'node', args: ['server.js'], cwd: '/tmp' }, + lifecycle: { mode: 'keep-alive' }, + }, + ], [ 'oauth', { diff --git a/tests/daemon.integration.test.ts b/tests/daemon.integration.test.ts index 85801bf..8293bca 100644 --- a/tests/daemon.integration.test.ts +++ b/tests/daemon.integration.test.ts @@ -77,8 +77,10 @@ describeDaemon('daemon keep-alive integration', () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-daemon-e2e-')); const scriptPath = path.join(tempDir, 'daemon-server.mjs'); const configPath = path.join(tempDir, 'mcporter.daemon.json'); + const launchLogPath = path.join(tempDir, 'launches.log'); const stdioServerSource = `import { randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; import { McpServer } from '${MCP_SERVER_MODULE}'; import { StdioServerTransport } from '${STDIO_SERVER_MODULE}'; import { z } from '${ZOD_MODULE}'; @@ -86,6 +88,10 @@ import { z } from '${ZOD_MODULE}'; const instanceId = randomUUID(); let counter = 0; +if (process.env.MCPORTER_TEST_LAUNCH_LOG) { + await fs.appendFile(process.env.MCPORTER_TEST_LAUNCH_LOG, instanceId + '\\n', 'utf8'); +} + const server = new McpServer({ name: 'daemon-e2e', version: '1.0.0' }); server.registerTool('next_value', { title: 'Next value', @@ -135,12 +141,16 @@ await new Promise((resolve) => { MCPORTER_DAEMON_LOG: '1', MCPORTER_DAEMON_LOG_PATH: logPath, MCPORTER_DAEMON_LOG_SERVERS: 'daemon-e2e', + MCPORTER_TEST_LAUNCH_LOG: launchLogPath, }; const cli = (args: string[]) => runCli(args, configPath, cliEnv); try { await cli(['daemon', 'stop']); + await cli(['list', 'daemon-e2e', '--json']); + await cli(['list', 'daemon-e2e', '--json']); + const first = await cli(['call', 'daemon-e2e.next_value', '--output', 'json']); const firstResult = parseCliJson(first.stdout); expect(firstResult.count).toBe(1); @@ -150,7 +160,12 @@ await new Promise((resolve) => { expect(secondResult.count).toBe(2); expect(secondResult.instanceId).toBe(firstResult.instanceId); + const launchLog = await readFileWithRetries(launchLogPath); + expect(launchLog.trim().split('\n')).toEqual([firstResult.instanceId]); + const logContents = await readFileWithRetries(logPath); + expect(logContents).toContain('listTools start server=daemon-e2e'); + expect(logContents).toContain('listTools success server=daemon-e2e'); expect(logContents).toContain('callTool start server=daemon-e2e tool=next_value'); expect(logContents).toContain('callTool success server=daemon-e2e tool=next_value'); } finally {