diff --git a/README.md b/README.md index be82bb6..1fa2dc0 100644 --- a/README.md +++ b/README.md @@ -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"); diff --git a/src/cli.ts b/src/cli.ts index ae623fa..a64c743 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 { 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') { diff --git a/src/daemon/runtime-wrapper.ts b/src/daemon/runtime-wrapper.ts index eec6f24..b860be3 100644 --- a/src/daemon/runtime-wrapper.ts +++ b/src/daemon/runtime-wrapper.ts @@ -18,6 +18,8 @@ export function createKeepAliveRuntime(base: Runtime, options: KeepAliveRuntimeO } class KeepAliveRuntime implements Runtime { + private readonly restartPromises = new Map>(); + 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 { + 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]); diff --git a/src/index.ts b/src/index.ts index 622361b..2b541d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/runtime.ts b/src/runtime.ts index d7a49df..5f44e8a 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -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 { + 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; diff --git a/tests/keep-alive-runtime.test.ts b/tests/keep-alive-runtime.test.ts index b514abf..0b7d946 100644 --- a/tests/keep-alive-runtime.test.ts +++ b/tests/keep-alive-runtime.test.ts @@ -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((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'); diff --git a/tests/runtime-managed.test.ts b/tests/runtime-managed.test.ts new file mode 100644 index 0000000..26b040b --- /dev/null +++ b/tests/runtime-managed.test.ts @@ -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']); + }); +});