feat: relay MCP tool calls to OpenClaw plugin registry instead of reimplementing

When Claude Code calls an MCP tool via /mcp/execute, the bridge now:
1. Looks up the tool in OpenClaw's global plugin registry (getGlobalPluginRegistry)
2. Calls the tool's factory with proper context (config from globalThis, canonical
   session key "agent:<agentId>:direct:bridge" for memory agentId resolution)
3. Executes the real tool implementation and returns the AgentToolResult text

This replaces the manual memory_search/memory_get implementations in memory-handlers.ts
with a generic relay that works for any registered OpenClaw tool. The agentId is now
propagated from dispatchToClaude through the MCP server env (AGENT_ID) to /mcp/execute.

The OpenClaw config is stored in globalThis._contractorOpenClawConfig during plugin
registration (index.ts api.config) since getRuntimeConfigSnapshot() uses module-level
state not shared across bundle boundaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
h z
2026-04-11 17:09:02 +01:00
parent f9d43477bf
commit 7a779c8560
5 changed files with 95 additions and 48 deletions

View File

@@ -50,13 +50,13 @@ export type RequestContext = {
*/
export function extractRequestContext(req: BridgeInboundRequest): RequestContext {
const systemMsg = req.messages.find((m) => m.role === "system");
if (!systemMsg) return { agentId: "", workspace: "", skillsBlock: "" };
if (!systemMsg) return { agentId: "", workspace: "", skillsBlock: "", workspaceContextFiles: [] };
const text = messageText(systemMsg);
// Match "Runtime: agent=<id> | ... | repo=<path> | ..."
const runtimeMatch = text.match(/Runtime:\s*([^\n]+)/);
if (!runtimeMatch) return { agentId: "", workspace: "", skillsBlock: "" };
if (!runtimeMatch) return { agentId: "", workspace: "", skillsBlock: "", workspaceContextFiles: [] };
const runtimeLine = runtimeMatch[1];
const agentMatch = runtimeLine.match(/\bagent=([^|\s]+)/);

View File

@@ -4,12 +4,11 @@ import type { BridgeInboundRequest } from "../types/model.js";
import { extractLatestUserMessage, extractRequestContext } from "./input-filter.js";
import { buildBootstrap } from "./bootstrap.js";
import { dispatchToClaude } from "../claude/sdk-adapter.js";
import { makeMemorySearchHandler, makeMemoryGetHandler } from "./memory-handlers.js";
import { getGlobalPluginRegistry } from "openclaw/plugin-sdk/plugin-runtime";
import {
getSession,
putSession,
markOrphaned,
updateActivity,
} from "../contractor/session-map-store.js";
export type BridgeServerConfig = {
@@ -21,11 +20,6 @@ export type BridgeServerConfig = {
agentId: string,
sessionKey: string,
) => { workspace: string } | null;
/**
* globalThis key that holds a Map<string, (args: unknown) => Promise<string>>.
* Plugins register their tool implementations here so the MCP proxy can call them.
*/
toolRegistryKey?: string;
logger: { info: (msg: string) => void; warn: (msg: string) => void };
};
@@ -88,8 +82,10 @@ function parseBody(req: http.IncomingMessage): Promise<BridgeInboundRequest> {
}
const _G = globalThis as Record<string, unknown>;
export function createBridgeServer(config: BridgeServerConfig): http.Server {
const { port, apiKey, permissionMode, resolveAgent, toolRegistryKey = "_contractorToolHandlers", logger } = config;
const { port, apiKey, permissionMode, resolveAgent, logger } = config;
async function handleChatCompletions(
req: http.IncomingMessage,
@@ -161,6 +157,7 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
for await (const event of dispatchToClaude({
prompt,
workspace,
agentId,
resumeSessionId: claudeSessionId ?? undefined,
permissionMode,
openclawTools, // pass every turn — MCP server exits with the claude process
@@ -253,12 +250,28 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
return;
}
// Debug: list registered tools in the global plugin registry
if (method === "GET" && url === "/debug/tools") {
const pluginRegistry = getGlobalPluginRegistry();
const liveConfig = _G["_contractorOpenClawConfig"] ?? null;
if (!pluginRegistry) {
sendJson(res, 200, { error: "registry not available" });
} else {
sendJson(res, 200, {
configAvailable: liveConfig !== null,
tools: pluginRegistry.tools.map((t) => ({ names: t.names, pluginId: t.pluginId })),
});
}
return;
}
// MCP proxy tool execution callback.
// Called by openclaw-mcp-server.mjs when Claude Code invokes an MCP tool.
// Relays the call to OpenClaw's actual tool implementation via the global plugin registry.
if (method === "POST" && url === "/mcp/execute") {
let body: { tool?: string; args?: Record<string, unknown>; workspace?: string };
let body: { tool?: string; args?: Record<string, unknown>; workspace?: string; agentId?: string };
try {
body = await parseBodyRaw(req) as { tool?: string; args?: Record<string, unknown>; workspace?: string };
body = await parseBodyRaw(req) as { tool?: string; args?: Record<string, unknown>; workspace?: string; agentId?: string };
} catch {
sendJson(res, 400, { error: "invalid_json" });
return;
@@ -266,42 +279,65 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
const toolName = body.tool ?? "";
const toolArgs = body.args ?? {};
const execWorkspace = body.workspace ?? "";
logger.info(`[contractor-bridge] mcp/execute tool=${toolName} workspace=${execWorkspace}`);
const execAgentId = body.agentId ?? "";
logger.info(`[contractor-bridge] mcp/execute tool=${toolName} workspace=${execWorkspace} agentId=${execAgentId}`);
// Built-in workspace-aware tool implementations
if (execWorkspace) {
if (toolName === "memory_search") {
// Relay to OpenClaw's actual tool implementation via the global plugin registry.
// This avoids reimplementing tool logic in the bridge — we call the real tool factory.
try {
const result = await makeMemorySearchHandler(execWorkspace)(toolArgs);
sendJson(res, 200, { result });
} catch (err) {
sendJson(res, 200, { error: `memory_search failed: ${String(err)}` });
}
const pluginRegistry = getGlobalPluginRegistry();
if (!pluginRegistry) {
sendJson(res, 200, { error: `[mcp/execute] plugin registry not available (tool=${toolName})` });
return;
}
if (toolName === "memory_get") {
try {
const result = await makeMemoryGetHandler(execWorkspace)(toolArgs);
sendJson(res, 200, { result });
} catch (err) {
sendJson(res, 200, { error: `memory_get failed: ${String(err)}` });
}
// Find the tool registration by name
const toolReg = pluginRegistry.tools.find((t) => t.names.includes(toolName));
if (!toolReg) {
sendJson(res, 200, { error: `Tool '${toolName}' not registered in OpenClaw plugin registry` });
return;
}
}
// Plugin-registered tool handlers (globalThis registry)
const registry = (globalThis as Record<string, unknown>)[toolRegistryKey];
const handler = registry instanceof Map ? (registry as Map<string, (args: unknown) => Promise<string>>).get(toolName) : undefined;
// Build tool execution context.
// config is required by memory tools (resolveMemoryToolContext checks it).
// Read from globalThis where index.ts stores api.config on every registration.
// sessionKey must be in OpenClaw's canonical format "agent:<agentId>:<rest>" so that
// parseAgentSessionKey() can extract the agentId for memory tool context resolution.
const liveConfig = (_G["_contractorOpenClawConfig"] ?? null) as Record<string, unknown> | null;
// Construct a canonical session key so memory tools can resolve the agentId from it.
// Format: "agent:<agentId>:direct:bridge"
const canonicalSessionKey = execAgentId ? `agent:${execAgentId}:direct:bridge` : undefined;
const toolCtx = {
config: liveConfig ?? undefined,
workspaceDir: execWorkspace || undefined,
agentDir: execWorkspace || undefined,
agentId: execAgentId || undefined,
sessionKey: canonicalSessionKey,
};
if (!handler) {
sendJson(res, 200, { error: `Tool '${toolName}' not found in contractor tool registry` });
// Instantiate the tool via its factory
const toolOrTools = toolReg.factory(toolCtx);
const toolInstance = Array.isArray(toolOrTools)
? toolOrTools.find((t) => (t as { name?: string }).name === toolName)
: toolOrTools;
if (!toolInstance || typeof (toolInstance as { execute?: unknown }).execute !== "function") {
sendJson(res, 200, { error: `Tool '${toolName}' factory returned no executable tool` });
return;
}
try {
const result = await handler(toolArgs);
sendJson(res, 200, { result });
const execFn = (toolInstance as { execute: (id: string, args: unknown) => Promise<{ content?: Array<{ type: string; text?: string }> }> }).execute;
const toolResult = await execFn(randomUUID(), toolArgs);
// Extract text content from AgentToolResult
const text = (toolResult.content ?? [])
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text as string)
.join("\n");
sendJson(res, 200, { result: text || "(no result)" });
} catch (err) {
logger.warn(`[contractor-bridge] mcp/execute error tool=${toolName}: ${String(err)}`);
sendJson(res, 200, { error: `Tool execution failed: ${String(err)}` });
}
return;

View File

@@ -18,6 +18,7 @@ export type OpenAITool = {
export type ClaudeDispatchOptions = {
prompt: string;
workspace: string;
agentId?: string;
resumeSessionId?: string;
permissionMode?: string;
/** OpenClaw tool definitions to expose to Claude as MCP tools */
@@ -43,6 +44,7 @@ function setupMcpConfig(
bridgePort: number,
bridgeApiKey: string,
workspace: string,
agentId: string,
): string | null {
if (!tools.length) return null;
if (!fs.existsSync(MCP_SERVER_SCRIPT)) return null;
@@ -65,6 +67,7 @@ function setupMcpConfig(
BRIDGE_EXECUTE_URL: `http://127.0.0.1:${bridgePort}/mcp/execute`,
BRIDGE_API_KEY: bridgeApiKey,
WORKSPACE: workspace,
AGENT_ID: agentId,
},
},
},
@@ -87,6 +90,7 @@ export async function* dispatchToClaude(
const {
prompt,
workspace,
agentId = "",
resumeSessionId,
permissionMode = "bypassPermissions",
openclawTools,
@@ -115,7 +119,7 @@ export async function* dispatchToClaude(
// Put --mcp-config after the prompt so its <configs...> variadic doesn't consume the prompt.
let mcpConfigPath: string | null = null;
if (openclawTools?.length) {
mcpConfigPath = setupMcpConfig(openclawTools, bridgePort, bridgeApiKey, workspace);
mcpConfigPath = setupMcpConfig(openclawTools, bridgePort, bridgeApiKey, workspace, agentId);
if (mcpConfigPath) {
args.push("--mcp-config", mcpConfigPath);
}

View File

@@ -24,6 +24,8 @@ function isPortFree(port: number): Promise<boolean> {
const _G = globalThis as Record<string, unknown>;
const LIFECYCLE_KEY = "_contractorAgentLifecycleRegistered";
const SERVER_KEY = "_contractorAgentBridgeServer";
/** Key for the live OpenClaw config accessor (getter fn) shared via globalThis. */
const OPENCLAW_CONFIG_KEY = "_contractorOpenClawConfig";
// ── Plugin entry ─────────────────────────────────────────────────────────────
@@ -59,6 +61,10 @@ export default {
// Guard with globalThis flag AND a port probe to handle the case where the
// gateway is already running the server while a CLI subprocess is starting up.
// (See LESSONS_LEARNED.md item 7 — lock file / port probe pattern)
// Always update the config accessor so hot-reloads get fresh config.
// server.ts reads this via globalThis to build tool execution context.
_G[OPENCLAW_CONFIG_KEY] = api.config;
if (!_G[LIFECYCLE_KEY]) {
_G[LIFECYCLE_KEY] = true;

View File

@@ -46,6 +46,7 @@ async function executeTool(name, args) {
const url = process.env.BRIDGE_EXECUTE_URL;
const apiKey = process.env.BRIDGE_API_KEY ?? "";
const workspace = process.env.WORKSPACE ?? "";
const agentId = process.env.AGENT_ID ?? "";
if (!url) return `[mcp-proxy] BRIDGE_EXECUTE_URL not configured`;
try {
@@ -55,8 +56,8 @@ async function executeTool(name, args) {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
// Include workspace so bridge can resolve workspace-relative operations (e.g. memory files)
body: JSON.stringify({ tool: name, args, workspace }),
// Include workspace and agentId so bridge can build the correct tool execution context
body: JSON.stringify({ tool: name, args, workspace, agentId }),
});
const data = await resp.json();
if (data.error) return `[tool error] ${data.error}`;