mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-28 02:13:33 +00:00
397 lines
13 KiB
TypeScript
397 lines
13 KiB
TypeScript
// Process-local MCP loopback runtime state for owner/non-owner HTTP access.
|
|
type McpLoopbackRuntime = {
|
|
port: number;
|
|
ownerToken: string;
|
|
nonOwnerToken: string;
|
|
};
|
|
|
|
export type McpLoopbackToolCallResult = {
|
|
toolName: string;
|
|
args: Record<string, unknown>;
|
|
result?: unknown;
|
|
isError: boolean;
|
|
};
|
|
|
|
export type McpLoopbackToolCallStart = Pick<McpLoopbackToolCallResult, "toolName" | "args">;
|
|
|
|
type McpLoopbackToolCallCapture = {
|
|
generation: number;
|
|
onYield?: (message: string) => Promise<void> | void;
|
|
onRequestStart?: () => void;
|
|
onRequestClassified?: () => void;
|
|
onRequestFinish?: () => void;
|
|
onToolCallStart?: (call: McpLoopbackToolCallStart) => void;
|
|
onToolCallUpdate?: (calls: {
|
|
previous: McpLoopbackToolCallStart;
|
|
current: McpLoopbackToolCallStart;
|
|
}) => void;
|
|
onToolCallFinish?: (call: McpLoopbackToolCallStart, state: { prepared: boolean }) => void;
|
|
onToolCallResult: (call: McpLoopbackToolCallResult) => void;
|
|
inFlight: number;
|
|
activityVersion: number;
|
|
activityWaiters: Set<() => void>;
|
|
};
|
|
|
|
export type McpLoopbackRequestCaptureHandle = {
|
|
capture: McpLoopbackToolCallCapture;
|
|
classified: boolean;
|
|
finished: boolean;
|
|
};
|
|
|
|
export type McpLoopbackToolCallCaptureHandle = {
|
|
capture: McpLoopbackToolCallCapture;
|
|
call: McpLoopbackToolCallStart;
|
|
prepared: boolean;
|
|
finished: boolean;
|
|
};
|
|
|
|
let activeRuntime: McpLoopbackRuntime | undefined;
|
|
let nextToolCallCaptureGeneration = 0;
|
|
const toolCallCaptures = new Map<string, McpLoopbackToolCallCapture>();
|
|
|
|
function deleteMcpLoopbackToolCallCapture(captureKey: string): void {
|
|
const capture = toolCallCaptures.get(captureKey);
|
|
if (!capture) {
|
|
return;
|
|
}
|
|
toolCallCaptures.delete(captureKey);
|
|
for (const resolve of capture.activityWaiters) {
|
|
resolve();
|
|
}
|
|
capture.activityWaiters.clear();
|
|
}
|
|
|
|
function notifyMcpLoopbackToolCallCaptureActivity(capture: McpLoopbackToolCallCapture): void {
|
|
capture.activityVersion += 1;
|
|
for (const resolve of capture.activityWaiters) {
|
|
resolve();
|
|
}
|
|
capture.activityWaiters.clear();
|
|
}
|
|
|
|
/** Start loopback tool-call result capture for one serialized CLI invocation. */
|
|
export function beginMcpLoopbackToolCallCapture(params: {
|
|
captureKey: string;
|
|
onYield?: (message: string) => Promise<void> | void;
|
|
onRequestStart?: () => void;
|
|
onRequestClassified?: () => void;
|
|
onRequestFinish?: () => void;
|
|
onToolCallStart?: (call: McpLoopbackToolCallStart) => void;
|
|
onToolCallUpdate?: (calls: {
|
|
previous: McpLoopbackToolCallStart;
|
|
current: McpLoopbackToolCallStart;
|
|
}) => void;
|
|
onToolCallFinish?: (call: McpLoopbackToolCallStart, state: { prepared: boolean }) => void;
|
|
onToolCallResult: (call: McpLoopbackToolCallResult) => void;
|
|
}): void {
|
|
const captureKey = params.captureKey.trim();
|
|
if (!captureKey) {
|
|
return;
|
|
}
|
|
nextToolCallCaptureGeneration += 1;
|
|
toolCallCaptures.set(captureKey, {
|
|
generation: nextToolCallCaptureGeneration,
|
|
onYield: params.onYield,
|
|
onRequestStart: params.onRequestStart,
|
|
onRequestClassified: params.onRequestClassified,
|
|
onRequestFinish: params.onRequestFinish,
|
|
onToolCallStart: params.onToolCallStart,
|
|
onToolCallUpdate: params.onToolCallUpdate,
|
|
onToolCallFinish: params.onToolCallFinish,
|
|
onToolCallResult: params.onToolCallResult,
|
|
inFlight: 0,
|
|
activityVersion: 0,
|
|
activityWaiters: new Set(),
|
|
});
|
|
}
|
|
|
|
/** Resolve yield state bound to the request's admitted CLI capture generation. */
|
|
export function resolveMcpLoopbackYieldContext(
|
|
captureHandle: McpLoopbackRequestCaptureHandle | undefined,
|
|
): { cacheKey: string; onYield: (message: string) => Promise<void> } | undefined {
|
|
const capture = captureHandle?.capture;
|
|
if (!capture?.onYield) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
cacheKey: String(capture.generation),
|
|
onYield: async (message: string) => {
|
|
await capture.onYield?.(message);
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Bind an authenticated HTTP request to the active capture generation before reading its body. */
|
|
export function markMcpLoopbackRequestStarted(
|
|
captureKey: string | undefined,
|
|
): McpLoopbackRequestCaptureHandle | undefined {
|
|
const normalizedKey = captureKey?.trim() ?? "";
|
|
if (!normalizedKey) {
|
|
return undefined;
|
|
}
|
|
const capture = toolCallCaptures.get(normalizedKey);
|
|
if (!capture) {
|
|
return undefined;
|
|
}
|
|
capture.inFlight += 1;
|
|
notifyMcpLoopbackToolCallCaptureActivity(capture);
|
|
try {
|
|
capture.onRequestStart?.();
|
|
} catch {
|
|
// Delivery observation is diagnostic state; it must not alter request handling.
|
|
}
|
|
return { capture, classified: false, finished: false };
|
|
}
|
|
|
|
/** Mark a request body as parsed so it no longer represents an unknown possible send. */
|
|
export function markMcpLoopbackRequestClassified(
|
|
captureHandle: McpLoopbackRequestCaptureHandle | undefined,
|
|
): void {
|
|
if (!captureHandle || captureHandle.classified || captureHandle.finished) {
|
|
return;
|
|
}
|
|
captureHandle.classified = true;
|
|
try {
|
|
captureHandle.capture.onRequestClassified?.();
|
|
} catch {
|
|
// Delivery observation is diagnostic state; it must not alter request handling.
|
|
}
|
|
}
|
|
|
|
/** Mark an authenticated request as settled and wake capture drains. */
|
|
export function markMcpLoopbackRequestFinished(
|
|
captureHandle: McpLoopbackRequestCaptureHandle | undefined,
|
|
): void {
|
|
if (!captureHandle || captureHandle.finished) {
|
|
return;
|
|
}
|
|
markMcpLoopbackRequestClassified(captureHandle);
|
|
captureHandle.finished = true;
|
|
const { capture } = captureHandle;
|
|
try {
|
|
capture.onRequestFinish?.();
|
|
} catch {
|
|
// Delivery observation is diagnostic state; it must not alter request handling.
|
|
}
|
|
capture.inFlight = Math.max(0, capture.inFlight - 1);
|
|
notifyMcpLoopbackToolCallCaptureActivity(capture);
|
|
}
|
|
|
|
/** Mark a captured loopback tool call as in flight. */
|
|
export function markMcpLoopbackToolCallStarted(params: {
|
|
captureKey?: string;
|
|
requestCaptureHandle?: McpLoopbackRequestCaptureHandle;
|
|
toolName: string;
|
|
args: Record<string, unknown>;
|
|
}): McpLoopbackToolCallCaptureHandle | undefined {
|
|
const toolName = params.toolName.trim();
|
|
if (!toolName || params.requestCaptureHandle?.finished) {
|
|
return undefined;
|
|
}
|
|
const captureKey = params.captureKey?.trim() ?? "";
|
|
const capture = params.requestCaptureHandle?.capture ?? toolCallCaptures.get(captureKey);
|
|
if (!capture) {
|
|
return undefined;
|
|
}
|
|
const call = { toolName, args: params.args };
|
|
capture.inFlight += 1;
|
|
notifyMcpLoopbackToolCallCaptureActivity(capture);
|
|
try {
|
|
capture.onToolCallStart?.(call);
|
|
} catch {
|
|
// Delivery observation is diagnostic state; it must not alter tool execution.
|
|
}
|
|
return { capture, call, prepared: false, finished: false };
|
|
}
|
|
|
|
/** Update an admitted call with the final arguments produced by gateway hooks. */
|
|
export function updateMcpLoopbackToolCallCapture(
|
|
captureHandle: McpLoopbackToolCallCaptureHandle | undefined,
|
|
call: McpLoopbackToolCallStart,
|
|
): void {
|
|
if (!captureHandle || captureHandle.finished) {
|
|
return;
|
|
}
|
|
const previous = captureHandle.call;
|
|
captureHandle.call = call;
|
|
captureHandle.prepared = true;
|
|
try {
|
|
captureHandle.capture.onToolCallUpdate?.({ previous, current: call });
|
|
} catch {
|
|
// Delivery observation is diagnostic state; it must not alter tool execution.
|
|
}
|
|
}
|
|
|
|
/** Report a completed call without letting observer failures alter tool execution. */
|
|
export function recordMcpLoopbackToolCallResult(params: {
|
|
captureHandle: McpLoopbackToolCallCaptureHandle;
|
|
toolName: string;
|
|
args: Record<string, unknown>;
|
|
result?: unknown;
|
|
isError: boolean;
|
|
}): void {
|
|
const toolName = params.toolName.trim();
|
|
if (!toolName) {
|
|
return;
|
|
}
|
|
try {
|
|
params.captureHandle.capture.onToolCallResult({
|
|
toolName,
|
|
args: params.args,
|
|
result: params.result,
|
|
isError: params.isError,
|
|
});
|
|
} catch {
|
|
// Delivery observation is diagnostic state; it must not turn a successful tool call into error.
|
|
}
|
|
}
|
|
|
|
/** Mark a captured loopback tool call as settled and wake idle drains. */
|
|
export function markMcpLoopbackToolCallFinished(
|
|
captureHandle: McpLoopbackToolCallCaptureHandle | undefined,
|
|
): void {
|
|
if (!captureHandle || captureHandle.finished) {
|
|
return;
|
|
}
|
|
captureHandle.finished = true;
|
|
const { capture } = captureHandle;
|
|
try {
|
|
capture.onToolCallFinish?.(captureHandle.call, { prepared: captureHandle.prepared });
|
|
} catch {
|
|
// Delivery observation is diagnostic state; it must not alter tool execution.
|
|
}
|
|
capture.inFlight = Math.max(0, capture.inFlight - 1);
|
|
notifyMcpLoopbackToolCallCaptureActivity(capture);
|
|
}
|
|
|
|
async function waitForMcpLoopbackToolCallCaptureActivity(
|
|
capture: McpLoopbackToolCallCapture,
|
|
timeoutMs: number,
|
|
): Promise<boolean> {
|
|
return await new Promise<boolean>((resolve) => {
|
|
let settled = false;
|
|
const finish = (active: boolean) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
capture.activityWaiters.delete(resolveActivity);
|
|
resolve(active);
|
|
};
|
|
const resolveActivity = () => finish(true);
|
|
const timer = setTimeout(() => finish(false), Math.max(0, timeoutMs));
|
|
timer.unref?.();
|
|
capture.activityWaiters.add(resolveActivity);
|
|
});
|
|
}
|
|
|
|
/** Wait for admitted calls to settle and for a quiet request-admission grace. */
|
|
export async function waitForMcpLoopbackToolCallCaptureIdle(
|
|
captureKey: string,
|
|
options: {
|
|
timeoutMs: number;
|
|
admissionGraceMs: number;
|
|
},
|
|
): Promise<boolean> {
|
|
const normalizedKey = captureKey.trim();
|
|
const capture = toolCallCaptures.get(normalizedKey);
|
|
if (!capture) {
|
|
return true;
|
|
}
|
|
const deadline = Date.now() + Math.max(0, options.timeoutMs);
|
|
while (toolCallCaptures.get(normalizedKey) === capture) {
|
|
const remainingMs = deadline - Date.now();
|
|
if (remainingMs <= 0) {
|
|
return false;
|
|
}
|
|
if (capture.inFlight > 0) {
|
|
await waitForMcpLoopbackToolCallCaptureActivity(capture, remainingMs);
|
|
continue;
|
|
}
|
|
const admissionGraceMs = Math.max(0, options.admissionGraceMs);
|
|
if (admissionGraceMs === 0) {
|
|
return true;
|
|
}
|
|
const activityVersion = capture.activityVersion;
|
|
const quietWaitMs = Math.min(admissionGraceMs, remainingMs);
|
|
const sawActivity = await waitForMcpLoopbackToolCallCaptureActivity(capture, quietWaitMs);
|
|
if (
|
|
!sawActivity &&
|
|
quietWaitMs === admissionGraceMs &&
|
|
capture.inFlight === 0 &&
|
|
capture.activityVersion === activityVersion
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Clear an unfinished invocation capture. Attempt keys are unique per CLI execution. */
|
|
export function clearMcpLoopbackToolCallCapture(captureKey: string): void {
|
|
deleteMcpLoopbackToolCallCapture(captureKey.trim());
|
|
}
|
|
|
|
/** Clear transient capture state between isolated tests. */
|
|
export function clearMcpLoopbackToolCallCapturesForTest(): void {
|
|
for (const captureKey of toolCallCaptures.keys()) {
|
|
deleteMcpLoopbackToolCallCapture(captureKey);
|
|
}
|
|
nextToolCallCaptureGeneration = 0;
|
|
}
|
|
|
|
/** Return a copy of the active loopback runtime, if one has been installed. */
|
|
export function getActiveMcpLoopbackRuntime(): McpLoopbackRuntime | undefined {
|
|
return activeRuntime ? { ...activeRuntime } : undefined;
|
|
}
|
|
|
|
/** Install the active loopback runtime used by in-process MCP callers. */
|
|
export function setActiveMcpLoopbackRuntime(runtime: McpLoopbackRuntime): void {
|
|
activeRuntime = { ...runtime };
|
|
}
|
|
|
|
/** Choose the bearer token matching owner/non-owner caller identity. */
|
|
export function resolveMcpLoopbackBearerToken(
|
|
runtime: McpLoopbackRuntime,
|
|
senderIsOwner: boolean,
|
|
): string {
|
|
return senderIsOwner ? runtime.ownerToken : runtime.nonOwnerToken;
|
|
}
|
|
|
|
/** Clear loopback runtime only when the owning token matches the active runtime. */
|
|
export function clearActiveMcpLoopbackRuntimeByOwnerToken(ownerToken: string): void {
|
|
if (activeRuntime?.ownerToken === ownerToken) {
|
|
activeRuntime = undefined;
|
|
}
|
|
}
|
|
|
|
/** Build the MCP server config injected into agents for loopback tool access. */
|
|
export function createMcpLoopbackServerConfig(port: number) {
|
|
return {
|
|
mcpServers: {
|
|
openclaw: {
|
|
type: "http",
|
|
url: `http://127.0.0.1:${port}/mcp`,
|
|
headers: {
|
|
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
|
|
"x-session-key": "${OPENCLAW_MCP_SESSION_KEY}",
|
|
"x-openclaw-session-id": "${OPENCLAW_MCP_SESSION_ID}",
|
|
"x-openclaw-agent-id": "${OPENCLAW_MCP_AGENT_ID}",
|
|
"x-openclaw-account-id": "${OPENCLAW_MCP_ACCOUNT_ID}",
|
|
"x-openclaw-message-channel": "${OPENCLAW_MCP_MESSAGE_CHANNEL}",
|
|
"x-openclaw-current-channel-id": "${OPENCLAW_MCP_CURRENT_CHANNEL_ID}",
|
|
"x-openclaw-current-thread-ts": "${OPENCLAW_MCP_CURRENT_THREAD_TS}",
|
|
"x-openclaw-current-message-id": "${OPENCLAW_MCP_CURRENT_MESSAGE_ID}",
|
|
"x-openclaw-current-inbound-audio": "${OPENCLAW_MCP_CURRENT_INBOUND_AUDIO}",
|
|
"x-openclaw-inbound-event-kind": "${OPENCLAW_MCP_INBOUND_EVENT_KIND}",
|
|
"x-openclaw-source-reply-delivery-mode": "${OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE}",
|
|
"x-openclaw-require-explicit-message-target":
|
|
"${OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET}",
|
|
"x-openclaw-cli-capture-key": "${OPENCLAW_MCP_CLI_CAPTURE_KEY}",
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|