feat: implement memory_search/memory_get MCP tools and fix resume MCP disconnect
Key fixes: - Pass --mcp-config on every turn (not just first): MCP server exits with each claude -p process, so --resume also needs a fresh MCP server - Pass openclawTools on every turn for the same reason - Add WORKSPACE env var to MCP server so execute requests include workspace path - Implement memory_search (keyword search over workspace/memory/*.md + MEMORY.md) - Implement memory_get (line-range read from workspace memory files) - Both are workspace-aware, resolved per-request via request body workspace field Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
162
src/bridge/memory-handlers.ts
Normal file
162
src/bridge/memory-handlers.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementations of OpenClaw memory tools for the contractor bridge.
|
||||||
|
*
|
||||||
|
* OpenClaw's real memory_search uses vector/semantic search; we approximate
|
||||||
|
* with keyword matching across workspace memory files. memory_get is a
|
||||||
|
* line-range file read and is implemented exactly.
|
||||||
|
*
|
||||||
|
* These handlers are registered in globalThis._contractorToolHandlers so the
|
||||||
|
* MCP proxy can call them via POST /mcp/execute.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type MemorySearchArgs = {
|
||||||
|
query?: string;
|
||||||
|
maxResults?: number;
|
||||||
|
minScore?: number;
|
||||||
|
corpus?: "memory" | "wiki" | "all";
|
||||||
|
};
|
||||||
|
|
||||||
|
type MemoryGetArgs = {
|
||||||
|
path?: string;
|
||||||
|
from?: number;
|
||||||
|
lines?: number;
|
||||||
|
corpus?: "memory" | "wiki" | "all";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Collect all memory files in a workspace. */
|
||||||
|
function collectMemoryFiles(workspace: string): string[] {
|
||||||
|
const files: string[] = [];
|
||||||
|
const memoryMd = path.join(workspace, "MEMORY.md");
|
||||||
|
if (fs.existsSync(memoryMd)) files.push(memoryMd);
|
||||||
|
|
||||||
|
const memoryDir = path.join(workspace, "memory");
|
||||||
|
if (fs.existsSync(memoryDir)) {
|
||||||
|
for (const f of fs.readdirSync(memoryDir)) {
|
||||||
|
if (f.endsWith(".md")) files.push(path.join(memoryDir, f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Score a file's relevance to a query by counting keyword occurrences. */
|
||||||
|
function scoreFile(content: string, query: string): { score: number; snippets: string[] } {
|
||||||
|
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
const lines = content.split("\n");
|
||||||
|
const snippets: string[] = [];
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i].toLowerCase();
|
||||||
|
const matches = words.filter((w) => line.includes(w)).length;
|
||||||
|
if (matches > 0) {
|
||||||
|
score += matches;
|
||||||
|
// Include surrounding context (1 line before/after)
|
||||||
|
const start = Math.max(0, i - 1);
|
||||||
|
const end = Math.min(lines.length - 1, i + 1);
|
||||||
|
const snippet = lines.slice(start, end + 1).join("\n").trim();
|
||||||
|
if (snippet && !snippets.includes(snippet)) {
|
||||||
|
snippets.push(snippet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { score, snippets };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeMemorySearchHandler(workspace: string) {
|
||||||
|
return async (args: unknown): Promise<string> => {
|
||||||
|
const { query = "", maxResults = 5, corpus = "memory" } = args as MemorySearchArgs;
|
||||||
|
|
||||||
|
if (corpus === "wiki") {
|
||||||
|
return JSON.stringify({ disabled: true, reason: "wiki corpus not available in contractor bridge" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = collectMemoryFiles(workspace);
|
||||||
|
if (files.length === 0) {
|
||||||
|
return JSON.stringify({
|
||||||
|
results: [],
|
||||||
|
note: "No memory files found in workspace. Memory is empty.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: Array<{ file: string; score: number; snippets: string[] }> = [];
|
||||||
|
|
||||||
|
for (const filePath of files) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, "utf8");
|
||||||
|
const { score, snippets } = scoreFile(content, query);
|
||||||
|
if (score > 0) {
|
||||||
|
results.push({
|
||||||
|
file: path.relative(workspace, filePath),
|
||||||
|
score,
|
||||||
|
snippets,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip unreadable files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.sort((a, b) => b.score - a.score);
|
||||||
|
const top = results.slice(0, maxResults);
|
||||||
|
|
||||||
|
if (top.length === 0) {
|
||||||
|
return JSON.stringify({
|
||||||
|
results: [],
|
||||||
|
note: `No memory matches found for query: "${query}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({ results: top });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeMemoryGetHandler(workspace: string) {
|
||||||
|
return async (args: unknown): Promise<string> => {
|
||||||
|
const { path: filePath = "", from, lines, corpus = "memory" } = args as MemoryGetArgs;
|
||||||
|
|
||||||
|
if (corpus === "wiki") {
|
||||||
|
return "[memory_get] wiki corpus not available in contractor bridge";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filePath) return "[memory_get] path is required";
|
||||||
|
|
||||||
|
// Resolve path relative to workspace (prevent path traversal)
|
||||||
|
const resolved = path.resolve(workspace, filePath);
|
||||||
|
if (!resolved.startsWith(workspace + path.sep) && resolved !== workspace) {
|
||||||
|
return `[memory_get] path outside workspace: ${filePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(resolved)) {
|
||||||
|
return `[memory_get] file not found: ${filePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(resolved, "utf8");
|
||||||
|
const allLines = content.split("\n");
|
||||||
|
|
||||||
|
const startLine = from != null ? Math.max(0, from - 1) : 0; // convert 1-based to 0-based
|
||||||
|
const endLine = lines != null ? startLine + lines : allLines.length;
|
||||||
|
|
||||||
|
return allLines.slice(startLine, endLine).join("\n");
|
||||||
|
} catch (err) {
|
||||||
|
return `[memory_get] error reading file: ${String(err)}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register memory tool handlers into the shared globalThis tool registry.
|
||||||
|
* Called by the bridge server on startup.
|
||||||
|
*/
|
||||||
|
export function registerMemoryHandlers(workspace: string): void {
|
||||||
|
const _G = globalThis as Record<string, unknown>;
|
||||||
|
if (!(_G["_contractorToolHandlers"] instanceof Map)) {
|
||||||
|
_G["_contractorToolHandlers"] = new Map<string, (args: unknown) => Promise<string>>();
|
||||||
|
}
|
||||||
|
const registry = _G["_contractorToolHandlers"] as Map<string, (args: unknown) => Promise<string>>;
|
||||||
|
registry.set("memory_search", makeMemorySearchHandler(workspace));
|
||||||
|
registry.set("memory_get", makeMemoryGetHandler(workspace));
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ 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 {
|
import {
|
||||||
getSession,
|
getSession,
|
||||||
putSession,
|
putSession,
|
||||||
@@ -162,7 +163,7 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
|
|||||||
workspace,
|
workspace,
|
||||||
resumeSessionId: claudeSessionId ?? undefined,
|
resumeSessionId: claudeSessionId ?? undefined,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
openclawTools: isFirstTurn ? openclawTools : undefined,
|
openclawTools, // pass every turn — MCP server exits with the claude process
|
||||||
bridgePort: port,
|
bridgePort: port,
|
||||||
bridgeApiKey: apiKey,
|
bridgeApiKey: apiKey,
|
||||||
})) {
|
})) {
|
||||||
@@ -255,17 +256,41 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
|
|||||||
// 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.
|
||||||
if (method === "POST" && url === "/mcp/execute") {
|
if (method === "POST" && url === "/mcp/execute") {
|
||||||
let body: { tool?: string; args?: Record<string, unknown> };
|
let body: { tool?: string; args?: Record<string, unknown>; workspace?: string };
|
||||||
try {
|
try {
|
||||||
body = await parseBodyRaw(req) as { tool?: string; args?: Record<string, unknown> };
|
body = await parseBodyRaw(req) as { tool?: string; args?: Record<string, unknown>; workspace?: string };
|
||||||
} catch {
|
} catch {
|
||||||
sendJson(res, 400, { error: "invalid_json" });
|
sendJson(res, 400, { error: "invalid_json" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const toolName = body.tool ?? "";
|
const toolName = body.tool ?? "";
|
||||||
const toolArgs = body.args ?? {};
|
const toolArgs = body.args ?? {};
|
||||||
logger.info(`[contractor-bridge] mcp/execute tool=${toolName}`);
|
const execWorkspace = body.workspace ?? "";
|
||||||
|
logger.info(`[contractor-bridge] mcp/execute tool=${toolName} workspace=${execWorkspace}`);
|
||||||
|
|
||||||
|
// Built-in workspace-aware tool implementations
|
||||||
|
if (execWorkspace) {
|
||||||
|
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 registry = (globalThis as Record<string, unknown>)[toolRegistryKey];
|
||||||
const handler = registry instanceof Map ? (registry as Map<string, (args: unknown) => Promise<string>>).get(toolName) : undefined;
|
const handler = registry instanceof Map ? (registry as Map<string, (args: unknown) => Promise<string>>).get(toolName) : undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ function setupMcpConfig(
|
|||||||
tools: OpenAITool[],
|
tools: OpenAITool[],
|
||||||
bridgePort: number,
|
bridgePort: number,
|
||||||
bridgeApiKey: string,
|
bridgeApiKey: string,
|
||||||
|
workspace: 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;
|
||||||
@@ -63,6 +64,7 @@ function setupMcpConfig(
|
|||||||
TOOL_DEFS_FILE: toolDefsPath,
|
TOOL_DEFS_FILE: toolDefsPath,
|
||||||
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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -108,12 +110,12 @@ export async function* dispatchToClaude(
|
|||||||
args.push("--resume", resumeSessionId);
|
args.push("--resume", resumeSessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If OpenClaw tools provided, set up MCP proxy so Claude sees them.
|
// Set up MCP proxy every turn — the MCP server process exits with each `claude -p`
|
||||||
// Added after prompt so --mcp-config <configs...> doesn't consume the prompt.
|
// invocation, so --resume sessions also need --mcp-config to restart it.
|
||||||
|
// 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 && !resumeSessionId) {
|
if (openclawTools?.length) {
|
||||||
// Only inject MCP config on first turn — resume already has MCP from the session.
|
mcpConfigPath = setupMcpConfig(openclawTools, bridgePort, bridgeApiKey, workspace);
|
||||||
mcpConfigPath = setupMcpConfig(openclawTools, bridgePort, bridgeApiKey);
|
|
||||||
if (mcpConfigPath) {
|
if (mcpConfigPath) {
|
||||||
args.push("--mcp-config", mcpConfigPath);
|
args.push("--mcp-config", mcpConfigPath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ function sendError(id, code, message) {
|
|||||||
async function executeTool(name, args) {
|
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 ?? "";
|
||||||
if (!url) return `[mcp-proxy] BRIDGE_EXECUTE_URL not configured`;
|
if (!url) return `[mcp-proxy] BRIDGE_EXECUTE_URL not configured`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -54,7 +55,8 @@ async function executeTool(name, args) {
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ tool: name, args }),
|
// Include workspace so bridge can resolve workspace-relative operations (e.g. memory files)
|
||||||
|
body: JSON.stringify({ tool: name, args, workspace }),
|
||||||
});
|
});
|
||||||
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