fix: add managed runtime and dedupe keep-alive restarts

This commit is contained in:
zm2231 2026-03-20 23:21:25 -04:00 committed by Peter Steinberger
parent 586c57d11b
commit d78a7954cb
No known key found for this signature in database
7 changed files with 245 additions and 28 deletions

View File

@ -236,9 +236,9 @@ console.log(result); // raw MCP envelope
## Compose Automations with the Runtime
```ts
import { createRuntime } from "mcporter";
import { createManagedRuntime } from "mcporter";
const runtime = await createRuntime();
const runtime = await createManagedRuntime();
const tools = await runtime.listTools("context7");
const result = await runtime.callTool("context7", "resolve-library-id", {
@ -249,16 +249,16 @@ console.log(result); // prints JSON/text automatically because the CLI pretty-pr
await runtime.close(); // shuts down transports and OAuth sessions
```
Reach for `createRuntime()` when you need connection pooling, repeated calls, or advanced options such as explicit timeouts and log streaming. The runtime reuses transports, refreshes OAuth tokens, and only tears everything down when you call `runtime.close()`.
Reach for `createManagedRuntime()` when you want the same keep-alive daemon behavior as the CLI, plus connection pooling, repeated calls, or advanced options such as explicit timeouts and log streaming. Use `createRuntime()` when you explicitly want direct runtime connections without daemon lifecycle management. Both runtimes reuse transports, refresh OAuth tokens, and only tear everything down when you call `runtime.close()`.
## Compose Tools in Code
The runtime API is built for agents and scripts, not just humans at a terminal.
```ts
import { createRuntime, createServerProxy } from "mcporter";
import { createManagedRuntime, createServerProxy } from "mcporter";
const runtime = await createRuntime();
const runtime = await createManagedRuntime();
const chrome = createServerProxy(runtime, "chrome-devtools");
const linear = createServerProxy(runtime, "linear");

View File

@ -14,10 +14,7 @@ import { handleList, printListHelp } from './cli/list-command.js';
import { logError, logInfo } from './cli/logger-context.js';
import { DEBUG_HANG, dumpActiveHandles, terminateChildProcesses } from './cli/runtime-debug.js';
import { resolveConfigPath } from './config.js';
import { DaemonClient } from './daemon/client.js';
import { createKeepAliveRuntime } from './daemon/runtime-wrapper.js';
import { isKeepAliveServer } from './lifecycle.js';
import { createRuntime } from './runtime.js';
import { createManagedRuntime, createRuntime } from './runtime.js';
export { handleAuth, printAuthHelp } from './cli/auth-command.js';
export { parseCallArguments } from './cli/call-arguments.js';
@ -110,22 +107,12 @@ export async function runCli(argv: string[]): Promise<void> {
return;
}
const baseRuntime = await createRuntime(runtimeOptionsWithPath);
const keepAliveServers = new Set(
baseRuntime
.getDefinitions()
.filter(isKeepAliveServer)
.map((entry) => entry.name)
);
const daemonClient =
keepAliveServers.size > 0
? new DaemonClient({
configPath: configResolution.path,
configExplicit: configResolution.explicit,
rootDir: rootOverride,
})
: null;
const runtime = createKeepAliveRuntime(baseRuntime, { daemonClient, keepAliveServers });
const runtime = await createManagedRuntime({
...runtimeOptions,
configPath: configPathResolved,
configExplicit: configResolution.explicit,
rootDir: rootOverride,
});
const inference = inferCommandRouting(command, args, runtime.getDefinitions());
if (inference.kind === 'abort') {

View File

@ -18,6 +18,8 @@ export function createKeepAliveRuntime(base: Runtime, options: KeepAliveRuntimeO
}
class KeepAliveRuntime implements Runtime {
private readonly restartPromises = new Map<string, Promise<void>>();
constructor(
private readonly base: Runtime,
private readonly daemon: DaemonClient,
@ -111,10 +113,26 @@ class KeepAliveRuntime implements Runtime {
// The daemon keeps STDIO transports warm; if a call fails due to a fatal error,
// force-close the cached server so the retry launches a fresh Chrome instance.
logDaemonRetry(server, operation, error);
await this.daemon.closeServer({ server }).catch(() => {});
await this.restartServer(server);
return action();
}
}
private async restartServer(server: string): Promise<void> {
const existing = this.restartPromises.get(server);
if (existing) {
await existing;
return;
}
const restart = this.daemon.closeServer({ server }).catch(() => {});
this.restartPromises.set(server, restart);
try {
await restart;
} finally {
this.restartPromises.delete(server);
}
}
}
const NON_FATAL_CODES = new Set([ErrorCode.InvalidRequest, ErrorCode.MethodNotFound, ErrorCode.InvalidParams]);

View File

@ -5,10 +5,11 @@ export { createCallResult, describeConnectionIssue, wrapCallResult } from './res
export type {
CallOptions,
ListToolsOptions,
ManagedRuntimeOptions,
Runtime,
RuntimeLogger,
ServerToolInfo,
} from './runtime.js';
export { callOnce, createRuntime } from './runtime.js';
export { callOnce, createManagedRuntime, createRuntime } from './runtime.js';
export type { ServerProxyOptions } from './server-proxy.js';
export { createServerProxy } from './server-proxy.js';

View File

@ -1,7 +1,10 @@
import { createRequire } from 'node:module';
import type { CallToolRequest, ListResourcesRequest } from '@modelcontextprotocol/sdk/types.js';
import { loadServerDefinitions, type ServerDefinition } from './config.js';
import { loadServerDefinitions, resolveConfigPath, type ServerDefinition } from './config.js';
import { DaemonClient } from './daemon/client.js';
import { createKeepAliveRuntime } from './daemon/runtime-wrapper.js';
import { isKeepAliveServer } from './lifecycle.js';
import { createPrefixedConsoleLogger, type Logger, type LogLevel, resolveLogLevelFromEnv } from './logging.js';
import { closeTransportAndWait } from './runtime-process-utils.js';
import './sdk-patches.js';
@ -34,6 +37,10 @@ export interface RuntimeOptions {
readonly oauthTimeoutMs?: number;
}
export interface ManagedRuntimeOptions extends RuntimeOptions {
readonly configExplicit?: boolean;
}
export type RuntimeLogger = Logger;
export interface CallOptions {
@ -86,6 +93,44 @@ export async function createRuntime(options: RuntimeOptions = {}): Promise<Runti
return runtime;
}
// createManagedRuntime mirrors the CLI's keep-alive behavior for library consumers.
export async function createManagedRuntime(options: ManagedRuntimeOptions = {}): Promise<Runtime> {
const rootDir = options.rootDir ?? process.cwd();
const configResolution = resolveConfigPath(options.configPath, rootDir);
const configPath = options.configPath ?? configResolution.path;
const baseRuntime = await createRuntime({
...options,
configPath: options.servers
? options.configPath
: (options.configExplicit ?? configResolution.explicit)
? configPath
: undefined,
rootDir,
});
if (options.servers) {
return baseRuntime;
}
const keepAliveServers = new Set(
baseRuntime
.getDefinitions()
.filter(isKeepAliveServer)
.map((entry) => entry.name)
);
if (keepAliveServers.size === 0) {
return baseRuntime;
}
const daemonClient = new DaemonClient({
configPath,
configExplicit: options.configExplicit ?? configResolution.explicit,
rootDir,
});
return createKeepAliveRuntime(baseRuntime, { daemonClient, keepAliveServers });
}
// callOnce connects to a server, invokes a single tool, and disposes the connection immediately.
export async function callOnce(params: {
server: string;

View File

@ -131,6 +131,39 @@ describe('createKeepAliveRuntime', () => {
logSpy.mockRestore();
});
it('deduplicates concurrent restarts for the same server', async () => {
const runtime = new FakeRuntime(definitions);
let releaseClose!: () => void;
const closePromise = new Promise<void>((resolve) => {
releaseClose = resolve;
});
const daemon = {
callTool: vi
.fn()
.mockRejectedValueOnce(new Error('transport hung up'))
.mockRejectedValueOnce(new Error('transport hung up'))
.mockResolvedValue('daemon-call'),
closeServer: vi.fn().mockImplementation(async () => {
await closePromise;
}),
listTools: vi.fn(),
listResources: vi.fn(),
};
const keepAliveRuntime = createKeepAliveRuntime(runtime as unknown as Runtime, {
daemonClient: daemon as never,
keepAliveServers: new Set(['alpha']),
});
const first = keepAliveRuntime.callTool('alpha', 'ping', {});
const second = keepAliveRuntime.callTool('alpha', 'pong', {});
await Promise.resolve();
expect(daemon.closeServer).toHaveBeenCalledTimes(1);
releaseClose();
await expect(Promise.all([first, second])).resolves.toEqual(['daemon-call', 'daemon-call']);
expect(daemon.closeServer).toHaveBeenCalledTimes(1);
});
it('does not restart daemon servers for InvalidParams errors', async () => {
const runtime = new FakeRuntime(definitions);
const error = new McpError(ErrorCode.InvalidParams, 'Tool not found');

View File

@ -0,0 +1,133 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Runtime } from '../src/runtime.js';
const daemonClientFactory = vi.fn();
const createKeepAliveRuntimeMock = vi.fn();
const isKeepAliveServerMock = vi.fn(
(definition: { lifecycle?: { mode?: string } }) => definition.lifecycle?.mode === 'keep-alive'
);
const resolveConfigPathMock = vi.fn((configPath?: string, rootDir?: string) => ({
path: configPath ?? `${rootDir ?? process.cwd()}/mcporter.json`,
explicit: Boolean(configPath),
}));
const loadServerDefinitionsMock = vi.fn();
vi.mock('../src/config.js', () => ({
loadServerDefinitions: loadServerDefinitionsMock,
resolveConfigPath: resolveConfigPathMock,
}));
vi.mock('../src/daemon/client.js', () => ({
DaemonClient: class {
constructor(options: unknown) {
daemonClientFactory(options);
}
},
}));
vi.mock('../src/daemon/runtime-wrapper.js', () => ({
createKeepAliveRuntime: createKeepAliveRuntimeMock,
}));
vi.mock('../src/lifecycle.js', () => ({
isKeepAliveServer: isKeepAliveServerMock,
}));
vi.mock('../src/runtime/transport.js', () => ({
createClientContext: vi.fn(),
}));
vi.mock('../src/sdk-patches.js', () => ({}));
async function loadRuntimeModule() {
return await import('../src/runtime.js');
}
describe('createManagedRuntime', () => {
beforeEach(() => {
loadServerDefinitionsMock.mockReset();
daemonClientFactory.mockReset();
createKeepAliveRuntimeMock.mockReset();
isKeepAliveServerMock.mockClear();
resolveConfigPathMock.mockClear();
});
it('wraps config-backed keep-alive runtimes with the daemon client', async () => {
loadServerDefinitionsMock.mockResolvedValue([
{
name: 'chrome-devtools',
command: { kind: 'stdio', command: 'node', args: [] },
lifecycle: { mode: 'keep-alive' },
},
{
name: 'context7',
command: { kind: 'http', url: new URL('https://example.com') },
},
]);
const wrappedRuntime = { wrapped: true } as unknown as Runtime;
createKeepAliveRuntimeMock.mockReturnValue(wrappedRuntime);
const { createManagedRuntime } = await loadRuntimeModule();
const runtime = await createManagedRuntime({
configPath: '/tmp/custom.json',
configExplicit: true,
rootDir: '/repo',
});
expect(resolveConfigPathMock).toHaveBeenCalledWith('/tmp/custom.json', '/repo');
expect(loadServerDefinitionsMock).toHaveBeenCalledWith({
configPath: '/tmp/custom.json',
rootDir: '/repo',
});
expect(daemonClientFactory).toHaveBeenCalledWith({
configPath: '/tmp/custom.json',
configExplicit: true,
rootDir: '/repo',
});
expect(createKeepAliveRuntimeMock).toHaveBeenCalledWith(
expect.objectContaining({
getDefinitions: expect.any(Function),
}),
expect.objectContaining({
keepAliveServers: new Set(['chrome-devtools']),
})
);
expect(runtime).toBe(wrappedRuntime);
});
it('returns a plain runtime when explicit servers are provided', async () => {
const { createManagedRuntime } = await loadRuntimeModule();
const runtime = await createManagedRuntime({
servers: [
{
name: 'chrome-devtools',
command: { kind: 'stdio', command: 'node', args: [] },
lifecycle: { mode: 'keep-alive' },
},
] as never,
rootDir: '/repo',
});
expect(loadServerDefinitionsMock).not.toHaveBeenCalled();
expect(daemonClientFactory).not.toHaveBeenCalled();
expect(createKeepAliveRuntimeMock).not.toHaveBeenCalled();
expect(runtime.listServers()).toEqual(['chrome-devtools']);
});
it('returns a plain runtime when there are no keep-alive servers', async () => {
loadServerDefinitionsMock.mockResolvedValue([
{
name: 'context7',
command: { kind: 'http', url: new URL('https://example.com') },
},
]);
const { createManagedRuntime } = await loadRuntimeModule();
const runtime = await createManagedRuntime({ rootDir: '/repo' });
expect(daemonClientFactory).not.toHaveBeenCalled();
expect(createKeepAliveRuntimeMock).not.toHaveBeenCalled();
expect(runtime.listServers()).toEqual(['context7']);
});
});