fix: support daemon idle timeout config
This commit is contained in:
parent
31bbaa804f
commit
a1201d1955
@ -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)
|
- 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)
|
- 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)
|
- 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
|
## [0.11.1] - 2026-05-14
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
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.
|
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
|
### 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.
|
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.
|
||||||
|
|||||||
@ -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).
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
## Agent Isolation
|
||||||
|
|||||||
@ -359,6 +359,18 @@
|
|||||||
},
|
},
|
||||||
"description": "Map of server names to their configurations"
|
"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": {
|
"imports": {
|
||||||
"description": "Editor configurations to import servers from. Omit to use defaults, or set to [] to disable imports",
|
"description": "Editor configurations to import servers from. Omit to use defaults, or set to [] to disable imports",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
|||||||
@ -175,6 +175,18 @@ export const RawEntrySchema = z
|
|||||||
export const RawConfigSchema = z
|
export const RawConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
mcpServers: z.record(z.string(), RawEntrySchema).describe('Map of server names to their configurations'),
|
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
|
imports: z
|
||||||
.array(ImportKindSchema)
|
.array(ImportKindSchema)
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
@ -105,6 +105,23 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
|
|||||||
return servers;
|
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(
|
export async function loadRawConfig(
|
||||||
options: LoadConfigOptions = {}
|
options: LoadConfigOptions = {}
|
||||||
): Promise<{ config: RawConfig; path: string; explicit: boolean }> {
|
): Promise<{ config: RawConfig; path: string; explicit: boolean }> {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import path from 'node:path';
|
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 { writeJsonFile } from '../fs-json.js';
|
||||||
import { isKeepAliveServer } from '../lifecycle.js';
|
import { isKeepAliveServer } from '../lifecycle.js';
|
||||||
import { createRuntime, type Runtime } from '../runtime.js';
|
import { createRuntime, type Runtime } from '../runtime.js';
|
||||||
@ -26,9 +26,11 @@ import type {
|
|||||||
} from './protocol.js';
|
} from './protocol.js';
|
||||||
import {
|
import {
|
||||||
buildErrorResponse,
|
buildErrorResponse,
|
||||||
|
daemonIdleWatcherInterval,
|
||||||
ensureManaged,
|
ensureManaged,
|
||||||
evictIdleServers,
|
evictIdleServers,
|
||||||
markActivity,
|
markActivity,
|
||||||
|
shouldShutdownDaemonForIdle,
|
||||||
type ServerActivity,
|
type ServerActivity,
|
||||||
} from './request-utils.js';
|
} from './request-utils.js';
|
||||||
|
|
||||||
@ -48,6 +50,10 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
|
|||||||
configPath: options.configExplicit ? options.configPath : undefined,
|
configPath: options.configExplicit ? options.configPath : undefined,
|
||||||
rootDir: options.rootDir,
|
rootDir: options.rootDir,
|
||||||
});
|
});
|
||||||
|
const daemonConfig = await loadDaemonConfig({
|
||||||
|
configPath: options.configExplicit ? options.configPath : undefined,
|
||||||
|
rootDir: options.rootDir,
|
||||||
|
});
|
||||||
const runtime = await createRuntime({
|
const runtime = await createRuntime({
|
||||||
configPath: options.configExplicit ? options.configPath : undefined,
|
configPath: options.configExplicit ? options.configPath : undefined,
|
||||||
rootDir: options.rootDir,
|
rootDir: options.rootDir,
|
||||||
@ -86,9 +92,37 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
|
|||||||
activity.set(definition.name, { connected: false });
|
activity.set(definition.name, { connected: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
const idleWatcher = setInterval(() => {
|
let shuttingDown = false;
|
||||||
void evictIdleServers(runtime, managedServers, activity);
|
let idleWatcher: NodeJS.Timeout | undefined;
|
||||||
}, 30_000);
|
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();
|
idleWatcher.unref();
|
||||||
|
|
||||||
logEvent(logContext, 'Daemon host started.');
|
logEvent(logContext, 'Daemon host started.');
|
||||||
@ -115,6 +149,8 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handled = true;
|
handled = true;
|
||||||
|
lastDaemonActivityAt = Date.now();
|
||||||
|
activeDaemonRequests += 1;
|
||||||
void handleSocketRequest(
|
void handleSocketRequest(
|
||||||
trimmed,
|
trimmed,
|
||||||
socket,
|
socket,
|
||||||
@ -132,7 +168,10 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
|
|||||||
logContext,
|
logContext,
|
||||||
shutdown,
|
shutdown,
|
||||||
parsedRequest
|
parsedRequest
|
||||||
);
|
).finally(() => {
|
||||||
|
activeDaemonRequests -= 1;
|
||||||
|
lastDaemonActivityAt = Date.now();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
socket.on('data', (chunk) => {
|
socket.on('data', (chunk) => {
|
||||||
buffer += chunk;
|
buffer += chunk;
|
||||||
@ -167,21 +206,6 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
|
|||||||
configMtimeMs,
|
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('SIGINT', shutdown);
|
||||||
process.once('SIGTERM', shutdown);
|
process.once('SIGTERM', shutdown);
|
||||||
process.once('SIGQUIT', shutdown);
|
process.once('SIGQUIT', shutdown);
|
||||||
|
|||||||
@ -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 {
|
export function buildErrorResponse(id: string, code: string, error?: unknown): DaemonResponse {
|
||||||
let message = code;
|
let message = code;
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import fs from 'node:fs/promises';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { describe, expect, it } from 'vitest';
|
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');
|
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');
|
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 () => {
|
it('normalizes refreshable bearer config for stdio servers', async () => {
|
||||||
await fs.mkdir(TEMP_DIR, { recursive: true });
|
await fs.mkdir(TEMP_DIR, { recursive: true });
|
||||||
const configPath = path.join(TEMP_DIR, 'mcporter-refreshable-stdio.json');
|
const configPath = path.join(TEMP_DIR, 'mcporter-refreshable-stdio.json');
|
||||||
|
|||||||
18
tests/daemon-request-utils.test.ts
Normal file
18
tests/daemon-request-utils.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user