fix(daemon): keep stdio list requests warm
Some checks failed
CI / build (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (windows-latest) (push) Has been cancelled

This commit is contained in:
Peter Steinberger 2026-05-29 05:18:32 +01:00
parent 815016a008
commit 56be50f763
No known key found for this signature in database
4 changed files with 70 additions and 1 deletions

View File

@ -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

View File

@ -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,

View File

@ -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<Runtime, 'callTool' | 'listTools'> {
function createManagedServers(): Map<string, ServerDefinition> {
return new Map([
[
'local',
{
name: 'local',
command: { kind: 'stdio', command: 'node', args: ['server.js'], cwd: '/tmp' },
lifecycle: { mode: 'keep-alive' },
},
],
[
'oauth',
{

View File

@ -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 {