Repository: arabold/docs-mcp-server
Pull request #72 (2025-04-29 22:09:15)

feat: Implement streamable HTTP protocol


Patch:
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index c846067..75fcd80 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -13,6 +13,10 @@ The Documentation MCP Server is designed with a modular architecture that ensure
 - Other files use kebab-case or regular camelCase (e.g., `index.ts`, `scraper-service.ts`)
 - Avoid typecasting where possible. Never use `any` type but prefer `unknown` or `never`.
 
+## Configuration Management
+
+Default configuration values for the server and its components (e.g., pipeline concurrency, scraping limits) should be defined as constants within the `src/utils/config.ts` file. Avoid hardcoding default values directly within component logic; instead, import and use the constants from this central configuration file. This ensures consistency and simplifies future adjustments.
+
 ### Directory Structure
 
 ```
diff --git a/README.md b/README.md
index 49575a1..dc1cc3b 100644
--- a/README.md
+++ b/README.md
@@ -173,6 +173,23 @@ This approach is useful when you need local file access (e.g., indexing document
 
 3. **That's it!** The server will now be available to your AI assistant.
 
+### Option 3: Using npx with HTTP Protocol
+
+Similar to Option 2, this uses `npx` to run the latest published package without needing Docker or a local clone. However, this option starts the server using the Streamable HTTP protocol instead of the default stdio, making it accessible via HTTP endpoints. This is useful if you have multiple clients, you work with multiple code assistants in parallel, or want to expose the server to other applications.
+
+1.  **Ensure Node.js is installed.**
+2.  **Run the command:**
+
+    ```bash
+    # Ensure required environment variables like OPENAI_API_KEY are set
+    npx --package=@arabold/docs-mcp-server docs-server --protocol http --port 8000
+    ```
+
+    - `--protocol http`: Instructs the server to use the HTTP protocol.
+    - `--port <number>`: Specifies the listening port (default: 8000).
+
+    The server will expose endpoints like `/mcp` and `/sse` on the specified port.
+
 ## Using the CLI
 
 You can use the CLI to manage documentation directly, either via Docker or npx. **Important: Use the same method (Docker or npx) for both the server and CLI to ensure access to the same indexed documentation.**
@@ -256,154 +273,6 @@ For OpenAI-compatible APIs (like Ollama), use the `openai` provider with `OPENAI
 
 These variables can be set regardless of how you run the server (Docker, npx, or from source).
 
-## CLI Command Reference
-
-The `docs-cli` provides commands for managing the documentation index. Access it either via Docker (`docker run -v docs-mcp-data:/data ghcr.io/arabold/docs-mcp-server:latest docs-cli ...`) or `npx` (`npx -y --package=@arabold/docs-mcp-server docs-cli ...`).
-
-**General Help:**
-
-```bash
-docs-cli --help
-# or
-npx -y --package=@arabold/docs-mcp-server docs-cli --help
-```
-
-**Command Specific Help:** (Replace `docs-cli` with the `npx...` command if not installed globally)
-
-```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).
-- `--scrape-mode <mode>`: HTML processing strategy: 'fetch' (fast, less JS), 'playwright' (slow, full JS), 'auto' (default).
-
-**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.
-
-```bash
-docs-cli scrape <library> <url> [options]
-```
-
-**Options:**
-
-- `-v, --version <string>`: The specific version to associate with the scraped documents.
-  - Accepts full versions (`1.2.3`), pre-release versions (`1.2.3-beta.1`), or partial versions (`1`, `1.2` which are expanded to `1.0.0`, `1.2.0`).
-  - If omitted, the documentation is indexed as **unversioned**.
-- `-p, --max-pages <number>`: Maximum pages to scrape (default: 1000).
-- `-d, --max-depth <number>`: Maximum navigation depth (default: 3).
-- `-c, --max-concurrency <number>`: Maximum concurrent requests (default: 3).
-- `--scope <scope>`: Defines the crawling boundary: 'subpages' (default), 'hostname', or 'domain'.
-- `--no-follow-redirects`: Disable following HTTP redirects (default: follow redirects).
-- `--scrape-mode <mode>`: HTML processing strategy: 'fetch' (fast, less JS), 'playwright' (slow, full JS), 'auto' (default).
-- `--ignore-errors`: Ignore errors during scraping (default: true).
-
-**Examples:**
-
-```bash
-# Scrape React 18.2.0 docs
-docs-cli scrape react --version 18.2.0 https://react.dev/
-```
-
-### Searching Documentation (`search`)
-
-Searches the indexed documentation for a library, optionally filtering by version.
-
-```bash
-docs-cli search <library> <query> [options]
-```
-
-**Options:**
-
-- `-v, --version <string>`: The target version or range to search within.
-  - Supports exact versions (`18.0.0`), partial versions (`18`), or ranges (`18.x`).
-  - If omitted, searches the **latest** available indexed version.
-  - If a specific version/range doesn't match, it falls back to the latest indexed version _older_ than the target.
-  - To search **only unversioned** documents, explicitly pass an empty string: `--version ""`. (Note: Omitting `--version` searches latest, which _might_ be unversioned if no other versions exist).
-- `-l, --limit <number>`: Maximum number of results (default: 5).
-- `-e, --exact-match`: Only match the exact version specified (disables fallback and range matching) (default: false).
-
-**Examples:**
-
-```bash
-# Search latest React docs for 'hooks'
-docs-cli search react 'hooks'
-```
-
-### Finding Available Versions (`find-version`)
-
-Checks the index for the best matching version for a library based on a target, and indicates if unversioned documents exist.
-
-```bash
-docs-cli find-version <library> [options]
-```
-
-**Options:**
-
-- `-v, --version <string>`: The target version or range. If omitted, finds the latest available version.
-
-**Examples:**
-
-```bash
-# Find the latest indexed version for react
-docs-cli find-version react
-```
-
-### Listing Libraries (`list`)
-
-Lists all libraries currently indexed in the store.
-
-```bash
-docs-cli list
-```
-
-### Removing Documentation (`remove`)
-
-Removes indexed documents for a specific library and version.
-
-```bash
-docs-cli remove <library> [options]
-```
-
-**Options:**
-
-- `-v, --version <string>`: The specific version to remove. If omitted, removes **unversioned** documents for the library.
-
-**Examples:**
-
-```bash
-# Remove React 18.2.0 docs
-docs-cli remove react --version 18.2.0
-```
-
-### Version Handling Summary
-
-- **Scraping:** Requires a specific, valid version (`X.Y.Z`, `X.Y.Z-pre`, `X.Y`, `X`) or no version (for unversioned docs). Ranges (`X.x`) are invalid for scraping.
-- **Searching/Finding:** Accepts specific versions, partials, or ranges (`X.Y.Z`, `X.Y`, `X`, `X.x`). Falls back to the latest older version if the target doesn't match. Omitting the version targets the latest available. Explicitly searching `--version ""` targets unversioned documents.
-- **Unversioned Docs:** Libraries can have documentation stored without a specific version (by omitting `--version` during scrape). These can be searched explicitly using `--version ""`. The `find-version` command will also report if unversioned docs exist alongside any semver matches.
-
 ## Development & Advanced Setup
 
 This section covers running the server/CLI directly from the source code for development purposes. The primary usage method is now via the public Docker image as described in "Method 2".
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..19adb12
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,19 @@
+services:
+  docs-mcp-server:
+    image: ghcr.io/arabold/docs-mcp-server:latest
+    build:
+      context: .
+      dockerfile: Dockerfile
+    command: ["node", "dist/server.js", "--protocol", "http"]
+    # platform: linux/amd64
+    container_name: docs-mcp-server
+    ports:
+      - "8000:8000"
+    env_file:
+      - .env
+    volumes:
+      - docs-mcp-data:/data
+
+volumes:
+  docs-mcp-data:
+    name: docs-mcp-data
diff --git a/package-lock.json b/package-lock.json
index 5cc8c35..63d35f9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "@arabold/docs-mcp-server",
-  "version": "1.9.0",
+  "version": "1.10.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@arabold/docs-mcp-server",
-      "version": "1.9.0",
+      "version": "1.10.0",
       "hasInstallScript": true,
       "license": "MIT",
       "dependencies": {
@@ -16,7 +16,7 @@
         "@langchain/google-genai": "^0.2.3",
         "@langchain/google-vertexai": "^0.2.4",
         "@langchain/openai": "^0.5.0",
-        "@modelcontextprotocol/sdk": "^1.6.1",
+        "@modelcontextprotocol/sdk": "^1.10.2",
         "axios": "^1.8.3",
         "axios-retry": "^4.5.0",
         "better-sqlite3": "^11.9.1",
@@ -3302,9 +3302,9 @@
       "license": "BSD-2-Clause"
     },
     "node_modules/@modelcontextprotocol/sdk": {
-      "version": "1.8.0",
-      "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.8.0.tgz",
-      "integrity": "sha512-e06W7SwrontJDHwCawNO5SGxG+nU9AAx+jpHHZqGl/WrDBdWOpvirC+s58VpJTB5QemI4jTRcjWT4Pt3Q1NPQQ==",
+      "version": "1.10.2",
+      "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.10.2.tgz",
+      "integrity": "sha512-rb6AMp2DR4SN+kc6L1ta2NCpApyA9WYNx3CrTSZvGxq9wH71bRur+zRqPfg0vQ9mjywR7qZdX2RGHOPq3ss+tA==",
       "license": "MIT",
       "dependencies": {
         "content-type": "^1.0.5",
@@ -3313,7 +3313,7 @@
         "eventsource": "^3.0.2",
         "express": "^5.0.1",
         "express-rate-limit": "^7.5.0",
-        "pkce-challenge": "^4.1.0",
+        "pkce-challenge": "^5.0.0",
         "raw-body": "^3.0.0",
         "zod": "^3.23.8",
         "zod-to-json-schema": "^3.24.1"
@@ -16307,9 +16307,9 @@
       }
     },
     "node_modules/pkce-challenge": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-4.1.0.tgz",
-      "integrity": "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==",
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz",
+      "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==",
       "license": "MIT",
       "engines": {
         "node": ">=16.20.0"
diff --git a/package.json b/package.json
index 1d140a1..4fafe00 100644
--- a/package.json
+++ b/package.json
@@ -40,7 +40,7 @@
     "@langchain/google-genai": "^0.2.3",
     "@langchain/google-vertexai": "^0.2.4",
     "@langchain/openai": "^0.5.0",
-    "@modelcontextprotocol/sdk": "^1.6.1",
+    "@modelcontextprotocol/sdk": "^1.10.2",
     "axios": "^1.8.3",
     "axios-retry": "^4.5.0",
     "better-sqlite3": "^11.9.1",
diff --git a/src/cli.ts b/src/cli.ts
index 894708a..0675261 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -2,7 +2,6 @@
 import "dotenv/config";
 import { Command } from "commander";
 import packageJson from "../package.json";
-import { DEFAULT_MAX_CONCURRENCY, DEFAULT_MAX_DEPTH, DEFAULT_MAX_PAGES } from "./config";
 import { PipelineManager } from "./pipeline/PipelineManager";
 import { FileFetcher, HttpFetcher } from "./scraper/fetcher";
 import { ScrapeMode } from "./scraper/types"; // Import ScrapeMode enum
@@ -14,6 +13,11 @@ import {
   ScrapeTool,
   SearchTool,
 } from "./tools";
+import {
+  DEFAULT_MAX_CONCURRENCY,
+  DEFAULT_MAX_DEPTH,
+  DEFAULT_MAX_PAGES,
+} from "./utils/config";
 import { LogLevel, setLogLevel } from "./utils/logger";
 
 const formatOutput = (data: unknown) => JSON.stringify(data, null, 2);
@@ -178,7 +182,7 @@ async function main() {
       .description("Find the best matching version for a library")
       .option(
         "-v, --version <string>", // Add optional version flag
-        "Target version to match (optional, supports ranges)",
+        "Pattern to match (optional, supports ranges)",
       )
       .action(async (library, options) => {
         // Update action parameters
diff --git a/src/mcp/index.ts b/src/mcp/index.ts
index 2832fd9..d717d0f 100644
--- a/src/mcp/index.ts
+++ b/src/mcp/index.ts
@@ -1,561 +1,34 @@
-#!/usr/bin/env node
 import "dotenv/config";
-import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
-import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
-import { z } from "zod";
-import { DEFAULT_MAX_DEPTH, DEFAULT_MAX_PAGES } from "../config";
-import { PipelineManager } from "../pipeline/PipelineManager";
-import { PipelineJobStatus } from "../pipeline/types";
-import { FileFetcher, HttpFetcher } from "../scraper/fetcher";
-import { DocumentManagementService } from "../store/DocumentManagementService";
-import {
-  CancelJobTool,
-  FetchUrlTool,
-  FindVersionTool,
-  GetJobInfoTool,
-  ListJobsTool,
-  ListLibrariesTool,
-  RemoveTool,
-  ScrapeTool,
-  SearchTool,
-  VersionNotFoundError,
-} from "../tools";
-import { LogLevel, logger, setLogLevel } from "../utils/logger"; // Import LogLevel and setLogLevel
-import { createError, createResponse } from "./utils";
-
-export async function startServer() {
-  // Set the default log level for the server to ERROR
-  setLogLevel(LogLevel.ERROR);
-
-  const docService = new DocumentManagementService();
+import { logger } from "../utils/logger";
+import { initializeServices } from "./services";
+import { startHttpServer } from "./startHttpServer";
+import { startStdioServer } from "./startStdioServer";
+import { initializeTools } from "./tools";
 
+export async function startServer(protocol: "stdio" | "http", port?: number) {
   try {
-    await docService.initialize();
-
-    // Instantiate PipelineManager
-    // TODO: Check if concurrency needs to be configurable
-    const pipelineManager = new PipelineManager(docService);
-    // Start the pipeline manager to process jobs
-    await pipelineManager.start(); // Assuming start is async and needed
-
-    // Instantiate tools, passing dependencies
-    const tools = {
-      listLibraries: new ListLibrariesTool(docService),
-      findVersion: new FindVersionTool(docService),
-      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),
-      // FetchUrlTool now uses middleware pipeline internally
-      fetchUrl: new FetchUrlTool(new HttpFetcher(), new FileFetcher()),
-    };
-
-    const server = new McpServer(
-      {
-        name: "docs-mcp-server",
-        version: "0.1.0",
-      },
-      {
-        capabilities: {
-          tools: {},
-          prompts: {},
-          resources: {},
-        },
-      },
-    );
-
-    // --- Tool Definitions ---
-
-    // Scrape docs tool
-    server.tool(
-      "scrape_docs",
-      "Scrape and index documentation from a URL",
-      {
-        url: z.string().url().describe("URL of the documentation to scrape"),
-        library: z.string().describe("Name of the library"),
-        version: z.string().optional().describe("Version of the library"),
-        maxPages: z
-          .number()
-          .optional()
-          .default(DEFAULT_MAX_PAGES)
-          .describe(`Maximum number of pages to scrape (default: ${DEFAULT_MAX_PAGES})`),
-        maxDepth: z
-          .number()
-          .optional()
-          .default(DEFAULT_MAX_DEPTH)
-          .describe(`Maximum navigation depth (default: ${DEFAULT_MAX_DEPTH})`),
-        scope: z
-          .enum(["subpages", "hostname", "domain"])
-          .optional()
-          .default("subpages")
-          .describe("Defines the crawling boundary: 'subpages', 'hostname', or 'domain'"),
-        followRedirects: z
-          .boolean()
-          .optional()
-          .default(true)
-          .describe("Whether to follow HTTP redirects (3xx responses)"),
-      },
-
-      async ({ url, library, version, maxPages, maxDepth, scope, followRedirects }) => {
-        try {
-          // Execute scrape tool without waiting and without progress callback
-          const result = await tools.scrape.execute({
-            url,
-            library,
-            version,
-            waitForCompletion: false, // Don't wait for completion
-            // onProgress: undefined, // Explicitly undefined or omitted
-            options: {
-              maxPages,
-              maxDepth,
-              scope,
-              followRedirects,
-            },
-          });
-
-          // Check the type of result
-          if ("jobId" in result) {
-            // If we got a jobId back, report that
-            return createResponse(`🚀 Scraping job started with ID: ${result.jobId}.`);
-          }
-          // This case shouldn't happen if waitForCompletion is false, but handle defensively
-          return createResponse(
-            `Scraping finished immediately (unexpectedly) with ${result.pagesScraped} pages.`,
-          );
-        } catch (error) {
-          // Handle errors during job *enqueueing* or initial setup
-          return createError(
-            `Failed to scrape documentation: ${
-              error instanceof Error ? error.message : String(error)
-            }`,
-          );
-        }
-      },
-    );
-
-    // Search docs tool (Keep as is)
-    server.tool(
-      "search_docs",
-      "Search indexed documentation. Examples:\n" +
-        '- {library: "react", query: "how do hooks work"} -> matches latest version of React\n' +
-        '- {library: "react", version: "18.0.0", query: "how do hooks work"} -> matches React 18.0.0 or earlier\n' +
-        '- {library: "typescript", version: "5.x", query: "ReturnType example"} -> any TypeScript 5.x.x version\n' +
-        '- {library: "typescript", version: "5.2.x", query: "ReturnType example"} -> any TypeScript 5.2.x version',
-      {
-        library: z.string().describe("Name of the library"),
-        version: z
-          .string()
-          .optional()
-          .describe(
-            "Version of the library (supports exact versions like '18.0.0' or X-Range patterns like '5.x', '5.2.x')",
-          ),
-        query: z.string().describe("Search query"),
-        limit: z.number().optional().default(5).describe("Maximum number of results"),
-      },
-      async ({ library, version, query, limit }) => {
-        try {
-          const result = await tools.search.execute({
-            library,
-            version,
-            query,
-            limit,
-            exactMatch: false, // Always false for MCP interface
-          });
-
-          const formattedResults = result.results.map(
-            (r, i) => `
-------------------------------------------------------------
-Result ${i + 1}: ${r.url}
-
-${r.content}\n`,
-          );
-
-          return createResponse(
-            `Search results for '${query}' in ${library} v${version}:
-${formattedResults.join("")}`,
-          );
-        } catch (error) {
-          if (error instanceof VersionNotFoundError) {
-            const indexedVersions = error.availableVersions
-              .filter((v): v is { version: string; indexed: true } => v.indexed)
-              .map((v) => v.version);
-            return createError(
-              indexedVersions.length > 0
-                ? `Version not found. Available indexed versions for ${library}: ${indexedVersions.join(", ")}`
-                : `Version not found. No indexed versions available for ${library}.`,
-            );
-          }
-          return createError(
-            `Failed to search documentation: ${
-              error instanceof Error ? error.message : String(error)
-            }`,
-          );
-        }
-      },
-    );
-
-    // List libraries tool (Keep as is)
-    server.tool("list_libraries", "List all indexed libraries", {}, async () => {
-      try {
-        const result = await tools.listLibraries.execute();
-
-        return createResponse(
-          `Indexed libraries:\n${result.libraries.map((lib) => `- ${lib.name}`).join("\n")}`,
-        );
-      } catch (error) {
-        return createError(
-          `Failed to list libraries: ${
-            error instanceof Error ? error.message : String(error)
-          }`,
-        );
+    // Initialize shared services
+    await initializeServices();
+
+    // Initialize and get shared tools
+    const tools = await initializeTools();
+
+    if (protocol === "stdio") {
+      await startStdioServer(tools);
+    } else if (protocol === "http") {
+      if (port === undefined) {
+        logger.error("HTTP protocol requires a port.");
+        process.exit(1);
       }
-    });
-
-    // Find version tool (Keep as is)
-    server.tool(
-      "find_version",
-      "Find best matching version for a library",
-      {
-        library: z.string().describe("Name of the library"),
-        targetVersion: z
-          .string()
-          .optional()
-          .describe(
-            "Target version to match (supports exact versions like '18.0.0' or X-Range patterns like '5.x', '5.2.x')",
-          ),
-      },
-      async ({ library, targetVersion }) => {
-        try {
-          const version = await tools.findVersion.execute({
-            library,
-            targetVersion,
-          });
-
-          if (!version) {
-            return createError("No matching version found");
-          }
-
-          return createResponse(`Found matching version: ${version}`);
-        } catch (error) {
-          return createError(
-            `Failed to find version: ${
-              error instanceof Error ? error.message : String(error)
-            }`,
-          );
-        }
-      },
-    );
-
-    // List jobs tool
-    server.tool(
-      "list_jobs",
-      "List pipeline jobs, optionally filtering by status.",
-      {
-        status: z
-          .nativeEnum(PipelineJobStatus)
-          .optional()
-          .describe("Optional status to filter jobs by."),
-      },
-      async ({ status }) => {
-        try {
-          const result = await tools.listJobs.execute({ status });
-          // Format the simplified job list for display
-          const formattedJobs = result.jobs
-            .map(
-              (
-                job, // Use 'any' or define a local type for the simplified structure
-              ) =>
-                `- ID: ${job.id}\n  Status: ${job.status}\n  Library: ${job.library}\n  Version: ${job.version}\n  Created: ${job.createdAt}${job.startedAt ? `\n  Started: ${job.startedAt}` : ""}${job.finishedAt ? `\n  Finished: ${job.finishedAt}` : ""}${job.error ? `\n  Error: ${job.error}` : ""}`,
-            )
-            .join("\n\n");
-          return createResponse(
-            result.jobs.length > 0
-              ? `Current Jobs:\n\n${formattedJobs}`
-              : "No jobs found matching criteria.",
-          );
-        } catch (error) {
-          return createError(
-            `Failed to list jobs: ${
-              error instanceof Error ? error.message : String(error)
-            }`,
-          );
-        }
-      },
-    );
-
-    // Get job info tool
-    server.tool(
-      "get_job_info",
-      "Get the simplified info for a specific pipeline job.",
-      {
-        jobId: z.string().uuid().describe("The ID of the job to query."),
-      },
-      async ({ jobId }) => {
-        try {
-          const result = await tools.getJobInfo.execute({ jobId });
-          if (!result.job) {
-            return createError(`Job with ID ${jobId} not found.`);
-          }
-          const job = result.job;
-          const formattedJob = `- ID: ${job.id}\n  Status: ${job.status}\n  Library: ${job.library}@${job.version}\n  Created: ${job.createdAt}${job.startedAt ? `\n  Started: ${job.startedAt}` : ""}${job.finishedAt ? `\n  Finished: ${job.finishedAt}` : ""}${job.error ? `\n  Error: ${job.error}` : ""}`;
-          return createResponse(`Job Info:\n\n${formattedJob}`);
-        } catch (error) {
-          return createError(
-            `Failed to get job info for ${jobId}: ${
-              error instanceof Error ? error.message : String(error)
-            }`,
-          );
-        }
-      },
-    );
-
-    // 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",
-      "Attempt to cancel a queued or running pipeline job.",
-      {
-        jobId: z.string().uuid().describe("The ID of the job to cancel."),
-      },
-      async ({ jobId }) => {
-        try {
-          const result = await tools.cancelJob.execute({ jobId });
-          // Use the message and success status from the tool's result
-          if (result.success) {
-            return createResponse(result.message);
-          }
-          // If not successful according to the tool, treat it as an error in MCP
-          return createError(result.message);
-        } catch (error) {
-          // Catch any unexpected errors during the tool execution itself
-          return createError(
-            `Failed to cancel job ${jobId}: ${
-              error instanceof Error ? error.message : String(error)
-            }`,
-          );
-        }
-      },
-    );
-
-    // Remove docs tool
-    server.tool(
-      "remove_docs",
-      "Remove indexed documentation for a library version.",
-      {
-        library: z.string().describe("Name of the library"),
-        version: z
-          .string()
-          .optional()
-          .describe("Version of the library (optional, removes unversioned if omitted)"),
-      },
-      async ({ library, version }) => {
-        try {
-          // Execute the remove tool logic
-          const result = await tools.remove.execute({ library, version });
-          // Use the message from the tool's successful execution
-          return createResponse(result.message);
-        } catch (error) {
-          // Catch errors thrown by the RemoveTool's execute method
-          return createError(
-            `Failed to remove documents: ${
-              error instanceof Error ? error.message : String(error)
-            }`,
-          );
-        }
-      },
-    );
-
-    server.prompt(
-      "docs",
-      "Search indexed documentation",
-      {
-        library: z.string().describe("Name of the library"),
-        version: z.string().optional().describe("Version of the library"),
-        query: z.string().describe("Search query"),
-      },
-      async ({ library, version, query }) => {
-        return {
-          messages: [
-            {
-              role: "user",
-              content: {
-                type: "text",
-                text: `Please search ${library} ${version || ""} documentation for this query: ${query}`,
-              },
-            },
-          ],
-        };
-      },
-    );
-
-    server.resource(
-      "libraries",
-      "docs://libraries",
-      {
-        description: "List all indexed libraries",
-      },
-      async (uri: URL) => {
-        const result = await tools.listLibraries.execute();
-
-        return {
-          contents: result.libraries.map((lib) => ({
-            uri: new URL(lib.name, uri).href,
-            text: lib.name,
-          })),
-        };
-      },
-    );
-
-    server.resource(
-      "versions",
-      new ResourceTemplate("docs://libraries/{library}/versions", {
-        list: undefined,
-      }),
-      {
-        description: "List all indexed versions for a library",
-      },
-      async (uri: URL, { library }) => {
-        const result = await tools.listLibraries.execute();
-
-        const lib = result.libraries.find((l) => l.name === library);
-        if (!lib) {
-          return { contents: [] };
-        }
-
-        return {
-          contents: lib.versions.map((v) => ({
-            uri: new URL(v.version, uri).href,
-            text: v.version,
-          })),
-        };
-      },
-    );
-
-    /**
-     * Resource handler for listing pipeline jobs.
-     * Supports filtering by status via a query parameter (e.g., ?status=running).
-     * URI: docs://jobs[?status=<status>]
-     */
-    server.resource(
-      "jobs",
-      "docs://jobs",
-      {
-        description: "List pipeline jobs, optionally filtering by status.",
-        mimeType: "application/json",
-      },
-      async (uri: URL) => {
-        const statusParam = uri.searchParams.get("status");
-        let statusFilter: PipelineJobStatus | undefined;
-
-        // Validate status parameter if provided
-        if (statusParam) {
-          const validation = z.nativeEnum(PipelineJobStatus).safeParse(statusParam);
-          if (validation.success) {
-            statusFilter = validation.data;
-          } else {
-            // Handle invalid status - perhaps return an error or ignore?
-            // For simplicity, let's ignore invalid status for now and return all jobs.
-            // Alternatively, could throw an McpError or return specific error content.
-            logger.warn(`Invalid status parameter received: ${statusParam}`);
-          }
-        }
-
-        // Fetch simplified jobs using the ListJobsTool
-        const result = await tools.listJobs.execute({ status: statusFilter });
-
-        return {
-          contents: [
-            {
-              uri: uri.href,
-              mimeType: "application/json",
-              text: JSON.stringify(result.jobs, null, 2), // Stringify the simplified jobs array
-            },
-          ],
-        };
-      },
-    );
-
-    /**
-     * Resource handler for retrieving a specific pipeline job by its ID.
-     * URI Template: docs://jobs/{jobId}
-     */
-    server.resource(
-      "job", // A distinct name for this specific resource type
-      new ResourceTemplate("docs://jobs/{jobId}", { list: undefined }),
-      {
-        description: "Get details for a specific pipeline job by ID.",
-        mimeType: "application/json",
-      },
-      async (uri: URL, { jobId }) => {
-        // Validate jobId format if necessary (basic check)
-        if (typeof jobId !== "string" || jobId.length === 0) {
-          // Handle invalid jobId format - return empty or error
-          logger.warn(`Invalid jobId received in URI: ${jobId}`);
-          return { contents: [] }; // Return empty content for invalid ID format
-        }
-
-        // Fetch the simplified job info using GetJobInfoTool
-        const result = await tools.getJobInfo.execute({ jobId });
-
-        // result.job is either the simplified job object or null
-        if (!result.job) {
-          // Job not found, return empty content
-          return { contents: [] };
-        }
-
-        // Job found, return its simplified details as JSON
-        return {
-          contents: [
-            {
-              uri: uri.href,
-              mimeType: "application/json",
-              text: JSON.stringify(result.job, null, 2), // Stringify the simplified job object
-            },
-          ],
-        };
-      },
-    );
-
-    // Start server
-    const transport = new StdioServerTransport();
-    await server.connect(transport);
-    logger.info("Documentation MCP server running on stdio");
-
-    // Handle cleanup
-    process.on("SIGINT", async () => {
-      await pipelineManager.stop(); // Stop the pipeline manager
-      await docService.shutdown();
-      await server.close();
-      process.exit(0);
-    });
+      await startHttpServer(tools, port);
+    } else {
+      // This case should be caught by src/server.ts, but handle defensively
+      logger.error(`Unknown protocol: ${protocol}`);
+      process.exit(1);
+    }
   } catch (error) {
-    await docService.shutdown(); // Ensure docService shutdown on error too
     logger.error(`❌ Fatal Error: ${error}`);
+    // No need to call shutdownServices here, as initializeServices handles cleanup on error
     process.exit(1);
   }
 }
diff --git a/src/mcp/mcpServer.ts b/src/mcp/mcpServer.ts
new file mode 100644
index 0000000..d98cf4a
--- /dev/null
+++ b/src/mcp/mcpServer.ts
@@ -0,0 +1,520 @@
+import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { z } from "zod";
+import { PipelineJobStatus } from "../pipeline/types";
+import { type JobInfo, LibraryNotFoundError, VersionNotFoundError } from "../tools";
+import { DEFAULT_MAX_DEPTH, DEFAULT_MAX_PAGES } from "../utils/config";
+import { logger } from "../utils/logger";
+import type { McpServerTools } from "./tools";
+import { createError, createResponse } from "./utils";
+
+/**
+ * Creates and configures an instance of the MCP server with registered tools, prompts, and resources.
+ * @param tools The shared tool instances to use for server operations.
+ * @returns A configured McpServer instance.
+ */
+export function createMcpServerInstance(tools: McpServerTools): McpServer {
+  const server = new McpServer(
+    {
+      name: "docs-mcp-server",
+      version: "0.1.0",
+    },
+    {
+      capabilities: {
+        tools: {},
+        prompts: {},
+        resources: {},
+      },
+    },
+  );
+
+  // --- Tool Definitions ---
+
+  // Scrape docs tool
+  server.tool(
+    "scrape_docs",
+    "Scrape and index documentation from a URL",
+    {
+      url: z.string().url().describe("URL of the documentation to scrape"),
+      library: z.string().describe("Name of the library"),
+      version: z.string().optional().describe("Version of the library"),
+      maxPages: z
+        .number()
+        .optional()
+        .default(DEFAULT_MAX_PAGES)
+        .describe(`Maximum number of pages to scrape (default: ${DEFAULT_MAX_PAGES})`),
+      maxDepth: z
+        .number()
+        .optional()
+        .default(DEFAULT_MAX_DEPTH)
+        .describe(`Maximum navigation depth (default: ${DEFAULT_MAX_DEPTH})`),
+      scope: z
+        .enum(["subpages", "hostname", "domain"])
+        .optional()
+        .default("subpages")
+        .describe("Defines the crawling boundary: 'subpages', 'hostname', or 'domain'"),
+      followRedirects: z
+        .boolean()
+        .optional()
+        .default(true)
+        .describe("Whether to follow HTTP redirects (3xx responses)"),
+    },
+    async ({ url, library, version, maxPages, maxDepth, scope, followRedirects }) => {
+      try {
+        // Execute scrape tool without waiting and without progress callback
+        const result = await tools.scrape.execute({
+          url,
+          library,
+          version,
+          waitForCompletion: false, // Don't wait for completion
+          // onProgress: undefined, // Explicitly undefined or omitted
+          options: {
+            maxPages,
+            maxDepth,
+            scope,
+            followRedirects,
+          },
+        });
+
+        // Check the type of result
+        if ("jobId" in result) {
+          // If we got a jobId back, report that
+          return createResponse(`🚀 Scraping job started with ID: ${result.jobId}.`);
+        }
+        // This case shouldn't happen if waitForCompletion is false, but handle defensively
+        return createResponse(
+          `Scraping finished immediately (unexpectedly) with ${result.pagesScraped} pages.`,
+        );
+      } catch (error) {
+        // Handle errors during job *enqueueing* or initial setup
+        return createError(
+          `Failed to scrape documentation: ${
+            error instanceof Error ? error.message : String(error)
+          }`,
+        );
+      }
+    },
+  );
+
+  // Search docs tool
+  server.tool(
+    "search_docs",
+    "Searches up-to-date documentation for a library. Examples:\n\n" +
+      '- {library: "react", query: "hooks lifecycle"} -> matches latest version of React\n' +
+      '- {library: "react", version: "18.0.0", query: "hooks lifecycle"} -> matches React 18.0.0 or earlier\n' +
+      '- {library: "typescript", version: "5.x", query: "ReturnType example"} -> any TypeScript 5.x.x version\n' +
+      '- {library: "typescript", version: "5.2.x", query: "ReturnType example"} -> any TypeScript 5.2.x version',
+    {
+      library: z.string().describe("Name of the library"),
+      version: z
+        .string()
+        .optional()
+        .describe(
+          "Version of the library (supports exact versions like '18.0.0' or X-Range patterns like '5.x', '5.2.x')",
+        ),
+      query: z.string().describe("Search query"),
+      limit: z.number().optional().default(5).describe("Maximum number of results"),
+    },
+    async ({ library, version, query, limit }) => {
+      try {
+        const result = await tools.search.execute({
+          library,
+          version,
+          query,
+          limit,
+          exactMatch: false, // Always false for MCP interface
+        });
+
+        const formattedResults = result.results.map(
+          (r: { url: string; content: string }, i: number) => `
+------------------------------------------------------------
+Result ${i + 1}: ${r.url}
+
+${r.content}\n`,
+        );
+
+        if (formattedResults.length === 0) {
+          return createResponse(`No results found for '${query}' in ${library}.`);
+        }
+        return createResponse(
+          `Search results for '${query}' in ${library}:
+${formattedResults.join("")}`,
+        );
+      } catch (error) {
+        if (error instanceof LibraryNotFoundError) {
+          return createResponse(
+            [
+              `Library "${library}" not found.`,
+              error.suggestions?.length
+                ? `Did you mean: ${error.suggestions?.join(", ")}?`
+                : undefined,
+            ].join(" "),
+          );
+        }
+        if (error instanceof VersionNotFoundError) {
+          const indexedVersions = error.availableVersions
+            .filter((v): v is { version: string; indexed: true } => v.indexed)
+            .map((v) => v.version);
+          return createResponse(
+            [
+              `Version "${version}" not found.`,
+              indexedVersions.length > 0
+                ? `Available indexed versions for ${library}: ${indexedVersions.join(", ")}`
+                : undefined,
+            ].join(" "),
+          );
+        }
+        return createError(
+          `Failed to search documentation: ${
+            error instanceof Error ? error.message : String(error)
+          }`,
+        );
+      }
+    },
+  );
+
+  // List libraries tool
+  server.tool("list_libraries", "List all indexed libraries", {}, async () => {
+    try {
+      const result = await tools.listLibraries.execute();
+      if (result.libraries.length === 0) {
+        return createResponse("No libraries indexed yet.");
+      }
+
+      return createResponse(
+        `Indexed libraries:\n\n${result.libraries.map((lib: { name: string }) => `- ${lib.name}`).join("\n")}`,
+      );
+    } catch (error) {
+      return createError(
+        `Failed to list libraries: ${
+          error instanceof Error ? error.message : String(error)
+        }`,
+      );
+    }
+  });
+
+  // Find version tool
+  server.tool(
+    "find_version",
+    "Find best matching version for a library",
+    {
+      library: z.string().describe("Name of the library"),
+      targetVersion: z
+        .string()
+        .optional()
+        .describe(
+          "Pattern to match (supports exact versions like '18.0.0' or X-Range patterns like '5.x', '5.2.x')",
+        ),
+    },
+    async ({ library, targetVersion }) => {
+      try {
+        const message = await tools.findVersion.execute({
+          library,
+          targetVersion,
+        });
+
+        if (!message) {
+          return createError("No matching version found");
+        }
+
+        return createResponse(message);
+      } catch (error) {
+        return createError(
+          `Failed to find version: ${
+            error instanceof Error ? error.message : String(error)
+          }`,
+        );
+      }
+    },
+  );
+
+  // List jobs tool
+  server.tool(
+    "list_jobs",
+    "List pipeline jobs, optionally filtering by status.",
+    {
+      status: z
+        .nativeEnum(PipelineJobStatus)
+        .optional()
+        .describe("Optional status to filter jobs by."),
+    },
+    async ({ status }) => {
+      try {
+        const result = await tools.listJobs.execute({ status });
+        // Format the simplified job list for display
+        const formattedJobs = result.jobs
+          .map(
+            (job: JobInfo) =>
+              `- ID: ${job.id}\n  Status: ${job.status}\n  Library: ${job.library}\n  Version: ${job.version}\n  Created: ${job.createdAt}${job.startedAt ? `\n  Started: ${job.startedAt}` : ""}${job.finishedAt ? `\n  Finished: ${job.finishedAt}` : ""}${job.error ? `\n  Error: ${job.error}` : ""}`,
+          )
+          .join("\n\n");
+        return createResponse(
+          result.jobs.length > 0 ? `Current Jobs:\n\n${formattedJobs}` : "No jobs found.",
+        );
+      } catch (error) {
+        return createError(
+          `Failed to list jobs: ${
+            error instanceof Error ? error.message : String(error)
+          }`,
+        );
+      }
+    },
+  );
+
+  // Get job info tool
+  server.tool(
+    "get_job_info",
+    "Get the simplified info for a specific pipeline job.",
+    {
+      jobId: z.string().uuid().describe("The ID of the job to query."),
+    },
+    async ({ jobId }) => {
+      try {
+        const result = await tools.getJobInfo.execute({ jobId });
+        if (!result.job) {
+          return createError(`Job with ID ${jobId} not found.`);
+        }
+        const job = result.job;
+        const formattedJob = `- ID: ${job.id}\n  Status: ${job.status}\n  Library: ${job.library}@${job.version}\n  Created: ${job.createdAt}${job.startedAt ? `\n  Started: ${job.startedAt}` : ""}${job.finishedAt ? `\n  Finished: ${job.finishedAt}` : ""}${job.error ? `\n  Error: ${job.error}` : ""}`;
+        return createResponse(`Job Info:\n\n${formattedJob}`);
+      } catch (error) {
+        return createError(
+          `Failed to get job info for ${jobId}: ${
+            error instanceof Error ? error.message : String(error)
+          }`,
+        );
+      }
+    },
+  );
+
+  // 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",
+    "Attempt to cancel a queued or running pipeline job.",
+    {
+      jobId: z.string().uuid().describe("The ID of the job to cancel."),
+    },
+    async ({ jobId }) => {
+      try {
+        const result = await tools.cancelJob.execute({ jobId });
+        // Use the message and success status from the tool's result
+        if (result.success) {
+          return createResponse(result.message);
+        }
+        // If not successful according to the tool, treat it as an error in MCP
+        return createError(result.message);
+      } catch (error) {
+        // Catch any unexpected errors during the tool execution itself
+        return createError(
+          `Failed to cancel job ${jobId}: ${
+            error instanceof Error ? error.message : String(error)
+          }`,
+        );
+      }
+    },
+  );
+
+  // Remove docs tool
+  server.tool(
+    "remove_docs",
+    "Remove indexed documentation for a library version.",
+    {
+      library: z.string().describe("Name of the library"),
+      version: z
+        .string()
+        .optional()
+        .describe("Version of the library (optional, removes unversioned if omitted)"),
+    },
+    async ({ library, version }) => {
+      try {
+        // Execute the remove tool logic
+        const result = await tools.remove.execute({ library, version });
+        // Use the message from the tool's successful execution
+        return createResponse(result.message);
+      } catch (error) {
+        // Catch errors thrown by the RemoveTool's execute method
+        return createError(
+          `Failed to remove documents: ${
+            error instanceof Error ? error.message : String(error)
+          }`,
+        );
+      }
+    },
+  );
+
+  server.prompt(
+    "docs",
+    "Search indexed documentation",
+    {
+      library: z.string().describe("Name of the library"),
+      version: z.string().optional().describe("Version of the library"),
+      query: z.string().describe("Search query"),
+    },
+    async ({ library, version, query }) => {
+      return {
+        messages: [
+          {
+            role: "user",
+            content: {
+              type: "text",
+              text: `Please search ${library} ${version || ""} documentation for this query: ${query}`,
+            },
+          },
+        ],
+      };
+    },
+  );
+
+  server.resource(
+    "libraries",
+    "docs://libraries",
+    {
+      description: "List all indexed libraries",
+    },
+    async (uri: URL) => {
+      const result = await tools.listLibraries.execute();
+
+      return {
+        contents: result.libraries.map((lib: { name: string }) => ({
+          uri: new URL(lib.name, uri).href,
+          text: lib.name,
+        })),
+      };
+    },
+  );
+
+  server.resource(
+    "versions",
+    new ResourceTemplate("docs://libraries/{library}/versions", {
+      list: undefined,
+    }),
+    {
+      description: "List all indexed versions for a library",
+    },
+    async (uri: URL, { library }) => {
+      const result = await tools.listLibraries.execute();
+
+      const lib = result.libraries.find((l: { name: string }) => l.name === library);
+      if (!lib) {
+        return { contents: [] };
+      }
+
+      return {
+        contents: lib.versions.map((v: { version: string }) => ({
+          uri: new URL(v.version, uri).href,
+          text: v.version,
+        })),
+      };
+    },
+  );
+
+  /**
+   * Resource handler for listing pipeline jobs.
+   * Supports filtering by status via a query parameter (e.g., ?status=running).
+   * URI: docs://jobs[?status=<status>]
+   */
+  server.resource(
+    "jobs",
+    "docs://jobs",
+    {
+      description: "List pipeline jobs, optionally filtering by status.",
+      mimeType: "application/json",
+    },
+    async (uri: URL) => {
+      const statusParam = uri.searchParams.get("status");
+      let statusFilter: PipelineJobStatus | undefined;
+
+      // Validate status parameter if provided
+      if (statusParam) {
+        const validation = z.nativeEnum(PipelineJobStatus).safeParse(statusParam);
+        if (validation.success) {
+          statusFilter = validation.data;
+        } else {
+          // Handle invalid status - perhaps return an error or ignore?
+          // For simplicity, let's ignore invalid status for now and return all jobs.
+          // Alternatively, could throw an McpError or return specific error content.
+          logger.warn(`Invalid status parameter received: ${statusParam}`);
+        }
+      }
+
+      // Fetch simplified jobs using the ListJobsTool
+      const result = await tools.listJobs.execute({ status: statusFilter });
+
+      return {
+        contents: [
+          {
+            uri: uri.href,
+            mimeType: "application/json",
+            text: JSON.stringify(result.jobs, null, 2), // Stringify the simplified jobs array
+          },
+        ],
+      };
+    },
+  );
+
+  /**
+   * Resource handler for retrieving a specific pipeline job by its ID.
+   * URI Template: docs://jobs/{jobId}
+   */
+  server.resource(
+    "job", // A distinct name for this specific resource type
+    new ResourceTemplate("docs://jobs/{jobId}", { list: undefined }),
+    {
+      description: "Get details for a specific pipeline job by ID.",
+      mimeType: "application/json",
+    },
+    async (uri: URL, { jobId }) => {
+      // Validate jobId format if necessary (basic check)
+      if (typeof jobId !== "string" || jobId.length === 0) {
+        // Handle invalid jobId format - return empty or error
+        logger.warn(`Invalid jobId received in URI: ${jobId}`);
+        return { contents: [] }; // Return empty content for invalid ID format
+      }
+
+      // Fetch the simplified job info using GetJobInfoTool
+      const result = await tools.getJobInfo.execute({ jobId });
+
+      // result.job is either the simplified job object or null
+      if (!result.job) {
+        // Job not found, return empty content
+        return { contents: [] };
+      }
+
+      // Job found, return its simplified details as JSON
+      return {
+        contents: [
+          {
+            uri: uri.href,
+            mimeType: "application/json",
+            text: JSON.stringify(result.job, null, 2), // Stringify the simplified job object
+          },
+        ],
+      };
+    },
+  );
+
+  return server;
+}
diff --git a/src/mcp/services.ts b/src/mcp/services.ts
new file mode 100644
index 0000000..b6b1f1d
--- /dev/null
+++ b/src/mcp/services.ts
@@ -0,0 +1,74 @@
+import { PipelineManager } from "../pipeline/PipelineManager";
+import { DocumentManagementService } from "../store/DocumentManagementService";
+import { logger } from "../utils/logger";
+
+let docService: DocumentManagementService | undefined;
+let pipelineManager: PipelineManager | undefined;
+
+/**
+ * Initializes the shared services (DocumentManagementService and PipelineManager).
+ * This should be called once at the server startup.
+ */
+export async function initializeServices(): Promise<void> {
+  if (docService || pipelineManager) {
+    logger.warn("Services already initialized.");
+    return;
+  }
+
+  docService = new DocumentManagementService();
+  try {
+    await docService.initialize();
+    logger.debug("DocumentManagementService initialized.");
+
+    // TODO: Check if concurrency needs to be configurable
+    pipelineManager = new PipelineManager(docService);
+    await pipelineManager.start();
+    logger.debug("PipelineManager initialized and started.");
+  } catch (error) {
+    logger.error(`Failed to initialize services: ${error}`);
+    // Attempt to shut down any services that might have been partially initialized
+    await shutdownServices();
+    throw error; // Re-throw the error to indicate initialization failure
+  }
+}
+
+/**
+ * Shuts down the shared services.
+ * This should be called during server cleanup.
+ */
+export async function shutdownServices(): Promise<void> {
+  if (pipelineManager) {
+    await pipelineManager.stop();
+    logger.info("PipelineManager stopped.");
+    pipelineManager = undefined;
+  }
+  if (docService) {
+    await docService.shutdown();
+    logger.info("DocumentManagementService shutdown.");
+    docService = undefined;
+  }
+}
+
+/**
+ * Gets the initialized DocumentManagementService instance.
+ * @returns The DocumentManagementService instance.
+ * @throws Error if services have not been initialized.
+ */
+export function getDocService(): DocumentManagementService {
+  if (!docService) {
+    throw new Error("DocumentManagementService has not been initialized.");
+  }
+  return docService;
+}
+
+/**
+ * Gets the initialized PipelineManager instance.
+ * @returns The PipelineManager instance.
+ * @throws Error if services have not been initialized.
+ */
+export function getPipelineManager(): PipelineManager {
+  if (!pipelineManager) {
+    throw new Error("PipelineManager has not been initialized.");
+  }
+  return pipelineManager;
+}
diff --git a/src/mcp/startHttpServer.ts b/src/mcp/startHttpServer.ts
new file mode 100644
index 0000000..f99547c
--- /dev/null
+++ b/src/mcp/startHttpServer.ts
@@ -0,0 +1,119 @@
+import { randomUUID } from "node:crypto";
+import * as http from "node:http";
+import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
+import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
+import { LogLevel, logger, setLogLevel } from "../utils/logger";
+import { createMcpServerInstance } from "./mcpServer";
+import { shutdownServices } from "./services";
+import type { McpServerTools } from "./tools";
+
+/**
+ * Starts the MCP server using the Streamable HTTP and SSE transports.
+ * @param tools The shared tool instances.
+ * @param port The port to listen on.
+ */
+export async function startHttpServer(
+  tools: McpServerTools,
+  port: number,
+): Promise<void> {
+  setLogLevel(LogLevel.INFO);
+
+  const server = createMcpServerInstance(tools);
+  const sseTransports: Record<string, SSEServerTransport> = {};
+
+  const httpServer = http.createServer(async (req, res) => {
+    try {
+      const url = new URL(req.url || "/", `http://${req.headers.host}`);
+
+      if (req.method === "GET" && url.pathname === "/sse") {
+        // Handle SSE connection
+        const transport = new SSEServerTransport("/messages", res);
+        sseTransports[transport.sessionId] = transport;
+
+        res.on("close", () => {
+          delete sseTransports[transport.sessionId];
+          transport.close();
+        });
+
+        await server.connect(transport);
+      } else if (req.method === "POST" && url.pathname === "/messages") {
+        // Handle SSE messages
+        const sessionId = url.searchParams.get("sessionId");
+        const transport = sessionId ? sseTransports[sessionId] : undefined;
+
+        if (transport) {
+          let body = "";
+          for await (const chunk of req) {
+            body += chunk;
+          }
+          const parsedBody = JSON.parse(body);
+          await transport.handlePostMessage(req, res, parsedBody);
+        } else {
+          res.writeHead(400, { "Content-Type": "application/json" });
+          res.end(JSON.stringify({ error: "No transport found for sessionId" }));
+        }
+      } else if (url.pathname === "/mcp") {
+        // Handle Streamable HTTP (stateless)
+        let body = "";
+        for await (const chunk of req) {
+          body += chunk;
+        }
+        const parsedBody = JSON.parse(body);
+
+        // In stateless mode, create a new instance of server and transport for each request
+        const requestServer = createMcpServerInstance(tools);
+        const requestTransport = new StreamableHTTPServerTransport({
+          sessionIdGenerator: undefined,
+        });
+
+        res.on("close", () => {
+          logger.info("Streamable HTTP request closed");
+          requestTransport.close();
+          requestServer.close(); // Close the per-request server instance
+        });
+
+        await requestServer.connect(requestTransport);
+        await requestTransport.handleRequest(req, res, parsedBody);
+      } else {
+        // Handle 404 Not Found
+        res.writeHead(404, { "Content-Type": "application/json" });
+        res.end(
+          JSON.stringify({
+            error: `Endpoint ${url.pathname} not found.`,
+          }),
+        );
+      }
+    } catch (error) {
+      logger.error(`Error handling HTTP request: ${error}`);
+      // Send an error response
+      res.writeHead(500, { "Content-Type": "application/json" });
+      res.end(
+        JSON.stringify({
+          error: error instanceof Error ? error.message : String(error),
+        }),
+      );
+    }
+  });
+
+  httpServer.listen(port, () => {
+    logger.info(`Documentation MCP server running on http://0.0.0.0:${port}`);
+  });
+
+  // Remove all existing SIGINT listeners
+  process.removeAllListeners("SIGINT");
+
+  // Handle cleanup for HTTP server
+  process.on("SIGINT", async () => {
+    logger.info("Shutting down HTTP server...");
+    await shutdownServices(); // Shutdown shared services
+    await server.close(); // Close the shared MCP server instance
+    // FIXME: Callback is not called for some reason
+    // httpServer.close(() => {
+    //   logger.info("HTTP server closed.");
+    //   process.exit(0);
+    // });
+    httpServer.close();
+    logger.info("HTTP server closed.");
+    process.exit(0);
+  });
+}
diff --git a/src/mcp/startStdioServer.ts b/src/mcp/startStdioServer.ts
new file mode 100644
index 0000000..f48cee6
--- /dev/null
+++ b/src/mcp/startStdioServer.ts
@@ -0,0 +1,33 @@
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import { LogLevel, logger, setLogLevel } from "../utils/logger";
+import { createMcpServerInstance } from "./mcpServer";
+import { shutdownServices } from "./services";
+import type { McpServerTools } from "./tools";
+
+/**
+ * Starts the MCP server using the Stdio transport.
+ * @param tools The shared tool instances.
+ */
+export async function startStdioServer(tools: McpServerTools): Promise<void> {
+  setLogLevel(LogLevel.ERROR);
+
+  // Create a server instance using the factory and shared tools
+  const server = createMcpServerInstance(tools);
+
+  // Start server with Stdio transport
+  const transport = new StdioServerTransport();
+  await server.connect(transport);
+  logger.info("Documentation MCP server running on stdio");
+
+  // Remove all existing SIGINT listeners
+  process.removeAllListeners("SIGINT");
+
+  // Handle cleanup
+  process.on("SIGINT", async () => {
+    logger.info("Shutting down Stdio server...");
+    await shutdownServices(); // Shutdown shared services
+    await server.close();
+    logger.info("Stdio server closed.");
+    process.exit(0);
+  });
+}
diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts
new file mode 100644
index 0000000..cd812d5
--- /dev/null
+++ b/src/mcp/tools.ts
@@ -0,0 +1,53 @@
+import { FileFetcher, HttpFetcher } from "../scraper/fetcher";
+import {
+  CancelJobTool,
+  FetchUrlTool,
+  FindVersionTool,
+  GetJobInfoTool,
+  ListJobsTool,
+  ListLibrariesTool,
+  RemoveTool,
+  ScrapeTool,
+  SearchTool,
+} from "../tools";
+import { getDocService, getPipelineManager } from "./services";
+
+/**
+ * Interface for the shared tool instances.
+ */
+export interface McpServerTools {
+  listLibraries: ListLibrariesTool;
+  findVersion: FindVersionTool;
+  scrape: ScrapeTool;
+  search: SearchTool;
+  listJobs: ListJobsTool;
+  getJobInfo: GetJobInfoTool;
+  cancelJob: CancelJobTool;
+  remove: RemoveTool;
+  fetchUrl: FetchUrlTool;
+}
+
+/**
+ * Initializes and returns the shared tool instances.
+ * This should be called after initializeServices has completed.
+ * @returns An object containing all instantiated tool instances.
+ */
+export async function initializeTools(): Promise<McpServerTools> {
+  const docService = getDocService();
+  const pipelineManager = getPipelineManager();
+
+  const tools: McpServerTools = {
+    listLibraries: new ListLibrariesTool(docService),
+    findVersion: new FindVersionTool(docService),
+    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),
+    // FetchUrlTool now uses middleware pipeline internally
+    fetchUrl: new FetchUrlTool(new HttpFetcher(), new FileFetcher()),
+  };
+
+  return tools;
+}
diff --git a/src/server.ts b/src/server.ts
index 767c8e4..75ab3db 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -1,8 +1,39 @@
 #!/usr/bin/env node
-import { startServer } from "./mcp";
-import { logger } from "./utils/logger";
+import { program } from "commander";
+import { startServer } from "./mcp/index.js";
+import { DEFAULT_HTTP_PORT, DEFAULT_PROTOCOL } from "./utils/config.js";
 
-startServer().catch((error) => {
-  logger.error(`❌ Fatal Error: ${error}`);
-  process.exit(1);
-});
+program
+  .option("--protocol <type>", "Protocol to use (stdio or http)", DEFAULT_PROTOCOL)
+  .option(
+    "--port <number>",
+    "Port to listen on for http protocol",
+    `${DEFAULT_HTTP_PORT}`,
+  )
+  .parse(process.argv);
+
+const options = program.opts();
+
+async function main() {
+  const protocol = options.protocol;
+  const port = Number.parseInt(options.port, 10);
+
+  if (protocol !== "stdio" && protocol !== "http") {
+    console.error('Invalid protocol specified. Use "stdio" or "http".');
+    process.exit(1);
+  }
+
+  if (protocol === "http" && Number.isNaN(port)) {
+    console.error("Port must be a number when using http protocol.");
+    process.exit(1);
+  }
+
+  try {
+    await startServer(protocol, protocol === "http" ? port : undefined);
+  } catch (error) {
+    console.error(`Server failed to start: ${error}`);
+    process.exit(1);
+  }
+}
+
+main();
diff --git a/src/tools/ScrapeTool.ts b/src/tools/ScrapeTool.ts
index 177596c..a857c8e 100644
--- a/src/tools/ScrapeTool.ts
+++ b/src/tools/ScrapeTool.ts
@@ -1,9 +1,13 @@
 import * as semver from "semver";
-import { DEFAULT_MAX_CONCURRENCY, DEFAULT_MAX_DEPTH, DEFAULT_MAX_PAGES } from "../config";
 import type { PipelineManager } from "../pipeline/PipelineManager";
 import { ScrapeMode } from "../scraper/types";
 import type { DocumentManagementService } from "../store/DocumentManagementService";
 import type { ProgressResponse } from "../types";
+import {
+  DEFAULT_MAX_CONCURRENCY,
+  DEFAULT_MAX_DEPTH,
+  DEFAULT_MAX_PAGES,
+} from "../utils/config";
 import { logger } from "../utils/logger";
 
 export interface ScrapeToolOptions {
diff --git a/src/tools/SearchTool.ts b/src/tools/SearchTool.ts
index e6f4a4c..56d00e5 100644
--- a/src/tools/SearchTool.ts
+++ b/src/tools/SearchTool.ts
@@ -1,7 +1,7 @@
 import type { DocumentManagementService } from "../store";
 import type { StoreSearchResult } from "../store/types";
 import { logger } from "../utils/logger";
-import { LibraryNotFoundError, VersionNotFoundError } from "./errors";
+import { VersionNotFoundError } from "./errors";
 
 export interface SearchToolOptions {
   library: string;
@@ -19,7 +19,6 @@ export interface SearchToolResultError {
 
 export interface SearchToolResult {
   results: StoreSearchResult[];
-  error?: SearchToolResultError;
 }
 
 /**
@@ -88,27 +87,6 @@ export class SearchTool {
 
       return { results };
     } catch (error) {
-      if (error instanceof LibraryNotFoundError) {
-        logger.info(`ℹ️ Library not found: ${error.message}`);
-        return {
-          results: [],
-          error: {
-            message: error.message,
-            suggestions: error.suggestions,
-          },
-        };
-      }
-      if (error instanceof VersionNotFoundError) {
-        logger.info(`ℹ️ Version not found: ${error.message}`);
-        return {
-          results: [],
-          error: {
-            message: error.message,
-            availableVersions: error.availableVersions,
-          },
-        };
-      }
-
       logger.error(
         `❌ Search failed: ${error instanceof Error ? error.message : "Unknown error"}`,
       );
diff --git a/src/config.ts b/src/utils/config.ts
similarity index 54%
rename from src/config.ts
rename to src/utils/config.ts
index a785a7b..22e474b 100644
--- a/src/config.ts
+++ b/src/utils/config.ts
@@ -1,5 +1,5 @@
 /**
- * Default configuration values for the scraping pipeline
+ * Default configuration values for the scraping pipeline and server
  */
 
 /** Maximum number of pages to scrape in a single job */
@@ -10,3 +10,9 @@ export const DEFAULT_MAX_DEPTH = 3;
 
 /** Maximum number of concurrent page requests */
 export const DEFAULT_MAX_CONCURRENCY = 3;
+
+/** Default protocol for the MCP server */
+export const DEFAULT_PROTOCOL = "stdio";
+
+/** Default port for the HTTP protocol */
+export const DEFAULT_HTTP_PORT = 8000;


Test patch:
diff --git a/src/tools/SearchTool.test.ts b/src/tools/SearchTool.test.ts
index 792824f..1369031 100644
--- a/src/tools/SearchTool.test.ts
+++ b/src/tools/SearchTool.test.ts
@@ -64,7 +64,6 @@ describe("SearchTool", () => {
       5, // Default limit
     );
     expect(result.results).toEqual(mockSearchResults);
-    expect(result.error).toBeUndefined();
   });
 
   it("should throw VersionNotFoundError when exactMatch is true but no version is specified", async () => {
@@ -114,7 +113,6 @@ describe("SearchTool", () => {
       5,
     );
     expect(result.results).toEqual(mockSearchResults);
-    expect(result.error).toBeUndefined();
   });
 
   it("should search unversioned docs if findBestVersion returns null bestMatch but hasUnversioned", async () => {
@@ -134,7 +132,6 @@ describe("SearchTool", () => {
       5,
     );
     expect(result.results).toEqual(mockSearchResults);
-    expect(result.error).toBeUndefined();
   });
 
   it("should use 'latest' for findBestVersion if version is omitted and exactMatch is false", async () => {
@@ -178,26 +175,26 @@ describe("SearchTool", () => {
 
   // --- Error Handling & Result Structure ---
 
-  it("should return error structure when VersionNotFoundError occurs", async () => {
+  it("should throw VersionNotFoundError and include available versions", async () => {
     const options: SearchToolOptions = { ...baseOptions, version: "nonexistent" };
     const available = [{ version: "1.0.0", indexed: true }];
     const error = new VersionNotFoundError("test-lib", "nonexistent", available);
     (mockDocService.findBestVersion as Mock).mockRejectedValue(error);
 
-    const result = await searchTool.execute(options);
+    const executePromise = searchTool.execute(options);
+    await expect(executePromise).rejects.toThrow(VersionNotFoundError);
+    await expect(executePromise).rejects.toThrow(
+      expect.objectContaining({
+        message: expect.stringContaining("Version nonexistent not found"),
+        availableVersions: available,
+      }),
+    );
 
     expect(mockDocService.findBestVersion).toHaveBeenCalledWith(
       "test-lib",
       "nonexistent",
     );
     expect(mockDocService.searchStore).not.toHaveBeenCalled();
-    expect(result.results).toEqual([]);
-    expect(result.error).toBeDefined();
-    expect(result.error?.message).toContain("Version nonexistent not found");
-    expect(result.error?.availableVersions).toEqual(available);
-    expect(logger.info).toHaveBeenCalledWith(
-      expect.stringContaining("Version not found"),
-    );
   });
 
   it("should re-throw unexpected errors from findBestVersion", async () => {
@@ -206,31 +203,26 @@ describe("SearchTool", () => {
     (mockDocService.findBestVersion as Mock).mockRejectedValue(unexpectedError);
 
     await expect(searchTool.execute(options)).rejects.toThrow("Store connection failed");
-    expect(logger.error).toHaveBeenCalledWith(
-      expect.stringContaining("Search failed: Store connection failed"),
-    );
   });
 
-  it("should return error structure with suggestions when LibraryNotFoundError occurs", async () => {
+  it("should throw LibraryNotFoundError and include suggestions", async () => {
     const options: SearchToolOptions = { ...baseOptions };
     const suggestions = ["test-lib-correct", "another-test-lib"];
     const error = new LibraryNotFoundError("test-lib", suggestions);
     (mockDocService.validateLibraryExists as Mock).mockRejectedValue(error);
 
-    const result = await searchTool.execute(options);
+    const executePromise = searchTool.execute(options);
+    await expect(executePromise).rejects.toThrow(LibraryNotFoundError);
+    await expect(executePromise).rejects.toThrow(
+      expect.objectContaining({
+        message: expect.stringContaining("Library 'test-lib' not found."),
+        suggestions: suggestions,
+      }),
+    );
 
     expect(mockDocService.validateLibraryExists).toHaveBeenCalledWith("test-lib");
     expect(mockDocService.findBestVersion).not.toHaveBeenCalled();
     expect(mockDocService.searchStore).not.toHaveBeenCalled();
-    expect(result.results).toEqual([]);
-    expect(result.error).toBeDefined();
-    expect(result.error?.message).toContain("Library 'test-lib' not found.");
-    expect(result.error?.suggestions).toEqual(suggestions);
-    expect(result.error?.availableVersions).toBeUndefined(); // Ensure version info isn't present
-    expect(logger.info).toHaveBeenCalledWith(
-      // Changed from warn to info to match implementation
-      expect.stringContaining("Library not found"),
-    );
   });
 
   it("should re-throw unexpected errors from validateLibraryExists", async () => {
@@ -241,9 +233,6 @@ describe("SearchTool", () => {
     await expect(searchTool.execute(options)).rejects.toThrow(
       "Validation DB connection failed",
     );
-    expect(logger.error).toHaveBeenCalledWith(
-      expect.stringContaining("Search failed: Validation DB connection failed"),
-    );
   });
 
   it("should re-throw unexpected errors from searchStore", async () => {
@@ -256,8 +245,5 @@ describe("SearchTool", () => {
     (mockDocService.searchStore as Mock).mockRejectedValue(unexpectedError);
 
     await expect(searchTool.execute(options)).rejects.toThrow("Search index corrupted");
-    expect(logger.error).toHaveBeenCalledWith(
-      expect.stringContaining("Search failed: Search index corrupted"),
-    );
   });
 });