From a1201d19558518ce92ed54bc99960ef599447ec5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 20 May 2026 17:34:42 +0100 Subject: [PATCH] fix: support daemon idle timeout config --- CHANGELOG.md | 1 + docs/config.md | 2 + docs/daemon.md | 2 +- mcporter.schema.json | 12 ++++++ src/config-schema.ts | 12 ++++++ src/config.ts | 17 ++++++++ src/daemon/host.ts | 64 ++++++++++++++++++++---------- src/daemon/request-utils.ts | 21 ++++++++++ tests/config-normalize.test.ts | 23 ++++++++++- tests/daemon-request-utils.test.ts | 18 +++++++++ 10 files changed, 150 insertions(+), 22 deletions(-) create mode 100644 tests/daemon-request-utils.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 35102ea..6ea8e12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Make `generate-cli --bundle` artifacts deterministic by removing bundle-only paths/timestamps from embedded metadata and sorting generated tool/schema output. (Issue #180, thanks @imroc) - Let daemon-managed OAuth servers reuse cached credentials for tool calls and tool listing after token expiry. (PR #182 / issue #181, thanks @bradhallett) - Avoid restarting browser OAuth when an already-connected server has a still-valid cached access token. (Issue #179, thanks @jaigew and @StanAngeloff) +- Add the documented top-level `daemonIdleTimeoutMs` config to shut down inactive keep-alive daemons. (Issue #174, thanks @jarek083) ## [0.11.1] - 2026-05-14 diff --git a/docs/config.md b/docs/config.md index d73134b..2e48484 100644 --- a/docs/config.md +++ b/docs/config.md @@ -241,6 +241,8 @@ Server definition fields (subset of what `RawEntrySchema` accepts): mcporter normalizes headers to include `Accept: application/json, text/event-stream` automatically, matching the runtime’s streaming expectations. String-valued config fields support `${VAR}` and `${VAR:-fallback}` placeholders. Secret-bearing `headers`, `env`, and bearer-token placeholders are preserved in `config get`/`config list` output and resolved only when the transport runs; `*Env` fields name environment variables and are not expanded. +Top-level `daemonIdleTimeoutMs` (or `daemon_idle_timeout_ms`) shuts down the keep-alive daemon after that many milliseconds without daemon requests. Per-server `lifecycle.idleTimeoutMs` still controls when individual keep-alive transports are closed. + ### Refreshable Bearer Tokens Use `auth: "refreshable_bearer"` when you already seeded OAuth tokens with `mcporter vault set ` or `tokenCacheDir`, and the server should receive only a fresh bearer token at runtime. HTTP servers get `Authorization: Bearer ` when no authorization header is already configured. STDIO servers require `refresh.accessTokenEnv`; mcporter refreshes before spawning the process and injects that env var with the raw access token. diff --git a/docs/daemon.md b/docs/daemon.md index e0db325..32afc4e 100644 --- a/docs/daemon.md +++ b/docs/daemon.md @@ -49,7 +49,7 @@ read_when: - **Auto start:** First call requiring the daemon triggers a lightweight bootstrap (fork/exec via `child_process.spawn` inside the CLI). We ensure the original command waits for the socket to become available (with a short timeout). - **macOS Bun binaries:** Homebrew/Bun-compiled binaries wrap the detached child launch with `nohup` so the background daemon survives the parent CLI exit on macOS 26. - **Auto restart:** The client shim treats `ECONNREFUSED`/broken pipe as a signal that the daemon died. It retries once by re-launching the daemon before surfacing the error. -- **Idle timeout:** Each keep-alive server can specify `idleTimeoutMs` (default `null` = never). The daemon tracks last activity timestamps and auto-closes transports (and associated external processes) after the idle window. A global `daemonIdleTimeoutMs` can shut down the entire daemon after long inactivity. +- **Idle timeout:** Each keep-alive server can specify `idleTimeoutMs` (default `null` = never). The daemon tracks last activity timestamps and auto-closes transports (and associated external processes) after the idle window. A top-level config `daemonIdleTimeoutMs` can shut down the entire daemon after long inactivity. - **Logging:** Daemon writes structured logs under the daemon runtime directory plus per-server logs for STDIO stderr so users can debug crashing servers. ## Agent Isolation diff --git a/mcporter.schema.json b/mcporter.schema.json index e9e6f02..822ce6a 100644 --- a/mcporter.schema.json +++ b/mcporter.schema.json @@ -359,6 +359,18 @@ }, "description": "Map of server names to their configurations" }, + "daemonIdleTimeoutMs": { + "description": "Idle timeout in milliseconds before shutting down an inactive daemon", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "daemon_idle_timeout_ms": { + "description": "Idle timeout in milliseconds before shutting down an inactive daemon", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, "imports": { "description": "Editor configurations to import servers from. Omit to use defaults, or set to [] to disable imports", "type": "array", diff --git a/src/config-schema.ts b/src/config-schema.ts index d68063f..4669db5 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -175,6 +175,18 @@ export const RawEntrySchema = z export const RawConfigSchema = z .object({ mcpServers: z.record(z.string(), RawEntrySchema).describe('Map of server names to their configurations'), + daemonIdleTimeoutMs: z + .number() + .int() + .positive() + .optional() + .describe('Idle timeout in milliseconds before shutting down an inactive daemon'), + daemon_idle_timeout_ms: z + .number() + .int() + .positive() + .optional() + .describe('Idle timeout in milliseconds before shutting down an inactive daemon'), imports: z .array(ImportKindSchema) .optional() diff --git a/src/config.ts b/src/config.ts index bc03b7f..3c98750 100644 --- a/src/config.ts +++ b/src/config.ts @@ -105,6 +105,23 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr return servers; } +export interface DaemonConfig { + readonly idleTimeoutMs?: number; +} + +export async function loadDaemonConfig(options: LoadConfigOptions = {}): Promise { + const rootDir = options.rootDir ?? process.cwd(); + const layers = await loadConfigLayers(options, rootDir); + let idleTimeoutMs: number | undefined; + for (const layer of layers) { + const raw = layer.config.daemonIdleTimeoutMs ?? layer.config.daemon_idle_timeout_ms; + if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) { + idleTimeoutMs = Math.trunc(raw); + } + } + return { idleTimeoutMs }; +} + export async function loadRawConfig( options: LoadConfigOptions = {} ): Promise<{ config: RawConfig; path: string; explicit: boolean }> { diff --git a/src/daemon/host.ts b/src/daemon/host.ts index 86d7245..b9ceb77 100644 --- a/src/daemon/host.ts +++ b/src/daemon/host.ts @@ -1,7 +1,7 @@ import fs from 'node:fs/promises'; import net from 'node:net'; import path from 'node:path'; -import type { ServerDefinition } from '../config.js'; +import { loadDaemonConfig, type ServerDefinition } from '../config.js'; import { writeJsonFile } from '../fs-json.js'; import { isKeepAliveServer } from '../lifecycle.js'; import { createRuntime, type Runtime } from '../runtime.js'; @@ -26,9 +26,11 @@ import type { } from './protocol.js'; import { buildErrorResponse, + daemonIdleWatcherInterval, ensureManaged, evictIdleServers, markActivity, + shouldShutdownDaemonForIdle, type ServerActivity, } from './request-utils.js'; @@ -48,6 +50,10 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise { configPath: options.configExplicit ? options.configPath : undefined, rootDir: options.rootDir, }); + const daemonConfig = await loadDaemonConfig({ + configPath: options.configExplicit ? options.configPath : undefined, + rootDir: options.rootDir, + }); const runtime = await createRuntime({ configPath: options.configExplicit ? options.configPath : undefined, rootDir: options.rootDir, @@ -86,9 +92,37 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise { activity.set(definition.name, { connected: false }); } - const idleWatcher = setInterval(() => { - void evictIdleServers(runtime, managedServers, activity); - }, 30_000); + let shuttingDown = false; + let idleWatcher: NodeJS.Timeout | undefined; + const shutdown = async (): Promise => { + if (shuttingDown) { + return; + } + shuttingDown = true; + logEvent(logContext, 'Shutting down daemon host.'); + if (idleWatcher) { + clearInterval(idleWatcher); + } + server.close(); + await runtime.close().catch(() => {}); + await disposeLogContext(logContext).catch(() => {}); + await cleanupArtifacts(options); + process.exit(0); + }; + + let lastDaemonActivityAt = Date.now(); + let activeDaemonRequests = 0; + idleWatcher = setInterval(() => { + void (async () => { + await evictIdleServers(runtime, managedServers, activity); + if ( + shouldShutdownDaemonForIdle(lastDaemonActivityAt, Date.now(), daemonConfig.idleTimeoutMs, activeDaemonRequests) + ) { + logEvent(logContext, 'Daemon idle timeout reached.'); + await shutdown(); + } + })(); + }, daemonIdleWatcherInterval(daemonConfig.idleTimeoutMs)); idleWatcher.unref(); logEvent(logContext, 'Daemon host started.'); @@ -115,6 +149,8 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise { return; } handled = true; + lastDaemonActivityAt = Date.now(); + activeDaemonRequests += 1; void handleSocketRequest( trimmed, socket, @@ -132,7 +168,10 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise { logContext, shutdown, parsedRequest - ); + ).finally(() => { + activeDaemonRequests -= 1; + lastDaemonActivityAt = Date.now(); + }); }; socket.on('data', (chunk) => { buffer += chunk; @@ -167,21 +206,6 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise { configMtimeMs, }); - let shuttingDown = false; - const shutdown = async (): Promise => { - if (shuttingDown) { - return; - } - shuttingDown = true; - logEvent(logContext, 'Shutting down daemon host.'); - clearInterval(idleWatcher); - server.close(); - await runtime.close().catch(() => {}); - await disposeLogContext(logContext).catch(() => {}); - await cleanupArtifacts(options); - process.exit(0); - }; - process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown); process.once('SIGQUIT', shutdown); diff --git a/src/daemon/request-utils.ts b/src/daemon/request-utils.ts index 552312a..1f4c1f2 100644 --- a/src/daemon/request-utils.ts +++ b/src/daemon/request-utils.ts @@ -49,6 +49,27 @@ export async function evictIdleServers( ); } +export function shouldShutdownDaemonForIdle( + lastActivityAt: number, + now: number, + idleTimeoutMs: number | undefined, + activeRequests = 0 +): boolean { + return ( + activeRequests <= 0 && + typeof idleTimeoutMs === 'number' && + idleTimeoutMs > 0 && + now - lastActivityAt >= idleTimeoutMs + ); +} + +export function daemonIdleWatcherInterval(idleTimeoutMs: number | undefined): number { + if (!idleTimeoutMs) { + return 30_000; + } + return Math.min(30_000, Math.max(100, Math.floor(idleTimeoutMs / 2))); +} + export function buildErrorResponse(id: string, code: string, error?: unknown): DaemonResponse { let message = code; if (error instanceof Error) { diff --git a/tests/config-normalize.test.ts b/tests/config-normalize.test.ts index f7bf35b..3f931e8 100644 --- a/tests/config-normalize.test.ts +++ b/tests/config-normalize.test.ts @@ -2,7 +2,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { loadServerDefinitions } from '../src/config.js'; +import { loadDaemonConfig, loadServerDefinitions } from '../src/config.js'; const TEMP_DIR = path.join(os.tmpdir(), 'mcporter-config-test'); @@ -159,6 +159,27 @@ describe('config normalization', () => { expect(servers.find((entry) => entry.name === 'defaulted')?.httpFetch).toBe('default'); }); + it('loads daemon idle timeout from config layers', async () => { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-daemon-idle-')); + const configDir = path.join(rootDir, 'config'); + const configPath = path.join(configDir, 'mcporter.json'); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify( + { + daemonIdleTimeoutMs: 12_345, + mcpServers: {}, + }, + null, + 2 + ), + 'utf8' + ); + + await expect(loadDaemonConfig({ rootDir })).resolves.toEqual({ idleTimeoutMs: 12_345 }); + }); + it('normalizes refreshable bearer config for stdio servers', async () => { await fs.mkdir(TEMP_DIR, { recursive: true }); const configPath = path.join(TEMP_DIR, 'mcporter-refreshable-stdio.json'); diff --git a/tests/daemon-request-utils.test.ts b/tests/daemon-request-utils.test.ts new file mode 100644 index 0000000..3455afb --- /dev/null +++ b/tests/daemon-request-utils.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { daemonIdleWatcherInterval, shouldShutdownDaemonForIdle } from '../src/daemon/request-utils.js'; + +describe('daemon idle helpers', () => { + it('detects global daemon idle timeout', () => { + expect(shouldShutdownDaemonForIdle(1_000, 4_000, 3_000)).toBe(true); + expect(shouldShutdownDaemonForIdle(1_000, 3_999, 3_000)).toBe(false); + expect(shouldShutdownDaemonForIdle(1_000, 10_000, undefined)).toBe(false); + expect(shouldShutdownDaemonForIdle(1_000, 10_000, 3_000, 1)).toBe(false); + }); + + it('uses short watcher intervals for short configured timeouts', () => { + expect(daemonIdleWatcherInterval(undefined)).toBe(30_000); + expect(daemonIdleWatcherInterval(150)).toBe(100); + expect(daemonIdleWatcherInterval(2_000)).toBe(1_000); + expect(daemonIdleWatcherInterval(120_000)).toBe(30_000); + }); +});