fix: dedupe keep-alive daemon restarts (#125) (thanks @zm2231)

This commit is contained in:
Peter Steinberger 2026-03-28 21:04:56 +00:00
parent b691380914
commit b554ab399b
No known key found for this signature in database
7 changed files with 28 additions and 236 deletions

View File

@ -3,6 +3,7 @@
## [Unreleased]
### CLI
- Deduplicate concurrent keep-alive daemon restarts per server so repeated fatal errors only force-close the cached daemon transport once before retrying. (PR #125, thanks @zm2231)
- Keep `mcporter call --output json` parseable by emitting valid JSON even when the command falls back to raw output. (PR #128, thanks @armanddp)
- Ignore static `Authorization` headers once OAuth is active so imported editor configs cannot override fresh OAuth tokens. (PR #123, thanks @ahonn)
- Preserve full JSON/error payloads when `data` is just one field instead of collapsing the response to `data` alone. (PR #106, thanks @AielloChan)

View File

@ -236,9 +236,9 @@ console.log(result); // raw MCP envelope
## Compose Automations with the Runtime
```ts
import { createManagedRuntime } from "mcporter";
import { createRuntime } from "mcporter";
const runtime = await createManagedRuntime();
const runtime = await createRuntime();
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 `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()`.
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()`.
## Compose Tools in Code
The runtime API is built for agents and scripts, not just humans at a terminal.
```ts
import { createManagedRuntime, createServerProxy } from "mcporter";
import { createRuntime, createServerProxy } from "mcporter";
const runtime = await createManagedRuntime();
const runtime = await createRuntime();
const chrome = createServerProxy(runtime, "chrome-devtools");
const linear = createServerProxy(runtime, "linear");

View File

@ -14,7 +14,10 @@ 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 { createManagedRuntime, createRuntime } from './runtime.js';
import { DaemonClient } from './daemon/client.js';
import { createKeepAliveRuntime } from './daemon/runtime-wrapper.js';
import { isKeepAliveServer } from './lifecycle.js';
import { createRuntime } from './runtime.js';
export { handleAuth, printAuthHelp } from './cli/auth-command.js';
export { parseCallArguments } from './cli/call-arguments.js';
@ -107,12 +110,22 @@ export async function runCli(argv: string[]): Promise<void> {
return;
}
const runtime = await createManagedRuntime({
...runtimeOptions,
configPath: configPathResolved,
configExplicit: configResolution.explicit,
rootDir: rootOverride,
});
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 inference = inferCommandRouting(command, args, runtime.getDefinitions());
if (inference.kind === 'abort') {

View File

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

View File

@ -1,10 +1,7 @@
import { createRequire } from 'node:module';
import type { CallToolRequest, ListResourcesRequest } from '@modelcontextprotocol/sdk/types.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 { loadServerDefinitions, type ServerDefinition } from './config.js';
import { createPrefixedConsoleLogger, type Logger, type LogLevel, resolveLogLevelFromEnv } from './logging.js';
import { closeTransportAndWait } from './runtime-process-utils.js';
import './sdk-patches.js';
@ -37,10 +34,6 @@ export interface RuntimeOptions {
readonly oauthTimeoutMs?: number;
}
export interface ManagedRuntimeOptions extends RuntimeOptions {
readonly configExplicit?: boolean;
}
export type RuntimeLogger = Logger;
export interface CallOptions {
@ -93,44 +86,6 @@ 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

@ -1,43 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
process.env.MCPORTER_DISABLE_AUTORUN = '1';
process.env.MCPORTER_NO_FORCE_EXIT = '1';
const runtime = {
getDefinitions: vi.fn(() => []),
close: vi.fn(async () => undefined),
};
const createManagedRuntimeMock = vi.fn(async () => runtime);
const createRuntimeMock = vi.fn(async () => runtime);
const handleListMock = vi.fn(async () => undefined);
vi.mock('../src/runtime.js', async () => {
const actual = await vi.importActual<typeof import('../src/runtime.js')>('../src/runtime.js');
return {
...actual,
createManagedRuntime: createManagedRuntimeMock,
createRuntime: createRuntimeMock,
};
});
vi.mock('../src/cli/list-command.js', async () => {
const actual = await vi.importActual<typeof import('../src/cli/list-command.js')>('../src/cli/list-command.js');
return {
...actual,
handleList: handleListMock,
};
});
describe('mcporter CLI managed runtime wiring', () => {
it('uses createManagedRuntime for normal CLI commands', async () => {
const { runCli } = await import('../src/cli.js');
await runCli(['list']);
expect(createManagedRuntimeMock).toHaveBeenCalledTimes(1);
expect(createRuntimeMock).not.toHaveBeenCalled();
expect(handleListMock).toHaveBeenCalledWith(runtime, []);
expect(runtime.close).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,133 +0,0 @@
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']);
});
});