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 {
|
export function extractRequestContext(req: BridgeInboundRequest): RequestContext {
|
||||||
const systemMsg = req.messages.find((m) => m.role === "system");
|
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);
|
const text = messageText(systemMsg);
|
||||||
|
|
||||||
// Match "Runtime: agent=<id> | ... | repo=<path> | ..."
|
// Match "Runtime: agent=<id> | ... | repo=<path> | ..."
|
||||||
const runtimeMatch = text.match(/Runtime:\s*([^\n]+)/);
|
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 runtimeLine = runtimeMatch[1];
|
||||||
const agentMatch = runtimeLine.match(/\bagent=([^|\s]+)/);
|
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 { extractLatestUserMessage, extractRequestContext } from "./input-filter.js";
|
||||||
import { buildBootstrap } from "./bootstrap.js";
|
import { buildBootstrap } from "./bootstrap.js";
|
||||||
import { dispatchToClaude } from "../claude/sdk-adapter.js";
|
import { dispatchToClaude } from "../claude/sdk-adapter.js";
|
||||||
import { makeMemorySearchHandler, makeMemoryGetHandler } from "./memory-handlers.js";
|
import { getGlobalPluginRegistry } from "openclaw/plugin-sdk/plugin-runtime";
|
||||||
import {
|
import {
|
||||||
getSession,
|
getSession,
|
||||||
putSession,
|
putSession,
|
||||||
markOrphaned,
|
markOrphaned,
|
||||||
updateActivity,
|
|
||||||
} from "../contractor/session-map-store.js";
|
} from "../contractor/session-map-store.js";
|
||||||
|
|
||||||
export type BridgeServerConfig = {
|
export type BridgeServerConfig = {
|
||||||
@@ -21,11 +20,6 @@ export type BridgeServerConfig = {
|
|||||||
agentId: string,
|
agentId: string,
|
||||||
sessionKey: string,
|
sessionKey: string,
|
||||||
) => { workspace: string } | null;
|
) => { 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 };
|
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 {
|
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(
|
async function handleChatCompletions(
|
||||||
req: http.IncomingMessage,
|
req: http.IncomingMessage,
|
||||||
@@ -161,6 +157,7 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
|
|||||||
for await (const event of dispatchToClaude({
|
for await (const event of dispatchToClaude({
|
||||||
prompt,
|
prompt,
|
||||||
workspace,
|
workspace,
|
||||||
|
agentId,
|
||||||
resumeSessionId: claudeSessionId ?? undefined,
|
resumeSessionId: claudeSessionId ?? undefined,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
openclawTools, // pass every turn — MCP server exits with the claude process
|
openclawTools, // pass every turn — MCP server exits with the claude process
|
||||||
@@ -253,12 +250,28 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
|
|||||||
return;
|
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.
|
// MCP proxy tool execution callback.
|
||||||
// Called by openclaw-mcp-server.mjs when Claude Code invokes an MCP tool.
|
// 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") {
|
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 {
|
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 {
|
} catch {
|
||||||
sendJson(res, 400, { error: "invalid_json" });
|
sendJson(res, 400, { error: "invalid_json" });
|
||||||
return;
|
return;
|
||||||
@@ -266,42 +279,65 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
|
|||||||
const toolName = body.tool ?? "";
|
const toolName = body.tool ?? "";
|
||||||
const toolArgs = body.args ?? {};
|
const toolArgs = body.args ?? {};
|
||||||
const execWorkspace = body.workspace ?? "";
|
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
|
// Relay to OpenClaw's actual tool implementation via the global plugin registry.
|
||||||
if (execWorkspace) {
|
// This avoids reimplementing tool logic in the bridge — we call the real tool factory.
|
||||||
if (toolName === "memory_search") {
|
|
||||||
try {
|
|
||||||
const result = await makeMemorySearchHandler(execWorkspace)(toolArgs);
|
|
||||||
sendJson(res, 200, { result });
|
|
||||||
} catch (err) {
|
|
||||||
sendJson(res, 200, { error: `memory_search failed: ${String(err)}` });
|
|
||||||
}
|
|
||||||
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)}` });
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
|
|
||||||
if (!handler) {
|
|
||||||
sendJson(res, 200, { error: `Tool '${toolName}' not found in contractor tool registry` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const result = await handler(toolArgs);
|
const pluginRegistry = getGlobalPluginRegistry();
|
||||||
sendJson(res, 200, { result });
|
if (!pluginRegistry) {
|
||||||
|
sendJson(res, 200, { error: `[mcp/execute] plugin registry not available (tool=${toolName})` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (err) {
|
||||||
|
logger.warn(`[contractor-bridge] mcp/execute error tool=${toolName}: ${String(err)}`);
|
||||||
sendJson(res, 200, { error: `Tool execution failed: ${String(err)}` });
|
sendJson(res, 200, { error: `Tool execution failed: ${String(err)}` });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type OpenAITool = {
|
|||||||
export type ClaudeDispatchOptions = {
|
export type ClaudeDispatchOptions = {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
workspace: string;
|
workspace: string;
|
||||||
|
agentId?: string;
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
permissionMode?: string;
|
permissionMode?: string;
|
||||||
/** OpenClaw tool definitions to expose to Claude as MCP tools */
|
/** OpenClaw tool definitions to expose to Claude as MCP tools */
|
||||||
@@ -43,6 +44,7 @@ function setupMcpConfig(
|
|||||||
bridgePort: number,
|
bridgePort: number,
|
||||||
bridgeApiKey: string,
|
bridgeApiKey: string,
|
||||||
workspace: string,
|
workspace: string,
|
||||||
|
agentId: string,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!tools.length) return null;
|
if (!tools.length) return null;
|
||||||
if (!fs.existsSync(MCP_SERVER_SCRIPT)) 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_EXECUTE_URL: `http://127.0.0.1:${bridgePort}/mcp/execute`,
|
||||||
BRIDGE_API_KEY: bridgeApiKey,
|
BRIDGE_API_KEY: bridgeApiKey,
|
||||||
WORKSPACE: workspace,
|
WORKSPACE: workspace,
|
||||||
|
AGENT_ID: agentId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -87,6 +90,7 @@ export async function* dispatchToClaude(
|
|||||||
const {
|
const {
|
||||||
prompt,
|
prompt,
|
||||||
workspace,
|
workspace,
|
||||||
|
agentId = "",
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
permissionMode = "bypassPermissions",
|
permissionMode = "bypassPermissions",
|
||||||
openclawTools,
|
openclawTools,
|
||||||
@@ -115,7 +119,7 @@ export async function* dispatchToClaude(
|
|||||||
// Put --mcp-config after the prompt so its <configs...> variadic doesn't consume the prompt.
|
// Put --mcp-config after the prompt so its <configs...> variadic doesn't consume the prompt.
|
||||||
let mcpConfigPath: string | null = null;
|
let mcpConfigPath: string | null = null;
|
||||||
if (openclawTools?.length) {
|
if (openclawTools?.length) {
|
||||||
mcpConfigPath = setupMcpConfig(openclawTools, bridgePort, bridgeApiKey, workspace);
|
mcpConfigPath = setupMcpConfig(openclawTools, bridgePort, bridgeApiKey, workspace, agentId);
|
||||||
if (mcpConfigPath) {
|
if (mcpConfigPath) {
|
||||||
args.push("--mcp-config", mcpConfigPath);
|
args.push("--mcp-config", mcpConfigPath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ function isPortFree(port: number): Promise<boolean> {
|
|||||||
const _G = globalThis as Record<string, unknown>;
|
const _G = globalThis as Record<string, unknown>;
|
||||||
const LIFECYCLE_KEY = "_contractorAgentLifecycleRegistered";
|
const LIFECYCLE_KEY = "_contractorAgentLifecycleRegistered";
|
||||||
const SERVER_KEY = "_contractorAgentBridgeServer";
|
const SERVER_KEY = "_contractorAgentBridgeServer";
|
||||||
|
/** Key for the live OpenClaw config accessor (getter fn) shared via globalThis. */
|
||||||
|
const OPENCLAW_CONFIG_KEY = "_contractorOpenClawConfig";
|
||||||
|
|
||||||
// ── Plugin entry ─────────────────────────────────────────────────────────────
|
// ── Plugin entry ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -59,6 +61,10 @@ export default {
|
|||||||
// Guard with globalThis flag AND a port probe to handle the case where the
|
// 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.
|
// gateway is already running the server while a CLI subprocess is starting up.
|
||||||
// (See LESSONS_LEARNED.md item 7 — lock file / port probe pattern)
|
// (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]) {
|
if (!_G[LIFECYCLE_KEY]) {
|
||||||
_G[LIFECYCLE_KEY] = true;
|
_G[LIFECYCLE_KEY] = true;
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ async function executeTool(name, args) {
|
|||||||
const url = process.env.BRIDGE_EXECUTE_URL;
|
const url = process.env.BRIDGE_EXECUTE_URL;
|
||||||
const apiKey = process.env.BRIDGE_API_KEY ?? "";
|
const apiKey = process.env.BRIDGE_API_KEY ?? "";
|
||||||
const workspace = process.env.WORKSPACE ?? "";
|
const workspace = process.env.WORKSPACE ?? "";
|
||||||
|
const agentId = process.env.AGENT_ID ?? "";
|
||||||
if (!url) return `[mcp-proxy] BRIDGE_EXECUTE_URL not configured`;
|
if (!url) return `[mcp-proxy] BRIDGE_EXECUTE_URL not configured`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -55,8 +56,8 @@ async function executeTool(name, args) {
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
},
|
},
|
||||||
// Include workspace so bridge can resolve workspace-relative operations (e.g. memory files)
|
// Include workspace and agentId so bridge can build the correct tool execution context
|
||||||
body: JSON.stringify({ tool: name, args, workspace }),
|
body: JSON.stringify({ tool: name, args, workspace, agentId }),
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.error) return `[tool error] ${data.error}`;
|
if (data.error) return `[tool error] ${data.error}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user