fix: support daemon idle timeout config

This commit is contained in:
Peter Steinberger 2026-05-20 17:34:42 +01:00
parent 31bbaa804f
commit a1201d1955
No known key found for this signature in database
10 changed files with 150 additions and 22 deletions

View File

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

View File

@ -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 runtimes 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 <server>` or `tokenCacheDir`, and the server should receive only a fresh bearer token at runtime. HTTP servers get `Authorization: Bearer <token>` 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.

View File

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

View File

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

View File

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

View File

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

View File

@ -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<void> {
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<void> {
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<void> => {
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<void> {
return;
}
handled = true;
lastDaemonActivityAt = Date.now();
activeDaemonRequests += 1;
void handleSocketRequest(
trimmed,
socket,
@ -132,7 +168,10 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
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<void> {
configMtimeMs,
});
let shuttingDown = false;
const shutdown = async (): Promise<void> => {
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);

View File

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

View File

@ -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');

View File

@ -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);
});
});