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:
@@ -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]+)/);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user