membot 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "membot",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Versioned context store with hybrid search for AI agents. Stdio + HTTP MCP server and CLI.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,7 +1,7 @@
1
1
  diff --git a/src/search/onnx-wasm-paths.ts b/src/search/onnx-wasm-paths.ts
2
2
  --- a/src/search/onnx-wasm-paths.ts
3
3
  +++ b/src/search/onnx-wasm-paths.ts
4
- @@ -1,31 +1,9 @@
4
+ @@ -1,31 +1,16 @@
5
5
  -// Embed the onnxruntime-web WASM runtime files into the compiled binary
6
6
  -// (`bun build --compile`) so they survive in a single-binary distribution
7
7
  -// where the user has no node_modules.
@@ -33,12 +33,19 @@ diff --git a/src/search/onnx-wasm-paths.ts b/src/search/onnx-wasm-paths.ts
33
33
  -};
34
34
  -
35
35
  -export { wasmBinPath, wasmMjsPath };
36
- +// PATCHED (membot): upstream mcpx ships static `with { type: "file" }` imports
37
- +// of onnxruntime-web WASM assets via `../../node_modules/...`, which only
38
- +// resolves when mcpx is built standalone. When consumed as an npm dep those
39
- +// paths are unreachable and `bun build --compile` fails at build time. membot
40
- +// never invokes mcpx's semantic search (only `mcpx.exec()` for URL fetching),
41
- +// so we stub the exports — semantic.ts wraps the dynamic import in try/catch
42
- +// and falls back to transformers.js's default WASM loader.
43
- +export const wasmMjsPath = "";
44
- +export const wasmBinPath = "";
36
+ +// PATCHED (membot): point mcpx's onnx-wasm-paths at the onnxruntime-web installed
37
+ +// at the top of membot's node_modules. Upstream's `../../node_modules/...` only
38
+ +// resolves in mcpx's standalone repo layout; for consumers we walk up 4 levels:
39
+ +// node_modules/@evantahler/mcpx/src/search node_modules onnxruntime-web.
40
+ +// biome-ignore lint/suspicious/noTsIgnore: must stay as ts-ignore — relative path only resolves at runtime in consumer layout
41
+ +// @ts-ignore - dynamic-only import
42
+ +import wasmMjsPath from "../../../../onnxruntime-web/dist/ort-wasm-simd-threaded.asyncify.mjs" with {
43
+ + type: "file",
44
+ +};
45
+ +// biome-ignore lint/suspicious/noTsIgnore: must stay as ts-ignore — relative path only resolves at runtime in consumer layout
46
+ +// @ts-ignore - dynamic-only import
47
+ +import wasmBinPath from "../../../../onnxruntime-web/dist/ort-wasm-simd-threaded.asyncify.wasm" with {
48
+ + type: "file",
49
+ +};
50
+ +
51
+ +export { wasmBinPath, wasmMjsPath };
@@ -38,11 +38,13 @@ apply_patch \
38
38
  "node_modules/@huggingface/transformers" \
39
39
  ".membot-transformers-patch-applied"
40
40
 
41
- # @evantahler/mcpx — stub `src/search/onnx-wasm-paths.ts` whose static
42
- # `with { type: "file" }` imports use a relative path that only resolves in
43
- # mcpx's own repo layout. When mcpx is consumed as an npm dep those paths are
44
- # unreachable and `bun build --compile` fails at build time. membot never
45
- # invokes mcpx's semantic search, so the stubbed exports are safe.
41
+ # @evantahler/mcpx — rewrite `src/search/onnx-wasm-paths.ts` so its static
42
+ # `with { type: "file" }` imports of onnxruntime-web's WASM resolve from the
43
+ # consumer's hoisted node_modules layout (../../../../onnxruntime-web/...)
44
+ # instead of mcpx's own repo layout (../../node_modules/...). With this
45
+ # patch in place, mcpx's semantic search runs end-to-end inside membot
46
+ # (the agent fetcher's `mcp_search` exercises it) and `bun build --compile`
47
+ # can bundle the WASM assets into the standalone binary.
46
48
  apply_patch \
47
49
  "patches/@evantahler%2Fmcpx@0.21.4.patch" \
48
50
  "node_modules/@evantahler/mcpx" \
@@ -0,0 +1,564 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import type {
3
+ Tool as AnthropicTool,
4
+ MessageParam,
5
+ ToolResultBlockParam,
6
+ ToolUseBlock,
7
+ } from "@anthropic-ai/sdk/resources/messages";
8
+ import type { LlmConfig } from "../config/schemas.ts";
9
+ import { HelpfulError } from "../errors.ts";
10
+ import { logger } from "../output/logger.ts";
11
+ import { sha256Hex } from "./local-reader.ts";
12
+
13
+ /** Number of times the agent may iterate. Each turn = one Claude call + tool dispatch. */
14
+ const MAX_TURNS = 10;
15
+ /** Bytes of content shown back to the LLM in the mcp_exec preview. The harness has the full content. */
16
+ const PREVIEW_CHARS = 2_000;
17
+ /** Token budget per Claude call. Should comfortably fit a tool-use response + reasoning. */
18
+ const MAX_RESPONSE_TOKENS = 4_096;
19
+
20
+ /**
21
+ * Outcome shape mirrored from `FetchedRemote` in fetcher.ts. We don't
22
+ * import that type here to avoid a cycle — the fetcher imports us.
23
+ */
24
+ export interface AgentFetchedRemote {
25
+ bytes: Uint8Array;
26
+ sha256: string;
27
+ mimeType: string;
28
+ fetcher: "mcpx";
29
+ fetcherServer: string;
30
+ fetcherTool: string;
31
+ fetcherArgs: Record<string, unknown>;
32
+ sourceUrl: string;
33
+ }
34
+
35
+ /**
36
+ * The slice of mcpx the agent loop needs. Kept minimal so tests can
37
+ * stub it without spinning up a real client.
38
+ */
39
+ export interface AgentMcpxAdapter {
40
+ search(
41
+ query: string,
42
+ options?: { keywordOnly?: boolean; semanticOnly?: boolean },
43
+ ): Promise<{ server: string; tool: string; description?: string; score?: number; matchType?: string }[]>;
44
+ listTools(server?: string): Promise<{ server: string; tool: { name: string; description?: string } }[]>;
45
+ info(
46
+ server: string,
47
+ tool: string,
48
+ ): Promise<{ name: string; description?: string; inputSchema?: unknown } | undefined>;
49
+ exec(
50
+ server: string,
51
+ tool: string,
52
+ args?: Record<string, unknown>,
53
+ ): Promise<{ isError?: boolean; content?: unknown[] }>;
54
+ }
55
+
56
+ export interface AgentFetchOptions {
57
+ url: string;
58
+ mcpx: AgentMcpxAdapter;
59
+ llm: LlmConfig;
60
+ hint?: string;
61
+ /** Test seam: inject a pre-built Anthropic client. */
62
+ _testClient?: Anthropic;
63
+ }
64
+
65
+ /**
66
+ * Outcome of the agent loop:
67
+ * - `accepted`: the agent picked a captured mcp_exec result; caller stores it as the new version.
68
+ * - `fallback`: the agent gave up on mcpx (request_http_fallback, no tool calls, max turns); caller does plain HTTP.
69
+ * - HelpfulError thrown: the agent reported an actionable failure (report_failure), or the loop hit a hard error.
70
+ */
71
+ export type AgentFetchOutcome = { kind: "accepted"; result: AgentFetchedRemote } | { kind: "fallback"; reason: string };
72
+
73
+ const FETCHER_SYSTEM_PROMPT = `You are a content fetcher. Your job is to find the right MCP tool to retrieve the content at the given URL, run it, and tell the harness which result to save.
74
+
75
+ **Important: the harness captures the full result of every mcp_exec call automatically.** You only see a short preview of each result so you can verify it looks reasonable. You do NOT need to read or copy the full content — you just identify which exec call to save.
76
+
77
+ **Format preference: markdown, in order of preference.**
78
+ 1. When searching with mcp_search or mcp_list_tools, prefer tools whose names indicate markdown output: anything containing "markdown", "md", "AsMarkdown", "AsMd", "AsDocmd", or similar.
79
+ 2. If no markdown-named variant exists, use mcp_info to inspect the tool's input schema for a "format", "mime_type", "output_format", or similar parameter and request "markdown" (or "md") when available.
80
+ 3. If neither is possible, run the tool anyway. The membot pipeline will normalize the captured content downstream — markdown-native tools are still preferred because they're cheaper and higher fidelity, but you do not have to find one.
81
+
82
+ Workflow:
83
+ 1. Use mcp_search or mcp_list_tools to find the best tool for this URL (e.g., Google Docs tools for docs.google.com, Firecrawl for generic web pages, GitHub tools for github.com). Apply the format preference above.
84
+ 2. Use mcp_info to inspect the tool's input schema. **Required before mcp_exec on any tool you haven't called this session.** Many tools want \`document_id\`, \`repo\`, \`page_id\`, etc. — not \`url\`. Extract the right value from the URL.
85
+ 3. Call mcp_exec with arguments that conform to the schema.
86
+ 4. **Multi-step workflows are expected.** Many providers need a sequence of calls — e.g. Firecrawl: \`scrape\` returns a job id, then \`get_job_status\` polls until done, then the final result has the content; some doc providers need a \`prepare/export\` call before \`download\`; large docs may paginate. Make as many mcp_exec calls as needed. Read each preview to decide the next step.
87
+ 5. If the tool errors (input_error / auth_error / "still processing"), read the error, adjust, and retry — or pivot to a different tool.
88
+ 6. Once a successful exec preview looks like the FINAL content, call accept_content with the exec_call_id (the tool_use_id of that mcp_exec call) and the actual mime_type the tool returned. Pick the call whose result is the actual content — not an intermediate job id or status response.
89
+
90
+ Terminal tools (call exactly one):
91
+ - accept_content(exec_call_id, mime_type?) — save the content captured from a previous mcp_exec call.
92
+ - request_http_fallback() — fall back to a basic HTTP fetch. Use only when no MCP tool can handle the URL after a genuine attempt.
93
+ - report_failure(message) — surface an actionable message to the user (e.g., "this Google Doc is private — share it with your service account"). Use only when there is a specific next step the user must take.`;
94
+
95
+ const acceptContentTool: AnthropicTool = {
96
+ name: "accept_content",
97
+ description:
98
+ "Save the full content captured by the harness from a previous mcp_exec call. You only need to supply the exec_call_id (the tool_use_id of that mcp_exec call). The harness already has the full content. Do NOT paste content here.",
99
+ input_schema: {
100
+ type: "object" as const,
101
+ properties: {
102
+ exec_call_id: {
103
+ type: "string",
104
+ description:
105
+ "The tool_use_id of the mcp_exec call whose result should be saved (the harness lists captured ids in mcp_exec previews).",
106
+ },
107
+ mime_type: {
108
+ type: "string",
109
+ description:
110
+ "MIME type the source tool returned (e.g. 'text/markdown', 'text/html', 'application/json'). Defaults to text/markdown.",
111
+ },
112
+ },
113
+ required: ["exec_call_id"],
114
+ },
115
+ };
116
+
117
+ const requestHttpFallbackTool: AnthropicTool = {
118
+ name: "request_http_fallback",
119
+ description: "Fall back to a basic HTTP fetch. Use only when no MCP tool can handle the URL after a genuine attempt.",
120
+ input_schema: { type: "object" as const, properties: {}, required: [] },
121
+ };
122
+
123
+ const reportFailureTool: AnthropicTool = {
124
+ name: "report_failure",
125
+ description:
126
+ "Report a fetch failure with an actionable message for the user (e.g., 'this Google Doc is private — share it with your service account'). Use only when there is a clear next step the user must take.",
127
+ input_schema: {
128
+ type: "object" as const,
129
+ properties: {
130
+ message: {
131
+ type: "string",
132
+ description: "Clear, actionable, user-facing message explaining what the user needs to do.",
133
+ },
134
+ },
135
+ required: ["message"],
136
+ },
137
+ };
138
+
139
+ const mcpSearchTool: AnthropicTool = {
140
+ name: "mcp_search",
141
+ description:
142
+ "Search for MCP tools by keyword + semantic similarity over the live mcpx catalog. Returns up to a handful of {server, tool, description, score} entries.",
143
+ input_schema: {
144
+ type: "object" as const,
145
+ properties: {
146
+ query: { type: "string", description: "Search query (e.g. 'fetch google docs as markdown')." },
147
+ },
148
+ required: ["query"],
149
+ },
150
+ };
151
+
152
+ const mcpListToolsTool: AnthropicTool = {
153
+ name: "mcp_list_tools",
154
+ description: "List available tools from configured MCP servers. Optionally filter by server name.",
155
+ input_schema: {
156
+ type: "object" as const,
157
+ properties: {
158
+ server: { type: "string", description: "Optional server name to filter on." },
159
+ },
160
+ required: [],
161
+ },
162
+ };
163
+
164
+ const mcpInfoTool: AnthropicTool = {
165
+ name: "mcp_info",
166
+ description:
167
+ "Get the full schema (name, description, input parameters) for a specific MCP tool. Required before mcp_exec on tools you haven't called this session.",
168
+ input_schema: {
169
+ type: "object" as const,
170
+ properties: {
171
+ server: { type: "string", description: "MCP server name." },
172
+ tool: { type: "string", description: "Tool name on the server." },
173
+ },
174
+ required: ["server", "tool"],
175
+ },
176
+ };
177
+
178
+ const mcpExecTool: AnthropicTool = {
179
+ name: "mcp_exec",
180
+ description:
181
+ "Execute a tool on an MCP server. The full result is captured by the harness keyed by tool_use_id; you receive a short preview to verify the content. To save the result, call accept_content with the exec_call_id.",
182
+ input_schema: {
183
+ type: "object" as const,
184
+ properties: {
185
+ server: { type: "string", description: "MCP server name." },
186
+ tool: { type: "string", description: "Tool name on the server." },
187
+ args: {
188
+ type: "object",
189
+ description: "Arguments object that conforms to the tool's input schema (verify via mcp_info).",
190
+ },
191
+ },
192
+ required: ["server", "tool"],
193
+ },
194
+ };
195
+
196
+ const ALL_TOOLS: AnthropicTool[] = [
197
+ mcpSearchTool,
198
+ mcpListToolsTool,
199
+ mcpInfoTool,
200
+ mcpExecTool,
201
+ acceptContentTool,
202
+ requestHttpFallbackTool,
203
+ reportFailureTool,
204
+ ];
205
+
206
+ interface CapturedExec {
207
+ server: string;
208
+ tool: string;
209
+ args: Record<string, unknown>;
210
+ content: string;
211
+ mimeType: string;
212
+ }
213
+
214
+ /**
215
+ * Run the multi-turn fetcher agent. Mirrors botholomew's `runFetcherLoop`.
216
+ *
217
+ * Returns `{ kind: "accepted", result }` when the agent calls `accept_content`
218
+ * on a captured mcp_exec result. Returns `{ kind: "fallback" }` when the agent
219
+ * calls `request_http_fallback`, produces no tool calls, or exhausts MAX_TURNS.
220
+ * Throws `HelpfulError` when the agent calls `report_failure` (the actionable
221
+ * message becomes the error's `message`/`hint`).
222
+ */
223
+ export async function agentFetch(opts: AgentFetchOptions): Promise<AgentFetchOutcome> {
224
+ if (!opts.llm.anthropic_api_key || opts.llm.anthropic_api_key.trim() === "") {
225
+ throw new HelpfulError({
226
+ kind: "auth_error",
227
+ message: `agentFetch requires ANTHROPIC_API_KEY but llm.anthropic_api_key is empty.`,
228
+ hint: `Set ANTHROPIC_API_KEY in your environment or under llm.anthropic_api_key in ~/.membot/config.json.`,
229
+ });
230
+ }
231
+
232
+ const client = opts._testClient ?? new Anthropic({ apiKey: opts.llm.anthropic_api_key });
233
+
234
+ const userPrompt = opts.hint
235
+ ? `Fetch the content at: ${opts.url}\n\nAdditional guidance:\n${opts.hint}`
236
+ : `Fetch the content at: ${opts.url}`;
237
+ const messages: MessageParam[] = [{ role: "user", content: userPrompt }];
238
+
239
+ const captured = new Map<string, CapturedExec>();
240
+
241
+ for (let turn = 0; turn < MAX_TURNS; turn++) {
242
+ const response = await client.messages.create({
243
+ model: opts.llm.converter_model,
244
+ max_tokens: MAX_RESPONSE_TOKENS,
245
+ system: FETCHER_SYSTEM_PROMPT,
246
+ messages,
247
+ tools: ALL_TOOLS,
248
+ });
249
+
250
+ for (const block of response.content) {
251
+ if (block.type === "text" && block.text.trim()) {
252
+ logger.debug(`agent-fetch turn ${turn + 1}: ${block.text.trim()}`);
253
+ }
254
+ }
255
+
256
+ if (response.stop_reason === "max_tokens") {
257
+ throw new HelpfulError({
258
+ kind: "internal_error",
259
+ message: `Fetcher agent hit max_tokens (${MAX_RESPONSE_TOKENS}) on turn ${turn + 1}.`,
260
+ hint: `The fetched document or the agent's reasoning is too long. Try \`membot add ${opts.url} --fetcher http\` or fetch a more specific section.`,
261
+ });
262
+ }
263
+
264
+ const toolUseBlocks = response.content.filter((b): b is ToolUseBlock => b.type === "tool_use");
265
+ if (toolUseBlocks.length === 0) {
266
+ logger.debug(`agent-fetch turn ${turn + 1}: no tool calls — falling back to HTTP`);
267
+ return { kind: "fallback", reason: "agent stopped without selecting an outcome" };
268
+ }
269
+
270
+ messages.push({ role: "assistant", content: response.content });
271
+
272
+ // Terminal tools — checked in priority order.
273
+ const failureCall = toolUseBlocks.find((b) => b.name === "report_failure");
274
+ if (failureCall) {
275
+ const input = failureCall.input as Partial<{ message: string }>;
276
+ const message =
277
+ typeof input.message === "string" && input.message.trim()
278
+ ? input.message.trim()
279
+ : "Fetch failed but the agent did not provide a message.";
280
+ throw new HelpfulError({
281
+ kind: "input_error",
282
+ message: `Fetcher agent reported failure for ${opts.url}: ${message}`,
283
+ hint: message,
284
+ });
285
+ }
286
+
287
+ const fallbackCall = toolUseBlocks.find((b) => b.name === "request_http_fallback");
288
+ if (fallbackCall) {
289
+ logger.debug(`agent-fetch turn ${turn + 1}: agent requested HTTP fallback`);
290
+ return { kind: "fallback", reason: "agent requested HTTP fallback" };
291
+ }
292
+
293
+ const acceptCall = toolUseBlocks.find((b) => b.name === "accept_content");
294
+ if (acceptCall) {
295
+ const input = acceptCall.input as Partial<{ exec_call_id: string; mime_type: string }>;
296
+ if (typeof input.exec_call_id !== "string") {
297
+ messages.push({
298
+ role: "user",
299
+ content: [
300
+ {
301
+ type: "tool_result",
302
+ tool_use_id: acceptCall.id,
303
+ content: "Invalid accept_content call: 'exec_call_id' is required.",
304
+ is_error: true,
305
+ },
306
+ ],
307
+ });
308
+ continue;
309
+ }
310
+ const cached = captured.get(input.exec_call_id);
311
+ if (!cached) {
312
+ const validIds = [...captured.keys()];
313
+ messages.push({
314
+ role: "user",
315
+ content: [
316
+ {
317
+ type: "tool_result",
318
+ tool_use_id: acceptCall.id,
319
+ content: `No mcp_exec call with id "${input.exec_call_id}" was captured. Captured ids: ${validIds.length ? validIds.join(", ") : "(none yet — run mcp_exec first)"}.`,
320
+ is_error: true,
321
+ },
322
+ ],
323
+ });
324
+ continue;
325
+ }
326
+ const claimedMime = (input.mime_type ?? cached.mimeType ?? "text/markdown").trim() || "text/markdown";
327
+ const bytes = new TextEncoder().encode(cached.content);
328
+ return {
329
+ kind: "accepted",
330
+ result: {
331
+ bytes,
332
+ sha256: sha256Hex(bytes),
333
+ mimeType: claimedMime,
334
+ fetcher: "mcpx",
335
+ fetcherServer: cached.server,
336
+ fetcherTool: cached.tool,
337
+ fetcherArgs: cached.args,
338
+ sourceUrl: opts.url,
339
+ },
340
+ };
341
+ }
342
+
343
+ // Discovery / exec tools — execute in parallel, feed results back.
344
+ const toolResults: ToolResultBlockParam[] = await Promise.all(
345
+ toolUseBlocks.map((toolUse) => dispatchAgentTool(toolUse, opts.mcpx, captured)),
346
+ );
347
+ messages.push({ role: "user", content: toolResults });
348
+ }
349
+
350
+ logger.debug(`agent-fetch: max turns (${MAX_TURNS}) exceeded — falling back to HTTP`);
351
+ return { kind: "fallback", reason: `agent exceeded MAX_TURNS=${MAX_TURNS}` };
352
+ }
353
+
354
+ /** Execute one agent tool call and produce the tool_result block fed back to Claude. */
355
+ async function dispatchAgentTool(
356
+ toolUse: ToolUseBlock,
357
+ mcpx: AgentMcpxAdapter,
358
+ captured: Map<string, CapturedExec>,
359
+ ): Promise<ToolResultBlockParam> {
360
+ try {
361
+ switch (toolUse.name) {
362
+ case "mcp_search":
363
+ return await runMcpSearch(toolUse, mcpx);
364
+ case "mcp_list_tools":
365
+ return await runMcpListTools(toolUse, mcpx);
366
+ case "mcp_info":
367
+ return await runMcpInfo(toolUse, mcpx);
368
+ case "mcp_exec":
369
+ return await runMcpExec(toolUse, mcpx, captured);
370
+ default:
371
+ return {
372
+ type: "tool_result",
373
+ tool_use_id: toolUse.id,
374
+ content: `Unknown tool: ${toolUse.name}`,
375
+ is_error: true,
376
+ };
377
+ }
378
+ } catch (err) {
379
+ return {
380
+ type: "tool_result",
381
+ tool_use_id: toolUse.id,
382
+ content: `Error: ${err instanceof Error ? err.message : String(err)}`,
383
+ is_error: true,
384
+ };
385
+ }
386
+ }
387
+
388
+ async function runMcpSearch(toolUse: ToolUseBlock, mcpx: AgentMcpxAdapter): Promise<ToolResultBlockParam> {
389
+ const input = toolUse.input as Partial<{ query: string }>;
390
+ if (typeof input.query !== "string" || !input.query.trim()) {
391
+ return { type: "tool_result", tool_use_id: toolUse.id, content: "mcp_search requires 'query'.", is_error: true };
392
+ }
393
+ try {
394
+ const results = await mcpx.search(input.query);
395
+ return {
396
+ type: "tool_result",
397
+ tool_use_id: toolUse.id,
398
+ content: JSON.stringify(
399
+ {
400
+ results: results.slice(0, 10).map((r) => ({
401
+ server: r.server,
402
+ tool: r.tool,
403
+ description: r.description ?? "",
404
+ score: r.score ?? 0,
405
+ })),
406
+ hint:
407
+ results.length > 0
408
+ ? "Use mcp_info to read the input schema before mcp_exec."
409
+ : "No results. Try broader terms or mcp_list_tools.",
410
+ },
411
+ null,
412
+ 2,
413
+ ),
414
+ };
415
+ } catch (err) {
416
+ return {
417
+ type: "tool_result",
418
+ tool_use_id: toolUse.id,
419
+ content: `mcp_search failed: ${err instanceof Error ? err.message : String(err)}. Try mcp_list_tools instead.`,
420
+ is_error: true,
421
+ };
422
+ }
423
+ }
424
+
425
+ async function runMcpListTools(toolUse: ToolUseBlock, mcpx: AgentMcpxAdapter): Promise<ToolResultBlockParam> {
426
+ const input = toolUse.input as Partial<{ server: string }>;
427
+ const tools = await mcpx.listTools(input.server);
428
+ const mapped = tools.map((t) => ({ server: t.server, name: t.tool.name, description: t.tool.description ?? "" }));
429
+ return {
430
+ type: "tool_result",
431
+ tool_use_id: toolUse.id,
432
+ content: JSON.stringify(
433
+ {
434
+ tools: mapped,
435
+ hint:
436
+ mapped.length > 0
437
+ ? "Use mcp_info on a {server, name} pair before mcp_exec."
438
+ : "No tools. mcpx may not be configured.",
439
+ },
440
+ null,
441
+ 2,
442
+ ),
443
+ };
444
+ }
445
+
446
+ async function runMcpInfo(toolUse: ToolUseBlock, mcpx: AgentMcpxAdapter): Promise<ToolResultBlockParam> {
447
+ const input = toolUse.input as Partial<{ server: string; tool: string }>;
448
+ if (typeof input.server !== "string" || typeof input.tool !== "string") {
449
+ return {
450
+ type: "tool_result",
451
+ tool_use_id: toolUse.id,
452
+ content: "mcp_info requires 'server' and 'tool'.",
453
+ is_error: true,
454
+ };
455
+ }
456
+ const tool = await mcpx.info(input.server, input.tool);
457
+ if (!tool) {
458
+ return {
459
+ type: "tool_result",
460
+ tool_use_id: toolUse.id,
461
+ content: `Tool "${input.tool}" not found on server "${input.server}". Use mcp_search or mcp_list_tools.`,
462
+ is_error: true,
463
+ };
464
+ }
465
+ return {
466
+ type: "tool_result",
467
+ tool_use_id: toolUse.id,
468
+ content: JSON.stringify(
469
+ {
470
+ name: tool.name,
471
+ description: tool.description ?? "",
472
+ input_schema: tool.inputSchema ?? {},
473
+ hint: `Call mcp_exec with server='${input.server}', tool='${tool.name}', and args matching this schema.`,
474
+ },
475
+ null,
476
+ 2,
477
+ ),
478
+ };
479
+ }
480
+
481
+ async function runMcpExec(
482
+ toolUse: ToolUseBlock,
483
+ mcpx: AgentMcpxAdapter,
484
+ captured: Map<string, CapturedExec>,
485
+ ): Promise<ToolResultBlockParam> {
486
+ const input = toolUse.input as Partial<{ server: string; tool: string; args: Record<string, unknown> }>;
487
+ if (typeof input.server !== "string" || typeof input.tool !== "string") {
488
+ return {
489
+ type: "tool_result",
490
+ tool_use_id: toolUse.id,
491
+ content: "mcp_exec requires 'server' and 'tool'.",
492
+ is_error: true,
493
+ };
494
+ }
495
+ const args = (input.args ?? {}) as Record<string, unknown>;
496
+
497
+ let result: { isError?: boolean; content?: unknown[] };
498
+ try {
499
+ result = await mcpx.exec(input.server, input.tool, args);
500
+ } catch (err) {
501
+ return {
502
+ type: "tool_result",
503
+ tool_use_id: toolUse.id,
504
+ content: `mcp_exec ${input.server}/${input.tool} threw: ${err instanceof Error ? err.message : String(err)}. Use mcp_info to verify the schema, then retry — or pivot to a different tool.`,
505
+ is_error: true,
506
+ };
507
+ }
508
+
509
+ const text = extractText(result);
510
+
511
+ if (result.isError === true) {
512
+ return {
513
+ type: "tool_result",
514
+ tool_use_id: toolUse.id,
515
+ content: `mcp_exec ${input.server}/${input.tool} returned isError=true: ${text}\n\nUse mcp_info to check the schema, fix the args, and retry — or try a different tool.`,
516
+ is_error: true,
517
+ };
518
+ }
519
+
520
+ if (!text?.trim()) {
521
+ return {
522
+ type: "tool_result",
523
+ tool_use_id: toolUse.id,
524
+ content: `mcp_exec ${input.server}/${input.tool} returned empty content. Try a different tool or different args.`,
525
+ is_error: true,
526
+ };
527
+ }
528
+
529
+ captured.set(toolUse.id, { server: input.server, tool: input.tool, args, content: text, mimeType: "text/markdown" });
530
+ const preview =
531
+ text.length > PREVIEW_CHARS
532
+ ? `${text.slice(0, PREVIEW_CHARS)}\n\n[... ${text.length - PREVIEW_CHARS} more chars truncated. Full content (${text.length} chars total) is captured by the harness with exec_call_id="${toolUse.id}". Call accept_content with this id to save it.]`
533
+ : `${text}\n\n[Full content (${text.length} chars) captured by the harness with exec_call_id="${toolUse.id}". Call accept_content with this id to save it.]`;
534
+ return { type: "tool_result", tool_use_id: toolUse.id, content: preview };
535
+ }
536
+
537
+ /**
538
+ * Extract a single string out of an MCP CallToolResult envelope. Mirrors
539
+ * the heterogeneous shapes mcpx tools return; tolerates string content,
540
+ * `text` fields, and the array-of-content-blocks shape.
541
+ */
542
+ function extractText(result: { content?: unknown } | unknown): string {
543
+ if (typeof result === "string") return result;
544
+ if (!result || typeof result !== "object") return "";
545
+ const r = result as Record<string, unknown>;
546
+ if (typeof r.text === "string") return r.text;
547
+ if (typeof r.content === "string") return r.content;
548
+ if (typeof r.markdown === "string") return r.markdown;
549
+ if (Array.isArray(r.content)) {
550
+ const out: string[] = [];
551
+ for (const c of r.content) {
552
+ if (c && typeof c === "object") {
553
+ const inner = c as Record<string, unknown>;
554
+ if (typeof inner.text === "string") out.push(inner.text);
555
+ }
556
+ }
557
+ if (out.length > 0) return out.join("\n\n");
558
+ }
559
+ try {
560
+ return JSON.stringify(result);
561
+ } catch {
562
+ return "";
563
+ }
564
+ }
@@ -1,6 +1,9 @@
1
+ import type { LlmConfig } from "../config/schemas.ts";
1
2
  import { DEFAULTS } from "../constants.ts";
2
3
  import { asHelpful, HelpfulError } from "../errors.ts";
3
4
  import { logger } from "../output/logger.ts";
5
+ import type { AgentMcpxAdapter } from "./agent-fetcher.ts";
6
+ import { agentFetch } from "./agent-fetcher.ts";
4
7
  import { sha256Hex } from "./local-reader.ts";
5
8
 
6
9
  export interface FetchedRemote {
@@ -14,38 +17,40 @@ export interface FetchedRemote {
14
17
  sourceUrl: string;
15
18
  }
16
19
 
17
- export interface McpxToolDescriptor {
18
- server: string;
19
- tool: { name: string; description?: string };
20
- }
21
-
22
- export interface McpxSearchHit {
23
- server: string;
24
- tool: { name: string; description?: string };
25
- score?: number;
26
- }
27
-
28
20
  export interface FetchOptions {
29
21
  /**
30
22
  * User-provided hint. Free-form keyword (e.g. "firecrawl", "github",
31
23
  * "google-docs", "http"). Special-cased: "http" forces plain fetch.
32
- * Otherwise the hint is used as a search query against the live
33
- * mcpx tool catalog we never hardcode server names.
24
+ * Otherwise the hint is passed verbatim to the agent loop as extra
25
+ * guidance about which provider to prefer.
34
26
  */
35
27
  hint?: string;
36
- /** Live mcpx adapter. Use listTools/search/exec to find a fetcher on the fly. */
37
- mcpx?: {
38
- exec(server: string, tool: string, args: Record<string, unknown>): Promise<unknown>;
39
- listTools(): Promise<McpxToolDescriptor[]>;
40
- search?(query: string): Promise<McpxSearchHit[]>;
41
- } | null;
28
+ /** Live mcpx adapter the agent loop drives via search/list/info/exec. */
29
+ mcpx?: AgentMcpxAdapter | null;
30
+ /**
31
+ * LLM config. The agent loop needs an Anthropic key; without one the
32
+ * mcpx path is skipped and we fall back to plain HTTP.
33
+ */
34
+ llm?: LlmConfig;
42
35
  }
43
36
 
44
37
  /**
45
- * Fetch a remote URL, preferring an mcpx-managed server (Firecrawl, Google
46
- * Docs, GitHub, …) for known providers and falling back to a plain `fetch`
47
- * otherwise. The chosen invocation (server/tool/args) is returned alongside
48
- * the bytes so the caller can persist it on the row for replay-on-refresh.
38
+ * Fetch a remote URL.
39
+ *
40
+ * - `--fetcher http` (or no mcpx, or no LLM key) plain HTTP.
41
+ * - Otherwise multi-turn agent loop: Claude is given mcpx tools
42
+ * (search/list/info/exec) and decides how to retrieve the URL,
43
+ * including multi-step flows (start a job → poll → download).
44
+ * The agent's selected mcp_exec invocation is recorded on the
45
+ * returned row so refresh can replay it deterministically without
46
+ * another agent round-trip.
47
+ *
48
+ * If the agent decides plain HTTP is the right call (`request_http_fallback`,
49
+ * no tool calls, max turns) we transparently fall through to `httpFetch`.
50
+ * If the agent reports an actionable failure, we surface that as a
51
+ * `HelpfulError`. If mcpx is configured but the LLM key is missing AND
52
+ * the HTTP fallback also fails, we surface an `auth_error` naming the env
53
+ * var so users see the real cause instead of a misleading 401.
49
54
  */
50
55
  export async function fetchRemote(url: string, options: FetchOptions = {}): Promise<FetchedRemote> {
51
56
  const mcpx = options.mcpx;
@@ -54,8 +59,46 @@ export async function fetchRemote(url: string, options: FetchOptions = {}): Prom
54
59
  if (hint === "http") return httpFetch(url);
55
60
  if (!mcpx) return httpFetch(url);
56
61
 
57
- const tried = await tryMcpx(url, mcpx, hint);
58
- if (tried) return tried;
62
+ const apiKey = options.llm?.anthropic_api_key?.trim();
63
+ if (!apiKey) {
64
+ // No way to drive the agent. Try HTTP; if that fails, the user
65
+ // almost certainly wanted mcpx — surface a clear key-missing error.
66
+ try {
67
+ return await httpFetch(url);
68
+ } catch (err) {
69
+ if (err instanceof HelpfulError && err.kind === "network_error") {
70
+ throw new HelpfulError({
71
+ kind: "auth_error",
72
+ message: `${url} couldn't be fetched directly (${err.message}). Membot has mcpx configured, but routing through it requires Claude to translate the URL into the right tool arguments — and ANTHROPIC_API_KEY isn't set.`,
73
+ hint: `Set ANTHROPIC_API_KEY in your environment (or under llm.anthropic_api_key in ~/.membot/config.json), then retry. To force the HTTP path explicitly, run \`membot add ${url} --fetcher http\`.`,
74
+ });
75
+ }
76
+ throw err;
77
+ }
78
+ }
79
+
80
+ let outcome: Awaited<ReturnType<typeof agentFetch>>;
81
+ try {
82
+ outcome = await agentFetch({ url, mcpx, llm: options.llm!, hint });
83
+ } catch (err) {
84
+ if (err instanceof HelpfulError) throw err;
85
+ logger.warn(`agent-fetch failed (${err instanceof Error ? err.message : String(err)}) — falling back to HTTP`);
86
+ return httpFetch(url);
87
+ }
88
+
89
+ if (outcome.kind === "accepted") {
90
+ return {
91
+ bytes: outcome.result.bytes,
92
+ sha256: outcome.result.sha256,
93
+ mimeType: outcome.result.mimeType,
94
+ fetcher: "mcpx",
95
+ fetcherServer: outcome.result.fetcherServer,
96
+ fetcherTool: outcome.result.fetcherTool,
97
+ fetcherArgs: outcome.result.fetcherArgs,
98
+ sourceUrl: url,
99
+ };
100
+ }
101
+ logger.debug(`agent-fetch fell back to HTTP: ${outcome.reason}`);
59
102
  return httpFetch(url);
60
103
  }
61
104
 
@@ -71,7 +114,7 @@ async function httpFetch(url: string): Promise<FetchedRemote> {
71
114
  throw asHelpful(
72
115
  err,
73
116
  `while fetching ${url}`,
74
- `Check your network and that ${url} is reachable. For mcpx-managed sources (gdocs/github/firecrawl), set --fetcher firecrawl etc.`,
117
+ `Check your network and that ${url} is reachable. For mcpx-managed sources (gdocs/github/firecrawl), set ANTHROPIC_API_KEY so membot can drive an mcpx tool.`,
75
118
  "network_error",
76
119
  );
77
120
  }
@@ -79,7 +122,7 @@ async function httpFetch(url: string): Promise<FetchedRemote> {
79
122
  throw new HelpfulError({
80
123
  kind: "network_error",
81
124
  message: `HTTP ${resp.status} ${resp.statusText}: ${url}`,
82
- hint: "Verify the URL is reachable and not gated behind auth. For private docs use mcpx via --fetcher.",
125
+ hint: "Verify the URL is reachable and not gated behind auth. For private docs use mcpx (set ANTHROPIC_API_KEY).",
83
126
  });
84
127
  }
85
128
  const bytes = new Uint8Array(await resp.arrayBuffer());
@@ -98,183 +141,13 @@ async function httpFetch(url: string): Promise<FetchedRemote> {
98
141
  }
99
142
 
100
143
  /**
101
- * Attempt to fetch via mcpx by discovering a suitable tool at runtime.
102
- *
103
- * Strategy:
104
- * 1. If the user passed a hint, search for it via mcpx.search() (semantic
105
- * tool search over the live catalog). The hint is the user's free-text
106
- * label for which provider they want — we never assume server names.
107
- * 2. Otherwise, fall back to a host-based search query (e.g. URL host
108
- * "github.com" → search for "github fetch markdown").
109
- * 3. From the returned candidates, prefer tools whose name or description
110
- * signals markdown output. Failing that, the first tool that takes a
111
- * URL-shaped argument.
112
- * 4. Execute the tool with `{ url, format: "markdown" }`-shaped args.
113
- * If exec fails, return null so the caller falls back to plain HTTP.
114
- */
115
- async function tryMcpx(
116
- url: string,
117
- mcpx: NonNullable<FetchOptions["mcpx"]>,
118
- hint: string | undefined,
119
- ): Promise<FetchedRemote | null> {
120
- const candidates = await discoverCandidates(url, mcpx, hint);
121
- if (candidates.length === 0) return null;
122
-
123
- const chosen = pickTool(candidates);
124
- if (!chosen) return null;
125
-
126
- const args = buildArgs(chosen.tool.name, url);
127
- let result: unknown;
128
- try {
129
- result = await mcpx.exec(chosen.server, chosen.tool.name, args);
130
- } catch (err) {
131
- logger.warn(
132
- `mcpx: ${chosen.server}/${chosen.tool.name} failed (${err instanceof Error ? err.message : String(err)})`,
133
- );
134
- return null;
135
- }
136
-
137
- const text = extractText(result);
138
- if (!text || text.trim().length === 0) return null;
139
- const bytes = new TextEncoder().encode(text);
140
- return {
141
- bytes,
142
- sha256: sha256Hex(bytes),
143
- mimeType: "text/markdown",
144
- fetcher: "mcpx",
145
- fetcherServer: chosen.server,
146
- fetcherTool: chosen.tool.name,
147
- fetcherArgs: args,
148
- sourceUrl: url,
149
- };
150
- }
151
-
152
- /**
153
- * Build a list of candidate fetcher tools by querying mcpx's live catalog.
154
- * Tries semantic search first (using the hint or the URL's host as the
155
- * query) then falls back to listing all tools and filtering by name. Never
156
- * hardcodes a server name — the catalog is the source of truth.
157
- */
158
- async function discoverCandidates(
159
- url: string,
160
- mcpx: NonNullable<FetchOptions["mcpx"]>,
161
- hint: string | undefined,
162
- ): Promise<McpxToolDescriptor[]> {
163
- const host = safeHost(url);
164
- const queries = buildQueries(hint, host);
165
-
166
- if (mcpx.search) {
167
- for (const q of queries) {
168
- try {
169
- const hits = await mcpx.search(q);
170
- if (hits.length > 0) {
171
- return hits.slice(0, 5).map((h) => ({ server: h.server, tool: h.tool }));
172
- }
173
- } catch (err) {
174
- logger.debug(`mcpx: search(${q}) failed (${err instanceof Error ? err.message : String(err)})`);
175
- }
176
- }
177
- }
178
-
179
- let tools: McpxToolDescriptor[];
180
- try {
181
- tools = await mcpx.listTools();
182
- } catch (err) {
183
- logger.debug(`mcpx: listTools failed (${err instanceof Error ? err.message : String(err)})`);
184
- return [];
185
- }
186
-
187
- const lowercaseHaystack = (t: McpxToolDescriptor) =>
188
- `${t.server} ${t.tool.name} ${t.tool.description ?? ""}`.toLowerCase();
189
-
190
- if (hint) {
191
- const needle = hint.toLowerCase();
192
- const matched = tools.filter((t) => lowercaseHaystack(t).includes(needle));
193
- if (matched.length > 0) return matched;
194
- }
195
-
196
- if (host) {
197
- const tokens = host.split(".");
198
- const matched = tools.filter((t) => tokens.some((tok) => tok.length > 2 && lowercaseHaystack(t).includes(tok)));
199
- if (matched.length > 0) return matched;
200
- }
201
-
202
- // Fall back to any tool that looks like a URL fetcher.
203
- return tools.filter((t) => /fetch|scrape|http|url/i.test(`${t.tool.name} ${t.tool.description ?? ""}`));
204
- }
205
-
206
- /** Compose semantic-search queries to feed mcpx.search. */
207
- function buildQueries(hint: string | undefined, host: string | null): string[] {
208
- const out: string[] = [];
209
- if (hint) out.push(`${hint} fetch markdown`);
210
- if (host) out.push(`fetch ${host} as markdown`, `scrape ${host}`);
211
- out.push("fetch URL as markdown", "scrape webpage to markdown");
212
- return out;
213
- }
214
-
215
- /** URL → hostname or null. */
216
- function safeHost(url: string): string | null {
217
- try {
218
- return new URL(url).hostname.toLowerCase();
219
- } catch {
220
- return null;
221
- }
222
- }
223
-
224
- /**
225
- * Among the candidate tools, prefer one whose name or description signals
226
- * markdown output (contains "markdown", "md", "Docmd", etc.). Falls back
227
- * to anything that looks like a generic fetch/scrape verb, and finally
228
- * to the first candidate so we always try something.
229
- */
230
- function pickTool(tools: McpxToolDescriptor[]): McpxToolDescriptor | null {
231
- const score = (t: McpxToolDescriptor) => {
232
- const hay = `${t.tool.name} ${t.tool.description ?? ""}`.toLowerCase();
233
- let s = 0;
234
- if (/markdown|docmd|asmd|\bmd\b/.test(hay)) s += 5;
235
- if (/scrape|extract|fetch|get|read/.test(hay)) s += 2;
236
- if (/url|web|html|page/.test(hay)) s += 1;
237
- return s;
238
- };
239
- const sorted = [...tools].sort((a, b) => score(b) - score(a));
240
- return sorted[0] ?? null;
241
- }
242
-
243
- /**
244
- * Build the argument object the mcpx fetcher tool likely accepts. We can't
245
- * know the schema without calling info(), so we build a permissive bag with
246
- * the common shapes (`{url, format: "markdown", formats: ["markdown"]}`)
247
- * and trust the underlying tool to ignore unknown fields.
144
+ * Detect MCP `CallToolResult` envelopes that signal tool failure. MCP
145
+ * tool errors don't throw — they return `{ isError: true, content: [...] }`
146
+ * — so callers must check this explicitly before treating the content
147
+ * as a successful payload. Used by the refresh runner; the agent loop
148
+ * has its own preview-aware check.
248
149
  */
249
- function buildArgs(toolName: string, url: string): Record<string, unknown> {
250
- const args: Record<string, unknown> = { url };
251
- if (/markdown|md/i.test(toolName)) args.format = "markdown";
252
- args.formats = ["markdown"];
253
- return args;
254
- }
255
-
256
- /** Pull a string out of the heterogeneous shapes mcpx tools return. */
257
- function extractText(result: unknown): string {
258
- if (typeof result === "string") return result;
259
- if (result && typeof result === "object") {
260
- const maybe = result as Record<string, unknown>;
261
- if (typeof maybe.text === "string") return maybe.text;
262
- if (typeof maybe.content === "string") return maybe.content;
263
- if (typeof maybe.markdown === "string") return maybe.markdown;
264
- if (Array.isArray(maybe.content)) {
265
- const out: string[] = [];
266
- for (const c of maybe.content) {
267
- if (c && typeof c === "object") {
268
- const inner = c as Record<string, unknown>;
269
- if (typeof inner.text === "string") out.push(inner.text);
270
- }
271
- }
272
- if (out.length > 0) return out.join("\n\n");
273
- }
274
- }
275
- try {
276
- return JSON.stringify(result);
277
- } catch {
278
- return "";
279
- }
150
+ export function isMcpToolError(result: unknown): boolean {
151
+ if (!result || typeof result !== "object") return false;
152
+ return (result as { isError?: unknown }).isError === true;
280
153
  }
@@ -3,6 +3,7 @@ import { upsertBlob } from "../db/blobs.ts";
3
3
  import { insertChunksForVersion, rebuildFts } from "../db/chunks.ts";
4
4
  import { type FetcherKind, getCurrent, insertVersion, millisIso, type SourceType } from "../db/files.ts";
5
5
  import { asHelpful, HelpfulError } from "../errors.ts";
6
+ import { logger } from "../output/logger.ts";
6
7
  import { chunkDeterministic } from "./chunker.ts";
7
8
  import { convert } from "./converter/index.ts";
8
9
  import { describe } from "./describer.ts";
@@ -188,12 +189,32 @@ async function ingestUrl(
188
189
  ): Promise<IngestResult> {
189
190
  const mcpxAdapter = ctx.mcpx
190
191
  ? {
191
- async listTools() {
192
- const tools = await ctx.mcpx!.listTools();
193
- return tools;
192
+ async search(query: string, options?: { keywordOnly?: boolean; semanticOnly?: boolean }) {
193
+ try {
194
+ const results = await ctx.mcpx!.search(query, options);
195
+ return results.map((r) => ({
196
+ server: r.server,
197
+ tool: r.tool,
198
+ description: r.description ?? undefined,
199
+ score: r.score,
200
+ matchType: r.matchType ?? undefined,
201
+ }));
202
+ } catch (err) {
203
+ logger.debug(`mcpx.search(${query}) failed: ${err instanceof Error ? err.message : String(err)}`);
204
+ return [];
205
+ }
194
206
  },
195
- async exec(server: string, tool: string, args: Record<string, unknown>) {
196
- return ctx.mcpx!.exec(server, tool, args);
207
+ async listTools(server?: string) {
208
+ const tools = await ctx.mcpx!.listTools(server);
209
+ return tools.map((t) => ({ server: t.server, tool: { name: t.tool.name, description: t.tool.description } }));
210
+ },
211
+ async info(server: string, tool: string) {
212
+ const t = await ctx.mcpx!.info(server, tool);
213
+ if (!t) return undefined;
214
+ return { name: t.name, description: t.description, inputSchema: t.inputSchema };
215
+ },
216
+ async exec(server: string, tool: string, args?: Record<string, unknown>) {
217
+ return ctx.mcpx!.exec(server, tool, args ?? {});
197
218
  },
198
219
  }
199
220
  : null;
@@ -212,7 +233,11 @@ async function ingestUrl(
212
233
  };
213
234
 
214
235
  try {
215
- const fetched = await fetchRemote(url, { hint: input.fetcher_hint, mcpx: mcpxAdapter });
236
+ const fetched = await fetchRemote(url, {
237
+ hint: input.fetcher_hint,
238
+ mcpx: mcpxAdapter,
239
+ llm: ctx.config.llm,
240
+ });
216
241
  result.mime_type = fetched.mimeType;
217
242
  result.size_bytes = fetched.bytes.byteLength;
218
243
  result.fetcher = fetched.fetcher;
@@ -583,6 +608,7 @@ function summarize(entries: IngestEntryResult[]): IngestResult {
583
608
  }
584
609
 
585
610
  function errorMessage(err: unknown): string {
611
+ if (err instanceof HelpfulError) return `${err.message} — ${err.hint}`;
586
612
  if (err instanceof Error) return err.message;
587
613
  return String(err);
588
614
  }
@@ -8,7 +8,7 @@ import { chunkDeterministic } from "../ingest/chunker.ts";
8
8
  import { convert } from "../ingest/converter/index.ts";
9
9
  import { describe } from "../ingest/describer.ts";
10
10
  import { embed } from "../ingest/embedder.ts";
11
- import { fetchRemote } from "../ingest/fetcher.ts";
11
+ import { fetchRemote, isMcpToolError } from "../ingest/fetcher.ts";
12
12
  import { mimeFromPath, readLocalFile, sha256Hex } from "../ingest/local-reader.ts";
13
13
  import { buildSearchText } from "../ingest/search-text.ts";
14
14
 
@@ -192,6 +192,14 @@ async function replayFetch(
192
192
  if (cur.fetcher === "mcpx" && cur.fetcher_server && cur.fetcher_tool && mcpx) {
193
193
  const args = cur.fetcher_args ?? {};
194
194
  const result = await mcpx.exec(cur.fetcher_server, cur.fetcher_tool, args);
195
+ if (isMcpToolError(result)) {
196
+ const detail = extractText(result).trim();
197
+ throw new HelpfulError({
198
+ kind: "network_error",
199
+ message: `mcpx tool ${cur.fetcher_server}/${cur.fetcher_tool} returned isError=true${detail ? `: ${detail}` : ""}`,
200
+ hint: `Re-add with a working fetcher: \`membot remove ${cur.logical_path}\` then \`membot add ${cur.source_path} --fetcher http\` (or another --fetcher hint).`,
201
+ });
202
+ }
195
203
  const text = extractText(result);
196
204
  const bytes = new TextEncoder().encode(text);
197
205
  return {