membot 0.4.2 → 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/.claude/skills/membot.md +1 -0
- package/.cursor/rules/membot.mdc +1 -0
- package/LICENSE +21 -0
- package/README.md +15 -2
- package/package.json +1 -1
- package/patches/@evantahler%2Fmcpx@0.21.4.patch +17 -10
- package/scripts/apply-patches.sh +7 -5
- package/src/cli.ts +2 -0
- package/src/commands/config.ts +494 -0
- package/src/config/loader.ts +12 -10
- package/src/config/schemas.ts +1 -1
- package/src/ingest/agent-fetcher.ts +564 -0
- package/src/ingest/fetcher.ts +78 -205
- package/src/ingest/ingest.ts +32 -6
- package/src/refresh/runner.ts +9 -1
package/src/ingest/fetcher.ts
CHANGED
|
@@ -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
|
|
33
|
-
*
|
|
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
|
|
37
|
-
mcpx?:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
|
58
|
-
if (
|
|
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
|
|
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
|
|
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
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
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
|
}
|
package/src/ingest/ingest.ts
CHANGED
|
@@ -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
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
196
|
-
|
|
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, {
|
|
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
|
}
|
package/src/refresh/runner.ts
CHANGED
|
@@ -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 {
|