Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ export async function runCli(argv: string[]): Promise<void> {
return;
}
const { handleAuth: importedHandleAuth } = await import('./cli/auth-command.js');
await importedHandleAuth(runtime, resolvedArgs);
await importedHandleAuth(runtime, resolvedArgs, { oauthTimeoutMs: runtimeOptionsWithPath.oauthTimeoutMs });
return;
}

Expand Down Expand Up @@ -441,7 +441,7 @@ async function invokeAuthCommand(runtimeOptions: RuntimeOptions, args: string[])
]);
const runtime = await createRuntime(runtimeOptions);
try {
await importedHandleAuth(runtime, args);
await importedHandleAuth(runtime, args, { oauthTimeoutMs: runtimeOptions.oauthTimeoutMs });
} finally {
await runtime.close().catch(() => {});
}
Expand Down Expand Up @@ -630,6 +630,7 @@ function createDaemonOnlyRuntime(daemonClient: import('./daemon/client.js').Daem
autoAuthorize: options?.autoAuthorize,
allowCachedAuth: options?.allowCachedAuth,
disableOAuth: options?.disableOAuth,
timeoutMs: options?.timeoutMs,
})) as Awaited<ReturnType<Runtime['listTools']>>,
callTool: (server, toolName, options) =>
daemonClient.callTool({
Expand Down
14 changes: 12 additions & 2 deletions src/cli/auth-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { OAuthAuthorizationRequest, OAuthSessionOptions } from '../oauth.js
import { analyzeConnectionError } from '../error-classifier.js';
import { clearOAuthCaches } from '../oauth-persistence.js';
import type { createRuntime } from '../runtime.js';
import { isOAuthFlowError } from '../runtime/oauth.js';
import { isOAuthFlowError, resolveOAuthTimeoutFromEnv } from '../runtime/oauth.js';
import type { EphemeralServerSpec } from './adhoc-server.js';
import { extractEphemeralServerFlags } from './ephemeral-flags.js';
import { persistPreparedEphemeralServer, prepareEphemeralServerTarget } from './ephemeral-target.js';
Expand All @@ -17,12 +17,17 @@ type Runtime = Awaited<ReturnType<typeof createRuntime>>;

type BrowserSuppression = 'default' | 'no-browser';

export interface AuthCommandOptions {
readonly oauthTimeoutMs?: number;
}

const TRUE_VALUES = new Set(['1', 'true', 'yes']);
const FALSE_VALUES = new Set(['0', 'false', 'no']);

export async function handleAuth(runtime: Runtime, args: string[]): Promise<void> {
export async function handleAuth(runtime: Runtime, args: string[], options: AuthCommandOptions = {}): Promise<void> {
const browserSuppression = consumeBrowserSuppression(args, process.env);
const noBrowser = browserSuppression === 'no-browser';
const oauthTimeoutMs = options.oauthTimeoutMs ?? resolveOAuthTimeoutFromEnv();
let authorizationOutputEmitted = false;
const markAuthorizationOutputEmitted = () => {
authorizationOutputEmitted = true;
Expand Down Expand Up @@ -85,6 +90,11 @@ export async function handleAuth(runtime: Runtime, args: string[]): Promise<void
const tools = await withInfoLogsSuppressed(noBrowser, () =>
runtime.listTools(target, {
autoAuthorize: true,
// The interactive OAuth browser flow runs inside this listTools
// request. Let it ride for as long as we're willing to wait for the
// authorization code (MCPORTER_OAUTH_TIMEOUT_MS, default 300s) instead
// of being killed by the SDK's 60s request cap.
timeoutMs: oauthTimeoutMs,
...(noBrowser
? {
oauthSessionOptions: buildNoBrowserOAuthOptions(format, markAuthorizationOutputEmitted),
Expand Down
90 changes: 61 additions & 29 deletions src/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import path from 'node:path';
import { listConfigLayerPaths } from '../config/path-discovery.js';
import { withFileLock } from '../fs-json.js';
import { getDaemonMetadataPath, getDaemonSocketPath } from './paths.js';
import {
DAEMON_PROTOCOL_VERSION,
DaemonFrameDecoder,
isDaemonProgressFrame,
resolveProgressInterval,
} from './protocol.js';
import type {
CallToolParams,
CloseServerParams,
Expand Down Expand Up @@ -34,6 +40,7 @@ export interface DaemonPaths {

interface DaemonMetadata {
readonly pid: number;
readonly protocolVersion?: number;
readonly socketPath: string;
readonly configPath: string;
readonly configMtimeMs?: number | null;
Expand Down Expand Up @@ -69,7 +76,7 @@ export class DaemonClient {
}

async listTools(params: ListToolsParams): Promise<unknown> {
return this.invoke('listTools', params);
return this.invoke('listTools', params, params.timeoutMs);
}

async listResources(params: ListResourcesParams): Promise<unknown> {
Expand Down Expand Up @@ -232,6 +239,9 @@ export class DaemonClient {
if (!metadata) {
return 'missing';
}
if (metadata.protocolVersion !== DAEMON_PROTOCOL_VERSION) {
return 'stale';
}
const currentLayers = normalizeLayers(await collectConfigLayers(this.options));
const metadataLayers = normalizeLayers(
metadata.configLayers ?? [{ path: metadata.configPath, mtimeMs: metadata.configMtimeMs ?? null }]
Expand All @@ -250,15 +260,26 @@ export class DaemonClient {
}

private async sendRequest<T>(method: DaemonRequestMethod, params: unknown, timeoutOverrideMs?: number): Promise<T> {
// This is an *idle* deadline, not an operation deadline: the daemon keeps
// resetting it with progress frames for as long as it is working, so a
// request survives any number of sequential phases (an OAuth code wait plus
// however many `tools/list` pages a server returns). The daemon still owns
// the operation deadline and answers with `operation_timeout` when a phase
// exceeds it; the socket only gives up when the daemon goes silent.
const idleTimeoutMs = resolveDaemonTimeout(timeoutOverrideMs);
const request: DaemonRequest = {
id: randomUUID(),
method,
params,
// Tell the daemon how often this deadline needs proof of life, so a short
// deadline cannot expire before the first frame arrives.
progressIntervalMs: resolveProgressInterval(idleTimeoutMs),
};
const payload = JSON.stringify(request);
const timeoutMs = resolveDaemonTimeout(timeoutOverrideMs);
const response = await new Promise<string>((resolve, reject) => {
const parsed = await new Promise<DaemonResponse<T>>((resolve, reject) => {
const socket = net.createConnection(this.socketPath);
const decoder = new DaemonFrameDecoder();
let response: DaemonResponse<T> | undefined;
let settled = false;
const finishReject = (error: Error): void => {
if (settled) {
Expand All @@ -267,23 +288,31 @@ export class DaemonClient {
settled = true;
reject(error);
};
const finishResolve = (value: string): void => {
const finishResolve = (value: DaemonResponse<T>): void => {
if (settled) {
return;
}
settled = true;
resolve(value);
};
socket.setTimeout(timeoutMs, () => {
// If the daemon doesn't answer in time we treat it as a transport error, destroy the socket,
// and let invoke() restart the daemon so hung keep-alive servers get a fresh start.
socket.destroy(
Object.assign(new Error('Daemon request timed out.'), {
code: 'ETIMEDOUT',
})
);
socket.setTimeout(idleTimeoutMs);
socket.on('timeout', () => {
// The daemon stopped proving it is alive. Treat that as a transport error, destroy the
// socket, and let invoke() restart the daemon so hung keep-alive servers get a fresh start.
socket.destroy(transportError('Daemon request timed out.', 'ETIMEDOUT'));
});
let buffer = '';
const consume = (frames: ReturnType<DaemonFrameDecoder['push']>): void => {
for (const frame of frames) {
if (isDaemonProgressFrame(frame)) {
// Proof of life from the daemon: restart the idle deadline.
socket.setTimeout(idleTimeoutMs);
continue;
}
response = frame as DaemonResponse<T>;
// The answer is in hand; stop policing the socket while it closes.
socket.setTimeout(0);
}
};
socket.on('connect', () => {
socket.write(payload, (error) => {
if (error) {
Expand All @@ -293,27 +322,24 @@ export class DaemonClient {
});
});
socket.on('data', (chunk) => {
buffer += chunk.toString();
consume(decoder.push(chunk.toString()));
});
socket.on('end', () => {
consume(decoder.flush());
if (response) {
finishResolve(response);
return;
}
finishReject(
decoder.malformed
? transportError('Failed to parse daemon response.', 'ECONNRESET')
: transportError('Empty daemon response.', 'ECONNRESET')
);
});
socket.on('end', () => finishResolve(buffer));
socket.on('error', (error) => {
finishReject(error as Error);
});
});
const trimmed = response.trim();
if (!trimmed) {
const error = new Error('Empty daemon response.');
(error as NodeJS.ErrnoException).code = 'ECONNRESET';
throw error;
}
let parsed: DaemonResponse<T>;
try {
parsed = JSON.parse(trimmed) as DaemonResponse<T>;
} catch {
const parseError = new Error('Failed to parse daemon response.');
(parseError as NodeJS.ErrnoException).code = 'ECONNRESET';
throw parseError;
}
if (!parsed.ok) {
const error = new Error(parsed.error?.message ?? 'Daemon error');
(error as NodeJS.ErrnoException).code = parsed.error?.code;
Expand All @@ -328,6 +354,12 @@ function deriveConfigKey(configPath: string): string {
return crypto.createHash('sha1').update(absolute).digest('hex').slice(0, 12);
}

function transportError(message: string, code: string): Error {
const error = new Error(message);
(error as NodeJS.ErrnoException).code = code;
return error;
}

function isTransportError(error: unknown): boolean {
if (!error || typeof error !== 'object') {
return false;
Expand Down
Loading