mcporter/src/daemon/runtime-wrapper.ts
Brad Hallett 1e6ce66d22 fix(daemon): pass allowCachedAuth to runtime for OAuth token reuse
The daemon host never passed allowCachedAuth when calling
runtime.callTool() or runtime.listTools(), and the KeepAliveRuntime
callTool wrapper did not forward it to the daemon client either.

Without allowCachedAuth, createClientContext skips
applyCachedAuthIfAvailable, so cached OAuth tokens are never read
and every daemon-managed OAuth server fails after token expiry.

Changes:
- protocol.ts: add allowCachedAuth to CallToolParams
- host.ts: pass allowCachedAuth in callTool and listTools handlers
- runtime-wrapper.ts: forward allowCachedAuth in callTool daemon path

Fixes openclaw/mcporter#181
Related openclaw/mcporter#179
2026-05-20 08:23:26 -04:00

169 lines
5.4 KiB
TypeScript

import type { ListResourcesRequest } from '@modelcontextprotocol/sdk/types.js';
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import type { ServerDefinition } from '../config.js';
import { isKeepAliveServer } from '../lifecycle.js';
import type { CallOptions, ListToolsOptions, Runtime } from '../runtime.js';
import type { DaemonClient } from './client.js';
interface KeepAliveRuntimeOptions {
readonly daemonClient: DaemonClient | null;
readonly keepAliveServers: Set<string>;
}
export function createKeepAliveRuntime(base: Runtime, options: KeepAliveRuntimeOptions): Runtime {
if (!options.daemonClient || options.keepAliveServers.size === 0) {
return base;
}
return new KeepAliveRuntime(base, options.daemonClient, options.keepAliveServers);
}
class KeepAliveRuntime implements Runtime {
private readonly restartPromises = new Map<string, Promise<void>>();
constructor(
private readonly base: Runtime,
private readonly daemon: DaemonClient,
private readonly keepAliveServers: Set<string>
) {}
listServers(): string[] {
return this.base.listServers();
}
getDefinitions(): ServerDefinition[] {
return this.base.getDefinitions();
}
getDefinition(server: string): ServerDefinition {
return this.base.getDefinition(server);
}
registerDefinition(definition: ServerDefinition, options?: { overwrite?: boolean }): void {
this.base.registerDefinition(definition, options);
if (isKeepAliveServer(definition)) {
this.keepAliveServers.add(definition.name);
} else {
this.keepAliveServers.delete(definition.name);
}
}
async getInstructions(server: string): Promise<string | undefined> {
return this.base.getInstructions?.(server);
}
async listTools(server: string, options?: ListToolsOptions): Promise<Awaited<ReturnType<Runtime['listTools']>>> {
if (options?.oauthSessionOptions) {
return this.base.listTools(server, options);
}
if (this.shouldUseDaemon(server)) {
return (await this.invokeWithRestart(server, 'listTools', () =>
this.daemon.listTools({
server,
includeSchema: options?.includeSchema,
autoAuthorize: options?.autoAuthorize,
})
)) as Awaited<ReturnType<Runtime['listTools']>>;
}
return this.base.listTools(server, options);
}
async callTool(server: string, toolName: string, options?: CallOptions): Promise<unknown> {
if (this.shouldUseDaemon(server)) {
return this.invokeWithRestart(server, 'callTool', () =>
this.daemon.callTool({
server,
tool: toolName,
args: options?.args,
timeoutMs: options?.timeoutMs,
allowCachedAuth: options?.allowCachedAuth ?? true,
})
);
}
return this.base.callTool(server, toolName, options);
}
async listResources(server: string, options?: Partial<ListResourcesRequest['params']>): Promise<unknown> {
if (this.shouldUseDaemon(server)) {
return this.invokeWithRestart(server, 'listResources', () =>
this.daemon.listResources({ server, params: options ?? {} })
);
}
return this.base.listResources(server, options);
}
async readResource(server: string, uri: string): Promise<unknown> {
if (this.shouldUseDaemon(server)) {
return this.invokeWithRestart(server, 'readResource', () => this.daemon.readResource({ server, uri }));
}
return this.base.readResource(server, uri);
}
async connect(server: string): Promise<Awaited<ReturnType<Runtime['connect']>>> {
return this.base.connect(server);
}
async close(server?: string): Promise<void> {
if (!server) {
await this.base.close();
return;
}
if (this.shouldUseDaemon(server)) {
await this.daemon.closeServer({ server }).catch(() => {});
return;
}
await this.base.close(server);
}
private shouldUseDaemon(server: string): boolean {
return this.keepAliveServers.has(server);
}
private async invokeWithRestart<T>(server: string, operation: string, action: () => Promise<T>): Promise<T> {
try {
return await action();
} catch (error) {
if (!shouldRestartDaemonServer(error)) {
throw error;
}
// 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.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]);
function shouldRestartDaemonServer(error: unknown): boolean {
if (!error) {
return false;
}
if (error instanceof McpError) {
return !NON_FATAL_CODES.has(error.code);
}
return true;
}
function logDaemonRetry(server: string, operation: string, error: unknown): void {
const reason = error instanceof Error ? error.message : String(error);
console.error(`[mcporter] Restarting '${server}' before retrying ${operation}: ${reason}`);
}