Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions src/agent/exa-web-fetch-alias.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,10 @@ describe("built-in Exa web_fetch alias", () => {
await connect(toolset);
const result = await runTool(toolset, "web_fetch", { url: "ftp://example.com/file" });

expect(result.isError).toBe(true);
expect(result.content).toContain("http or https");
expect(result).not.toHaveProperty("isError");
expect(result.content).toBe(
'Error: Unsupported protocol "ftp:"; only http and https are allowed.',
);
expect(calls).toHaveLength(0);
} finally {
await toolset.dispose();
Expand All @@ -182,7 +184,7 @@ describe("built-in Exa web_fetch alias", () => {
try {
await connect(toolset);
const result = await runTool(toolset, "web_fetch", { url: "https://example.com" });
expect(result.isError).toBe(true);
expect(result).not.toHaveProperty("isError");
expect(result.content).toContain("Exa MCP");
expect(result.content).toContain("web_fetch_exa");
expect(calls).toHaveLength(0);
Expand All @@ -195,7 +197,7 @@ describe("built-in Exa web_fetch alias", () => {
try {
await connect(failed);
const result = await runTool(failed, "web_fetch", { url: "https://example.com" });
expect(result.isError).toBe(true);
expect(result).not.toHaveProperty("isError");
expect(result.content).toContain("Exa MCP");
expect(result.content).toContain("connection exploded");
expect(calls).toHaveLength(0);
Expand Down
57 changes: 55 additions & 2 deletions src/tools/web-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { createServer, type Server } from "node:http";
import type { MCPClient } from "../mcp/client.js";
import { createExaMCPWebFetchTool, runWebFetch, MAX_FETCH_BYTES } from "./web-fetch.js";
import { createDynamicToolRunner } from "../tui/dynamic-tool-runner.js";
import {
createExaMCPWebFetchTool,
createWebFetchTool,
runWebFetch,
MAX_FETCH_BYTES,
} from "./web-fetch.js";

let server: Server;
let baseUrl: string;
Expand Down Expand Up @@ -154,10 +160,57 @@ describe("createExaMCPWebFetchTool", () => {
callId: "timeout-call",
content:
"Error: Request to https://example.com timed out after 1s. Retry with a larger timeout parameter (up to 120s) if the site is slow.",
isError: true,
});
});

test("matches the complete native dynamic-runner result for protocol failures", async () => {
const exa = createTool(async () => "unused");
const nativeRunner = createDynamicToolRunner([createWebFetchTool()]);
const exaRunner = createDynamicToolRunner([exa]);
const call = {
id: "protocol-call",
name: "web_fetch",
arguments: { url: "ftp://example.com/file" },
};

const nativeResult = await nativeRunner.run(call, new AbortController().signal);
const exaResult = await exaRunner.run(call, new AbortController().signal);

expect(nativeResult).toEqual({
callId: "protocol-call",
content: 'Error: Unsupported protocol "ftp:"; only http and https are allowed.',
});
expect(exaResult).toEqual(nativeResult);
});

test("matches the complete native dynamic-runner result for timeout failures", async () => {
handler = (_req, _res) => undefined;
const exa = createTool(
async (_name, _args, signal) =>
new Promise<string>((_resolve, reject) => {
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
}),
);
const nativeRunner = createDynamicToolRunner([createWebFetchTool()]);
const exaRunner = createDynamicToolRunner([exa]);
const call = {
id: "timeout-call",
name: "web_fetch",
arguments: { url: `${baseUrl}/`, timeout: 1 },
};

const [nativeResult, exaResult] = await Promise.all([
nativeRunner.run(call, new AbortController().signal),
exaRunner.run(call, new AbortController().signal),
]);

expect(nativeResult).toEqual({
callId: "timeout-call",
content: `Error: Request to ${baseUrl}/ timed out after 1s. Retry with a larger timeout parameter (up to 120s) if the site is slow.`,
});
expect(exaResult).toEqual(nativeResult);
});

test("returns distinct markdown, text, and html representations", async () => {
handler = (_req, res) => {
res.writeHead(200, { "content-type": "text/html" });
Expand Down
13 changes: 3 additions & 10 deletions src/tools/web-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,6 @@ export function createExaMCPWebFetchTool(args: {
callId: call.id,
content:
"Error: web_fetch requires a non-empty url (http/https); format and timeout are optional.",
isError: true,
};
}

Expand All @@ -244,15 +243,13 @@ export function createExaMCPWebFetchTool(args: {
} catch {
return {
callId: call.id,
content: "Error: web_fetch URL must use http or https.",
isError: true,
content: `Error: Invalid URL: ${parsed.url}`,
};
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
return {
callId: call.id,
content: "Error: web_fetch URL must use http or https.",
isError: true,
content: `Error: Unsupported protocol "${url.protocol}"; only http and https are allowed.`,
};
}

Expand All @@ -261,7 +258,7 @@ export function createExaMCPWebFetchTool(args: {
if (format !== "markdown") {
const outcome = await runWebFetch(parsed.url, format, timeout);
if (!outcome.ok) {
return { callId: call.id, content: `Error: ${outcome.error}`, isError: true };
return { callId: call.id, content: `Error: ${outcome.error}` };
}
const suffix = outcome.truncated
? `\n\n[content truncated at ${MAX_FETCH_BYTES} bytes]`
Expand All @@ -288,15 +285,13 @@ export function createExaMCPWebFetchTool(args: {
return {
callId: call.id,
content: `Error: Exa MCP web_fetch unavailable: ${connection.error}`,
isError: true,
};
}
if (!connection.client.tools.some((tool) => tool.name === "web_fetch_exa")) {
return {
callId: call.id,
content:
"Error: Exa MCP web_fetch unavailable: connected Exa server did not advertise web_fetch_exa.",
isError: true,
};
}
const content = await connection.client.call(
Expand All @@ -313,13 +308,11 @@ export function createExaMCPWebFetchTool(args: {
return {
callId: call.id,
content: `Error: Request to ${parsed.url} timed out after ${timeoutSeconds}s. Retry with a larger timeout parameter (up to 120s) if the site is slow.`,
isError: true,
};
}
return {
callId: call.id,
content: `Error: Exa MCP web_fetch failed: ${err instanceof Error ? err.message : String(err)}`,
isError: true,
};
} finally {
if (timer !== undefined) clearTimeout(timer);
Expand Down
Loading