Repository: arabold/docs-mcp-server (ts)

Issue:
Feature: Add fetch_url tool to CLI and MCP server
Implement a new tool `fetch_url` that fetches the content of a single URL and returns it as processed Markdown.

**Plan:**

1.  **Create Core Tool Logic (`src/tools/FetchUrlTool.ts`):**
    *   Define `FetchUrlTool` class with an `execute(url: string)` method.
    *   Use `HttpFetcher` to get raw HTML.
    *   Use `HtmlProcessor` to convert HTML to Markdown.
    *   Return Markdown string.
    *   Include error handling.
    *   Define input JSON schema: `{ "type": "object", "properties": { "url": { "type": "string", "format": "uri" } }, "required": ["url"] }`.

2.  **Integrate with CLI (`src/cli.ts`):**
    *   Add `fetch-url <url>` command using `commander`.
    *   Instantiate `FetchUrlTool` and call `execute`.
    *   Print result to stdout.

3.  **Integrate with MCP Server (`src/mcp/index.ts`):**
    *   Import `FetchUrlTool`.
    *   Add `fetch_url` tool definition to MCP server setup, linking to `FetchUrlTool.execute` and its schema.

4.  **Add Unit Tests (`src/tools/FetchUrlTool.test.ts`):**
    *   Create tests for `FetchUrlTool`.
    *   Mock `HttpFetcher` and `HtmlProcessor`.
    *   Verify output and error handling.

5.  **Update Documentation (`README.md`):**
    *   Document `docs-cli fetch-url <url>` command.
    *   Document `fetch_url` MCP tool.

**Implementation Notes:**

*   Leverage existing `HttpFetcher` and `HtmlProcessor` from the `scraper` module.
*   Follow project architecture guidelines (place core logic in `tools/`).
*   Adhere to coding standards and add TSDoc.

Pull request:
feat: Add fetch-url tool to CLI and MCP server
Added a new `fetch-url` tool that supports both HTTP and local file URLs.

### Changes

- Added `FetchUrlTool` with support for both HTTP and local file URLs via the existing `ContentFetcher` interface
- Implemented CLI command `fetch-url` with `--no-follow-redirects` flag
- Added MCP tool `fetch_url` with `followRedirects` option
- Added comprehensive tests covering both HTTP and file URL scenarios
- Updated documentation in README.md and ARCHITECTURE.md

### Testing

The changes include extensive test coverage:
- HTTP URL fetching with redirect handling
- Local file URL fetching
- Error handling for invalid URLs
- Proper handling of the `followRedirects` option

Closes #34

Patch:
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 9edf69f..2161e35 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -212,7 +212,7 @@ This design allows easy addition of new embedding providers while maintaining co
 
 **Database Location:** The application determines the database file (`documents.db`) location dynamically:
 
-1. It first checks for a `.store` directory in the current working directory (`process.cwd()`). If `.store/documents.db` exists, it uses this path. This prioritizes local development databases.
+1. It first checks for a `.store` directory in the current project directory. If `.store/documents.db` exists, it uses this path. This prioritizes local development databases.
 2. If the local `.store/documents.db` does not exist, it defaults to a standard, OS-specific application data directory (e.g., `~/Library/Application Support/docs-mcp-server/` on macOS, `~/.local/share/docs-mcp-server/` on Linux) determined using the `env-paths` library. This ensures a stable, persistent location when running via `npx` or outside a local project context.
 
 Documents are stored with URLs and sequential ordering to maintain source context:
diff --git a/README.md b/README.md
index f41a9b0..7e01b07 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@ The server exposes MCP tools for:
 - Listing indexed libraries (`list_libraries`).
 - Finding appropriate versions (`find_version`).
 - Removing indexed documents (`remove_docs`).
+- Fetching single URLs (`fetch_url`): Fetches a URL and returns its content as Markdown.
 
 ## Configuration
 
@@ -250,11 +251,31 @@ npx -y --package=@arabold/docs-mcp-server docs-cli --help
 ```bash
 docs-cli scrape --help
 docs-cli search --help
+docs-cli fetch-url --help
 docs-cli find-version --help
 docs-cli remove --help
 docs-cli list --help
 ```
 
+### Fetching Single URLs (`fetch-url`)
+
+Fetches a single URL and converts its content to Markdown. Unlike `scrape`, this command does not crawl links or store the content.
+
+```bash
+docs-cli fetch-url <url> [options]
+```
+
+**Options:**
+
+- `--no-follow-redirects`: Disable following HTTP redirects (default: follow redirects)
+
+**Examples:**
+
+```bash
+# Fetch a URL and convert to Markdown
+docs-cli fetch-url https://example.com/page.html
+```
+
 ### Scraping Documentation (`scrape`)
 
 Scrapes and indexes documentation from a given URL for a specific library.
diff --git a/src/cli.ts b/src/cli.ts
index 19d222b..481d4e4 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -3,8 +3,16 @@ import "dotenv/config";
 import { Command } from "commander";
 import packageJson from "../package.json";
 import { PipelineManager } from "./pipeline/PipelineManager";
+import { FileFetcher, HttpFetcher } from "./scraper/fetcher";
+import { HtmlProcessor } from "./scraper/processor";
 import { DocumentManagementService } from "./store/DocumentManagementService";
-import { FindVersionTool, ListLibrariesTool, ScrapeTool, SearchTool } from "./tools";
+import {
+  FetchUrlTool,
+  FindVersionTool,
+  ListLibrariesTool,
+  ScrapeTool,
+  SearchTool,
+} from "./tools";
 import { LogLevel, setLogLevel } from "./utils/logger";
 
 const formatOutput = (data: unknown) => JSON.stringify(data, null, 2);
@@ -27,6 +35,11 @@ async function main() {
       findVersion: new FindVersionTool(docService),
       scrape: new ScrapeTool(docService, pipelineManager), // Pass manager
       search: new SearchTool(docService),
+      fetchUrl: new FetchUrlTool(
+        new HttpFetcher(),
+        new FileFetcher(),
+        new HtmlProcessor(),
+      ),
     };
 
     const program = new Command();
@@ -184,6 +197,21 @@ async function main() {
         }
       });
 
+    program
+      .command("fetch-url <url>")
+      .description("Fetch a URL and convert its content to Markdown")
+      .option(
+        "--no-follow-redirects",
+        "Disable following HTTP redirects (default: follow redirects)",
+      )
+      .action(async (url, options) => {
+        const content = await tools.fetchUrl.execute({
+          url,
+          followRedirects: options.followRedirects,
+        });
+        console.log(content);
+      });
+
     // Hook to set log level after parsing global options but before executing command action
     program.hook("preAction", (thisCommand) => {
       // Global options are attached to the program (thisCommand)
diff --git a/src/mcp/index.ts b/src/mcp/index.ts
index c0c15c4..76e8d8d 100644
--- a/src/mcp/index.ts
+++ b/src/mcp/index.ts
@@ -5,9 +5,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
 import { z } from "zod";
 import { PipelineManager } from "../pipeline/PipelineManager";
 import { PipelineJobStatus } from "../pipeline/types";
+import { FileFetcher, HttpFetcher } from "../scraper/fetcher";
+import { HtmlProcessor } from "../scraper/processor";
 import { DocumentManagementService } from "../store/DocumentManagementService";
 import {
   CancelJobTool,
+  FetchUrlTool,
   FindVersionTool,
   GetJobInfoTool,
   ListJobsTool,
@@ -39,15 +42,17 @@ export async function startServer() {
     const tools = {
       listLibraries: new ListLibrariesTool(docService),
       findVersion: new FindVersionTool(docService),
-      // TODO: Update ScrapeTool constructor if needed to accept PipelineManager
-      // ScrapeTool currently uses docService.getPipelineManager() which doesn't exist.
-      // Pass both docService and pipelineManager to ScrapeTool constructor
       scrape: new ScrapeTool(docService, pipelineManager),
       search: new SearchTool(docService),
       listJobs: new ListJobsTool(pipelineManager),
       getJobInfo: new GetJobInfoTool(pipelineManager),
       cancelJob: new CancelJobTool(pipelineManager),
-      remove: new RemoveTool(docService), // Instantiate RemoveTool
+      remove: new RemoveTool(docService),
+      fetchUrl: new FetchUrlTool(
+        new HttpFetcher(),
+        new FileFetcher(),
+        new HtmlProcessor(),
+      ),
     };
 
     const server = new McpServer(
@@ -305,6 +310,30 @@ ${formattedResults.join("")}`,
       },
     );
 
+    // Fetch URL tool
+    server.tool(
+      "fetch_url",
+      "Fetch a single URL and convert its content to Markdown",
+      {
+        url: z.string().url().describe("The URL to fetch and convert to markdown"),
+        followRedirects: z
+          .boolean()
+          .optional()
+          .default(true)
+          .describe("Whether to follow HTTP redirects (3xx responses)"),
+      },
+      async ({ url, followRedirects }) => {
+        try {
+          const result = await tools.fetchUrl.execute({ url, followRedirects });
+          return createResponse(result);
+        } catch (error) {
+          return createError(
+            `Failed to fetch URL: ${error instanceof Error ? error.message : String(error)}`,
+          );
+        }
+      },
+    );
+
     // Cancel job tool
     server.tool(
       "cancel_job",
diff --git a/src/scraper/processor/types.ts b/src/scraper/processor/types.ts
index 0c7c3fe..9bc9018 100644
--- a/src/scraper/processor/types.ts
+++ b/src/scraper/processor/types.ts
@@ -1,5 +1,7 @@
 import type { RawContent } from "../fetcher/types";
 
+export type { RawContent };
+
 /**
  * Processed content in normalized form (markdown)
  * with extracted metadata and references
diff --git a/src/tools/FetchUrlTool.ts b/src/tools/FetchUrlTool.ts
new file mode 100644
index 0000000..6b9fbf6
--- /dev/null
+++ b/src/tools/FetchUrlTool.ts
@@ -0,0 +1,92 @@
+import type { ContentFetcher, FileFetcher, HttpFetcher } from "../scraper/fetcher";
+import type { HtmlProcessor } from "../scraper/processor";
+import { ScraperError } from "../utils/errors";
+import { logger } from "../utils/logger";
+import { ToolError } from "./errors";
+
+export interface FetchUrlToolOptions {
+  /**
+   * The URL to fetch and convert to markdown.
+   * Must be a valid HTTP/HTTPS URL or file:// URL.
+   */
+  url: string;
+
+  /**
+   * Whether to follow HTTP redirects.
+   * @default true
+   */
+  followRedirects?: boolean;
+}
+
+/**
+ * Tool for fetching a single URL and converting its content to Markdown.
+ * Unlike scrape_docs, this tool only processes one page without crawling
+ * or storing the content.
+ *
+ * Supports both HTTP/HTTPS URLs and local file URLs (file://).
+ */
+export class FetchUrlTool {
+  /**
+   * Collection of fetchers that will be tried in order for a given URL.
+   */
+  private readonly fetchers: ContentFetcher[];
+
+  constructor(
+    httpFetcher: HttpFetcher,
+    fileFetcher: FileFetcher,
+    private readonly processor: HtmlProcessor,
+  ) {
+    this.fetchers = [httpFetcher, fileFetcher];
+  }
+
+  /**
+   * Fetches content from a URL and converts it to Markdown.
+   * Supports both HTTP/HTTPS URLs and local file URLs (file://).
+   * @returns The processed Markdown content
+   * @throws {ToolError} If fetching or processing fails
+   */
+  async execute(options: FetchUrlToolOptions): Promise<string> {
+    const { url } = options;
+
+    // Check all fetchers first (helpful for testing and future extensions)
+    const canFetchResults = this.fetchers.map((f) => f.canFetch(url));
+
+    // Find an appropriate fetcher for this URL
+    const fetcherIndex = canFetchResults.findIndex((result) => result === true);
+    if (fetcherIndex === -1) {
+      throw new ToolError(
+        `Invalid URL: ${url}. Must be an HTTP/HTTPS URL or a file:// URL.`,
+        this.constructor.name,
+      );
+    }
+
+    const fetcher = this.fetchers[fetcherIndex];
+
+    try {
+      // Fetch the content
+      logger.info(`📡 Fetching ${url}...`);
+      const rawContent = await fetcher.fetch(url, {
+        followRedirects: options.followRedirects ?? true,
+        maxRetries: 3,
+      });
+
+      // Process the HTML to Markdown
+      logger.info("🔄 Converting to Markdown...");
+      const processed = await this.processor.process(rawContent);
+
+      logger.info(`✅ Successfully converted ${url} to Markdown`);
+      return processed.content;
+    } catch (error) {
+      if (error instanceof ScraperError) {
+        throw new ToolError(
+          `Failed to fetch or process URL: ${error.message}`,
+          this.constructor.name,
+        );
+      }
+      throw new ToolError(
+        `Failed to fetch or process URL: ${error instanceof Error ? error.message : String(error)}`,
+        this.constructor.name,
+      );
+    }
+  }
+}
diff --git a/src/tools/index.ts b/src/tools/index.ts
index fd1e28b..f0238f1 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -1,9 +1,10 @@
-export * from "./SearchTool";
+export * from "./CancelJobTool";
+export * from "./errors";
+export * from "./FetchUrlTool";
 export * from "./FindVersionTool";
-export * from "./ListLibrariesTool";
-export * from "./ScrapeTool";
-export * from "./ListJobsTool";
 export * from "./GetJobInfoTool";
-export * from "./CancelJobTool";
+export * from "./ListJobsTool";
+export * from "./ListLibrariesTool";
 export * from "./RemoveTool";
-export * from "./errors";
+export * from "./ScrapeTool";
+export * from "./SearchTool";


Test patch:
diff --git a/src/tools/FetchUrlTool.test.ts b/src/tools/FetchUrlTool.test.ts
new file mode 100644
index 0000000..29b4170
--- /dev/null
+++ b/src/tools/FetchUrlTool.test.ts
@@ -0,0 +1,331 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { FileFetcher, HttpFetcher } from "../scraper/fetcher";
+import type { HtmlProcessor } from "../scraper/processor";
+import { ScraperError } from "../utils/errors";
+import { logger } from "../utils/logger";
+import { FetchUrlTool, type FetchUrlToolOptions } from "./FetchUrlTool";
+import { ToolError } from "./errors";
+
+// Mock dependencies
+vi.mock("../utils/logger");
+vi.mock("../scraper/fetcher/HttpFetcher");
+vi.mock("../scraper/fetcher/FileFetcher");
+vi.mock("../scraper/processor/HtmlProcessor");
+
+describe("FetchUrlTool", () => {
+  let mockHttpFetcher: Partial<HttpFetcher>;
+  let mockFileFetcher: Partial<FileFetcher>;
+  let mockHtmlProcessor: Partial<HtmlProcessor>;
+  let fetchUrlTool: FetchUrlTool;
+
+  beforeEach(() => {
+    // Reset mocks before each test
+    vi.resetAllMocks();
+
+    // Setup mock HttpFetcher
+    mockHttpFetcher = {
+      canFetch: vi.fn(),
+      fetch: vi.fn(),
+    };
+
+    // Setup mock FileFetcher
+    mockFileFetcher = {
+      canFetch: vi.fn(),
+      fetch: vi.fn(),
+    };
+
+    // Setup mock HtmlProcessor
+    mockHtmlProcessor = {
+      process: vi.fn(),
+    };
+
+    // Create instance of the tool with the mock dependencies
+    fetchUrlTool = new FetchUrlTool(
+      mockHttpFetcher as HttpFetcher,
+      mockFileFetcher as FileFetcher,
+      mockHtmlProcessor as HtmlProcessor,
+    );
+  });
+
+  it("should fetch a URL and convert it to markdown", async () => {
+    const url = "https://example.com/docs";
+    const options: FetchUrlToolOptions = { url };
+
+    // Mock fetchers to determine which one should handle this URL
+    mockHttpFetcher.canFetch = vi.fn().mockReturnValue(true);
+    mockFileFetcher.canFetch = vi.fn().mockReturnValue(false);
+
+    // Mock the fetcher response
+    mockHttpFetcher.fetch = vi.fn().mockResolvedValue({
+      content: "<h1>Hello World</h1>",
+      mimeType: "text/html",
+      source: url,
+    });
+
+    // Mock the processor response
+    mockHtmlProcessor.process = vi.fn().mockResolvedValue({
+      content: "# Hello World",
+      title: "Example Page",
+      source: url,
+      links: [],
+      metadata: {},
+    });
+
+    const result = await fetchUrlTool.execute(options);
+
+    // Verify the call chain and result
+    expect(mockHttpFetcher.canFetch).toHaveBeenCalledWith(url);
+    expect(mockFileFetcher.canFetch).toHaveBeenCalledWith(url);
+    expect(mockHttpFetcher.fetch).toHaveBeenCalledWith(url, {
+      followRedirects: true,
+      maxRetries: 3,
+    });
+    expect(mockFileFetcher.fetch).not.toHaveBeenCalled();
+    expect(mockHtmlProcessor.process).toHaveBeenCalledWith({
+      content: "<h1>Hello World</h1>",
+      mimeType: "text/html",
+      source: url,
+    });
+    expect(result).toBe("# Hello World");
+    expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Fetching"));
+    expect(logger.info).toHaveBeenCalledWith(
+      expect.stringContaining("Converting to Markdown"),
+    );
+    expect(logger.info).toHaveBeenCalledWith(
+      expect.stringContaining("Successfully converted"),
+    );
+  });
+
+  it("should fetch a file URL and convert it to markdown", async () => {
+    const url = "file:///path/to/document.html";
+    const options: FetchUrlToolOptions = { url };
+
+    // Mock fetchers to determine which one should handle this URL
+    mockHttpFetcher.canFetch = vi.fn().mockReturnValue(false);
+    mockFileFetcher.canFetch = vi.fn().mockReturnValue(true);
+
+    // Mock the fetcher response
+    mockFileFetcher.fetch = vi.fn().mockResolvedValue({
+      content: "<h1>Local File Content</h1>",
+      mimeType: "text/html",
+      source: url,
+      encoding: "utf-8",
+    });
+
+    // Mock the processor response
+    mockHtmlProcessor.process = vi.fn().mockResolvedValue({
+      content: "# Local File Content",
+      title: "Local Document",
+      source: url,
+      links: [],
+      metadata: {},
+    });
+
+    const result = await fetchUrlTool.execute(options);
+
+    // Verify the call chain and result
+    expect(mockHttpFetcher.canFetch).toHaveBeenCalledWith(url);
+    expect(mockFileFetcher.canFetch).toHaveBeenCalledWith(url);
+    expect(mockFileFetcher.fetch).toHaveBeenCalledWith(url, {
+      followRedirects: true,
+      maxRetries: 3,
+    });
+    expect(mockHttpFetcher.fetch).not.toHaveBeenCalled();
+    expect(mockHtmlProcessor.process).toHaveBeenCalledWith({
+      content: "<h1>Local File Content</h1>",
+      mimeType: "text/html",
+      source: url,
+      encoding: "utf-8",
+    });
+    expect(result).toBe("# Local File Content");
+    expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Fetching"));
+    expect(logger.info).toHaveBeenCalledWith(
+      expect.stringContaining("Converting to Markdown"),
+    );
+    expect(logger.info).toHaveBeenCalledWith(
+      expect.stringContaining("Successfully converted"),
+    );
+  });
+
+  it("should throw ToolError if URL is invalid", async () => {
+    const invalidUrl = "invalid://example.com";
+    const options: FetchUrlToolOptions = { url: invalidUrl };
+
+    // Mock the fetchers to reject the URL
+    mockHttpFetcher.canFetch = vi.fn().mockReturnValue(false);
+    mockFileFetcher.canFetch = vi.fn().mockReturnValue(false);
+
+    await expect(fetchUrlTool.execute(options)).rejects.toThrow(ToolError);
+    expect(mockHttpFetcher.canFetch).toHaveBeenCalledWith(invalidUrl);
+    expect(mockFileFetcher.canFetch).toHaveBeenCalledWith(invalidUrl);
+    expect(mockHttpFetcher.fetch).not.toHaveBeenCalled();
+    expect(mockFileFetcher.fetch).not.toHaveBeenCalled();
+    expect(mockHtmlProcessor.process).not.toHaveBeenCalled();
+  });
+
+  it("should handle ScraperError during fetch", async () => {
+    const url = "https://example.com/broken";
+    const options: FetchUrlToolOptions = { url };
+
+    mockHttpFetcher.canFetch = vi.fn().mockReturnValue(true);
+    mockHttpFetcher.fetch = vi.fn().mockRejectedValue(new ScraperError("Network error"));
+
+    await expect(fetchUrlTool.execute(options)).rejects.toThrow(ToolError);
+    expect(mockHttpFetcher.canFetch).toHaveBeenCalledWith(url);
+    expect(mockHttpFetcher.fetch).toHaveBeenCalled();
+    expect(mockHtmlProcessor.process).not.toHaveBeenCalled();
+  });
+
+  it("should handle non-ScraperError during fetch", async () => {
+    const url = "https://example.com/error";
+    const options: FetchUrlToolOptions = { url };
+
+    mockHttpFetcher.canFetch = vi.fn().mockReturnValue(true);
+    mockHttpFetcher.fetch = vi.fn().mockRejectedValue(new Error("Unexpected error"));
+
+    await expect(fetchUrlTool.execute(options)).rejects.toThrow(ToolError);
+    expect(mockHttpFetcher.canFetch).toHaveBeenCalledWith(url);
+    expect(mockHttpFetcher.fetch).toHaveBeenCalled();
+    expect(mockHtmlProcessor.process).not.toHaveBeenCalled();
+  });
+
+  it("should handle ScraperError during processing", async () => {
+    const url = "https://example.com/process-error";
+    const options: FetchUrlToolOptions = { url };
+
+    mockHttpFetcher.canFetch = vi.fn().mockReturnValue(true);
+    mockHttpFetcher.fetch = vi.fn().mockResolvedValue({
+      content: "<h1>Problematic Content</h1>",
+      mimeType: "text/html",
+      source: url,
+    });
+    mockHtmlProcessor.process = vi
+      .fn()
+      .mockRejectedValue(new ScraperError("Processing failed"));
+
+    await expect(fetchUrlTool.execute(options)).rejects.toThrow(ToolError);
+    expect(mockHttpFetcher.canFetch).toHaveBeenCalledWith(url);
+    expect(mockHttpFetcher.fetch).toHaveBeenCalled();
+    expect(mockHtmlProcessor.process).toHaveBeenCalled();
+  });
+
+  it("should handle ScraperError during file fetch", async () => {
+    const url = "file:///path/to/missing/file.html";
+    const options: FetchUrlToolOptions = { url };
+
+    // Mock fetchers to determine which one should handle this URL
+    mockHttpFetcher.canFetch = vi.fn().mockReturnValue(false);
+    mockFileFetcher.canFetch = vi.fn().mockReturnValue(true);
+
+    // Mock file fetcher to throw an error
+    mockFileFetcher.fetch = vi.fn().mockRejectedValue(new ScraperError("File not found"));
+
+    await expect(fetchUrlTool.execute(options)).rejects.toThrow(ToolError);
+    expect(mockHttpFetcher.canFetch).toHaveBeenCalledWith(url);
+    expect(mockFileFetcher.canFetch).toHaveBeenCalledWith(url);
+    expect(mockFileFetcher.fetch).toHaveBeenCalled();
+    expect(mockHttpFetcher.fetch).not.toHaveBeenCalled();
+    expect(mockHtmlProcessor.process).not.toHaveBeenCalled();
+  });
+
+  it("should handle non-ScraperError during file fetch", async () => {
+    const url = "file:///path/to/inaccessible/file.html";
+    const options: FetchUrlToolOptions = { url };
+
+    // Mock fetchers to determine which one should handle this URL
+    mockHttpFetcher.canFetch = vi.fn().mockReturnValue(false);
+    mockFileFetcher.canFetch = vi.fn().mockReturnValue(true);
+
+    // Mock file fetcher to throw a different kind of error
+    mockFileFetcher.fetch = vi.fn().mockRejectedValue(new Error("Permission denied"));
+
+    await expect(fetchUrlTool.execute(options)).rejects.toThrow(ToolError);
+    expect(mockHttpFetcher.canFetch).toHaveBeenCalledWith(url);
+    expect(mockFileFetcher.canFetch).toHaveBeenCalledWith(url);
+    expect(mockFileFetcher.fetch).toHaveBeenCalled();
+    expect(mockHttpFetcher.fetch).not.toHaveBeenCalled();
+    expect(mockHtmlProcessor.process).not.toHaveBeenCalled();
+  });
+
+  it("should respect followRedirects=false for HTTP URLs", async () => {
+    const url = "https://example.com/docs";
+    const options: FetchUrlToolOptions = { url, followRedirects: false };
+
+    mockHttpFetcher.canFetch = vi.fn().mockReturnValue(true);
+    mockFileFetcher.canFetch = vi.fn().mockReturnValue(false);
+    mockHttpFetcher.fetch = vi.fn().mockResolvedValue({
+      content: "<h1>No Redirects</h1>",
+      mimeType: "text/html",
+      source: url,
+    });
+    mockHtmlProcessor.process = vi.fn().mockResolvedValue({
+      content: "# No Redirects",
+      title: "Example Page",
+      source: url,
+      links: [],
+      metadata: {},
+    });
+
+    await fetchUrlTool.execute(options);
+
+    expect(mockHttpFetcher.fetch).toHaveBeenCalledWith(url, {
+      followRedirects: false,
+      maxRetries: 3,
+    });
+  });
+
+  it("should respect followRedirects=false for file URLs", async () => {
+    const url = "file:///path/to/document.html";
+    const options: FetchUrlToolOptions = { url, followRedirects: false };
+
+    mockHttpFetcher.canFetch = vi.fn().mockReturnValue(false);
+    mockFileFetcher.canFetch = vi.fn().mockReturnValue(true);
+    mockFileFetcher.fetch = vi.fn().mockResolvedValue({
+      content: "<h1>Local File</h1>",
+      mimeType: "text/html",
+      source: url,
+      encoding: "utf-8",
+    });
+    mockHtmlProcessor.process = vi.fn().mockResolvedValue({
+      content: "# Local File",
+      title: "Local Document",
+      source: url,
+      links: [],
+      metadata: {},
+    });
+
+    await fetchUrlTool.execute(options);
+
+    expect(mockFileFetcher.fetch).toHaveBeenCalledWith(url, {
+      followRedirects: false,
+      maxRetries: 3,
+    });
+  });
+
+  it("should use followRedirects=true by default for HTTP URLs", async () => {
+    const url = "https://example.com/docs";
+    const options: FetchUrlToolOptions = { url };
+
+    mockHttpFetcher.canFetch = vi.fn().mockReturnValue(true);
+    mockFileFetcher.canFetch = vi.fn().mockReturnValue(false);
+    mockHttpFetcher.fetch = vi.fn().mockResolvedValue({
+      content: "<h1>With Redirects</h1>",
+      mimeType: "text/html",
+      source: url,
+    });
+    mockHtmlProcessor.process = vi.fn().mockResolvedValue({
+      content: "# With Redirects",
+      title: "Example Page",
+      source: url,
+      links: [],
+      metadata: {},
+    });
+
+    await fetchUrlTool.execute(options);
+
+    expect(mockHttpFetcher.fetch).toHaveBeenCalledWith(url, {
+      followRedirects: true,
+      maxRetries: 3,
+    });
+  });
+});