# Directory Structure ``` ├── .dockerignore ├── .github │ └── workflows │ ├── docker-publish.yml │ └── npm-publish.yml ├── .gitignore ├── Dockerfile ├── evals.ts ├── LICENSE ├── package.json ├── README.md ├── scripts │ └── update-version.js ├── src │ ├── cache.ts │ ├── error-handler.ts │ ├── http-server.ts │ ├── index.ts │ ├── logging.ts │ ├── proxy.ts │ ├── resources.ts │ ├── search.ts │ ├── types.ts │ └── url-reader.ts ├── test-suite.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- ``` # Build outputs dist/ build/ # Development files node_modules/ npm-debug.log yarn-debug.log yarn-error.log # Editor directories and files .idea/ .vscode/ *.suo *.ntvs* *.njsproj *.sln *.sw? .roo/ # Git files .git/ .github/ .gitignore # Docker files .dockerignore Dockerfile # Other .DS_Store *.log coverage/ .env *.env.local ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Build outputs dist build # Test outputs coverage # Dependencies node_modules package-lock.json # Environment variables .env .env.local .env.development.local .env.test.local .env.production.local # Logs npm-debug.log* yarn-debug.log* yarn-error.log* logs *.log # Editor directories and files .idea .vscode *.suo *.ntvs* *.njsproj *.sln *.sw? .roo # OS files .DS_Store Thumbs.db ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # SearXNG MCP Server An [MCP server](https://modelcontextprotocol.io/introduction) implementation that integrates the [SearXNG](https://docs.searxng.org) API, providing web search capabilities. [](https://www.npmjs.com/package/mcp-searxng) [](https://hub.docker.com/r/isokoliuk/mcp-searxng) <a href="https://glama.ai/mcp/servers/0j7jjyt7m9"><img width="380" height="200" src="https://glama.ai/mcp/servers/0j7jjyt7m9/badge" alt="SearXNG Server MCP server" /></a> ## Features - **Web Search**: General queries, news, articles, with pagination. - **URL Content Reading**: Advanced content extraction with pagination, section filtering, and heading extraction. - **Intelligent Caching**: URL content is cached with TTL (Time-To-Live) to improve performance and reduce redundant requests. - **Pagination**: Control which page of results to retrieve. - **Time Filtering**: Filter results by time range (day, month, year). - **Language Selection**: Filter results by preferred language. - **Safe Search**: Control content filtering level for search results. ## Tools - **searxng_web_search** - Execute web searches with pagination - Inputs: - `query` (string): The search query. This string is passed to external search services. - `pageno` (number, optional): Search page number, starts at 1 (default 1) - `time_range` (string, optional): Filter results by time range - one of: "day", "month", "year" (default: none) - `language` (string, optional): Language code for results (e.g., "en", "fr", "de") or "all" (default: "all") - `safesearch` (number, optional): Safe search filter level (0: None, 1: Moderate, 2: Strict) (default: instance setting) - **web_url_read** - Read and convert the content from a URL to markdown with advanced content extraction options - Inputs: - `url` (string): The URL to fetch and process - `startChar` (number, optional): Starting character position for content extraction (default: 0) - `maxLength` (number, optional): Maximum number of characters to return - `section` (string, optional): Extract content under a specific heading (searches for heading text) - `paragraphRange` (string, optional): Return specific paragraph ranges (e.g., '1-5', '3', '10-') - `readHeadings` (boolean, optional): Return only a list of headings instead of full content ## Configuration ### Setting the SEARXNG_URL The `SEARXNG_URL` environment variable defines which SearxNG instance to connect to. #### Environment Variable Format ```bash SEARXNG_URL=<protocol>://<hostname>[:<port>] ``` #### Examples ```bash # Local development (default) SEARXNG_URL=http://localhost:8080 # Public instance SEARXNG_URL=https://search.example.com # Custom port SEARXNG_URL=http://my-searxng.local:3000 ``` #### Setup Instructions 1. Choose a SearxNG instance from the [list of public instances](https://searx.space/) or use your local environment 2. Set the `SEARXNG_URL` environment variable to the complete instance URL 3. If not specified, the default value `http://localhost:8080` will be used ### Using Authentication (Optional) If you are using a password protected SearxNG instance you can set a username and password for HTTP Basic Auth: - Set the `AUTH_USERNAME` environment variable to your username - Set the `AUTH_PASSWORD` environment variable to your password **Note:** Authentication is only required for password-protected SearxNG instances. See the usage examples below for how to configure authentication with different installation methods. ### Proxy Support (Optional) The server supports HTTP and HTTPS proxies through environment variables. This is useful when running behind corporate firewalls or when you need to route traffic through a specific proxy server. #### Proxy Environment Variables Set one or more of these environment variables to configure proxy support: - `HTTP_PROXY`: Proxy URL for HTTP requests - `HTTPS_PROXY`: Proxy URL for HTTPS requests - `http_proxy`: Alternative lowercase version for HTTP requests - `https_proxy`: Alternative lowercase version for HTTPS requests #### Proxy URL Formats The proxy URL can be in any of these formats: ```bash # Basic proxy export HTTP_PROXY=http://proxy.company.com:8080 export HTTPS_PROXY=http://proxy.company.com:8080 # Proxy with authentication export HTTP_PROXY=http://username:[email protected]:8080 export HTTPS_PROXY=http://username:[email protected]:8080 ``` **Note:** If no proxy environment variables are set, the server will make direct connections as normal. See the usage examples below for how to configure proxy settings with different installation methods. ### [NPX](https://www.npmjs.com/package/mcp-searxng) ```json { "mcpServers": { "searxng": { "command": "npx", "args": ["-y", "mcp-searxng"], "env": { "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL" } } } } ``` <details> <summary>Additional NPX Configuration Options</summary> #### With Authentication ```json { "mcpServers": { "searxng": { "command": "npx", "args": ["-y", "mcp-searxng"], "env": { "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL", "AUTH_USERNAME": "your_username", "AUTH_PASSWORD": "your_password" } } } } ``` #### With Proxy Support ```json { "mcpServers": { "searxng": { "command": "npx", "args": ["-y", "mcp-searxng"], "env": { "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL", "HTTP_PROXY": "http://proxy.company.com:8080", "HTTPS_PROXY": "http://proxy.company.com:8080" } } } } ``` #### With Authentication and Proxy Support ```json { "mcpServers": { "searxng": { "command": "npx", "args": ["-y", "mcp-searxng"], "env": { "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL", "AUTH_USERNAME": "your_username", "AUTH_PASSWORD": "your_password", "HTTP_PROXY": "http://proxy.company.com:8080", "HTTPS_PROXY": "http://proxy.company.com:8080" } } } } ``` </details> ### [NPM](https://www.npmjs.com/package/mcp-searxng) ```bash npm install -g mcp-searxng ``` ```json { "mcpServers": { "searxng": { "command": "mcp-searxng", "env": { "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL" } } } } ``` <details> <summary>Additional NPM Configuration Options</summary> #### With Authentication ```json { "mcpServers": { "searxng": { "command": "mcp-searxng", "env": { "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL", "AUTH_USERNAME": "your_username", "AUTH_PASSWORD": "your_password" } } } } ``` #### With Proxy Support ```json { "mcpServers": { "searxng": { "command": "mcp-searxng", "env": { "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL", "HTTP_PROXY": "http://proxy.company.com:8080", "HTTPS_PROXY": "http://proxy.company.com:8080" } } } } ``` #### With Authentication and Proxy Support ```json { "mcpServers": { "searxng": { "command": "mcp-searxng", "env": { "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL", "AUTH_USERNAME": "your_username", "AUTH_PASSWORD": "your_password", "HTTP_PROXY": "http://proxy.company.com:8080", "HTTPS_PROXY": "http://proxy.company.com:8080" } } } } ``` </details> ### Docker #### Using [Pre-built Image from Docker Hub](https://hub.docker.com/r/isokoliuk/mcp-searxng) ```bash docker pull isokoliuk/mcp-searxng:latest ``` ```json { "mcpServers": { "searxng": { "command": "docker", "args": [ "run", "-i", "--rm", "-e", "SEARXNG_URL", "isokoliuk/mcp-searxng:latest" ], "env": { "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL" } } } } ``` <details> <summary>Additional Docker Configuration Options</summary> #### With Authentication ```json { "mcpServers": { "searxng": { "command": "docker", "args": [ "run", "-i", "--rm", "-e", "SEARXNG_URL", "-e", "AUTH_USERNAME", "-e", "AUTH_PASSWORD", "isokoliuk/mcp-searxng:latest" ], "env": { "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL", "AUTH_USERNAME": "your_username", "AUTH_PASSWORD": "your_password" } } } } ``` #### With Proxy Support ```json { "mcpServers": { "searxng": { "command": "docker", "args": [ "run", "-i", "--rm", "-e", "SEARXNG_URL", "-e", "HTTP_PROXY", "-e", "HTTPS_PROXY", "isokoliuk/mcp-searxng:latest" ], "env": { "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL", "HTTP_PROXY": "http://proxy.company.com:8080", "HTTPS_PROXY": "http://proxy.company.com:8080" } } } } ``` #### With Authentication and Proxy Support ```json { "mcpServers": { "searxng": { "command": "docker", "args": [ "run", "-i", "--rm", "-e", "SEARXNG_URL", "-e", "AUTH_USERNAME", "-e", "AUTH_PASSWORD", "-e", "HTTP_PROXY", "-e", "HTTPS_PROXY", "isokoliuk/mcp-searxng:latest" ], "env": { "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL", "AUTH_USERNAME": "your_username", "AUTH_PASSWORD": "your_password", "HTTP_PROXY": "http://proxy.company.com:8080", "HTTPS_PROXY": "http://proxy.company.com:8080" } } } } ``` </details> #### Build Locally ```bash docker build -t mcp-searxng:latest -f Dockerfile . ``` ```json { "mcpServers": { "searxng": { "command": "docker", "args": [ "run", "-i", "--rm", "-e", "SEARXNG_URL", "mcp-searxng:latest" ], "env": { "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL" } } } } ``` <details> <summary>Additional Build Locally Configuration Options</summary> #### With Authentication ```json { "mcpServers": { "searxng": { "command": "docker", "args": [ "run", "-i", "--rm", "-e", "SEARXNG_URL", "-e", "AUTH_USERNAME", "-e", "AUTH_PASSWORD", "mcp-searxng:latest" ], "env": { "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL", "AUTH_USERNAME": "your_username", "AUTH_PASSWORD": "your_password" } } } } ``` #### With Proxy Support ```json { "mcpServers": { "searxng": { "command": "docker", "args": [ "run", "-i", "--rm", "-e", "SEARXNG_URL", "-e", "HTTP_PROXY", "-e", "HTTPS_PROXY", "mcp-searxng:latest" ], "env": { "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL", "HTTP_PROXY": "http://proxy.company.com:8080", "HTTPS_PROXY": "http://proxy.company.com:8080" } } } } ``` #### With Authentication and Proxy Support ```json { "mcpServers": { "searxng": { "command": "docker", "args": [ "run", "-i", "--rm", "-e", "SEARXNG_URL", "-e", "AUTH_USERNAME", "-e", "AUTH_PASSWORD", "-e", "HTTP_PROXY", "-e", "HTTPS_PROXY", "mcp-searxng:latest" ], "env": { "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL", "AUTH_USERNAME": "your_username", "AUTH_PASSWORD": "your_password", "HTTP_PROXY": "http://proxy.company.com:8080", "HTTPS_PROXY": "http://proxy.company.com:8080" } } } } ``` </details> #### Docker Compose Create a `docker-compose.yml` file: ```yaml services: mcp-searxng: image: isokoliuk/mcp-searxng:latest stdin_open: true environment: - SEARXNG_URL=YOUR_SEARXNG_INSTANCE_URL ``` Then configure your MCP client: ```json { "mcpServers": { "searxng": { "command": "docker-compose", "args": ["run", "--rm", "mcp-searxng"] } } } ``` <details> <summary>Additional Docker Compose Configuration Options</summary> #### With Authentication ```yaml services: mcp-searxng: image: isokoliuk/mcp-searxng:latest stdin_open: true environment: - SEARXNG_URL=YOUR_SEARXNG_INSTANCE_URL - AUTH_USERNAME=your_username - AUTH_PASSWORD=your_password ``` #### With Proxy Support ```yaml services: mcp-searxng: image: isokoliuk/mcp-searxng:latest stdin_open: true environment: - SEARXNG_URL=YOUR_SEARXNG_INSTANCE_URL - HTTP_PROXY=http://proxy.company.com:8080 - HTTPS_PROXY=http://proxy.company.com:8080 ``` #### With Authentication and Proxy Support ```yaml services: mcp-searxng: image: isokoliuk/mcp-searxng:latest stdin_open: true environment: - SEARXNG_URL=YOUR_SEARXNG_INSTANCE_URL - AUTH_USERNAME=your_username - AUTH_PASSWORD=your_password - HTTP_PROXY=http://proxy.company.com:8080 - HTTPS_PROXY=http://proxy.company.com:8080 ``` #### Using Local Build ```yaml services: mcp-searxng: build: . stdin_open: true environment: - SEARXNG_URL=YOUR_SEARXNG_INSTANCE_URL ``` </details> ### HTTP Transport (Optional) The server supports both STDIO (default) and HTTP transports: #### STDIO Transport (Default) - **Best for**: Claude Desktop and most MCP clients - **Usage**: Automatic - no additional configuration needed #### HTTP Transport - **Best for**: Web-based applications and remote MCP clients - **Usage**: Set the `MCP_HTTP_PORT` environment variable ```json { "mcpServers": { "searxng-http": { "command": "mcp-searxng", "env": { "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL", "MCP_HTTP_PORT": "3000" } } } } ``` <details> <summary>Additional HTTP Transport Configuration Options</summary> #### HTTP Server with Authentication ```json { "mcpServers": { "searxng-http": { "command": "mcp-searxng", "env": { "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL", "MCP_HTTP_PORT": "3000", "AUTH_USERNAME": "your_username", "AUTH_PASSWORD": "your_password" } } } } ``` #### HTTP Server with Proxy Support ```json { "mcpServers": { "searxng-http": { "command": "mcp-searxng", "env": { "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL", "MCP_HTTP_PORT": "3000", "HTTP_PROXY": "http://proxy.company.com:8080", "HTTPS_PROXY": "http://proxy.company.com:8080" } } } } ``` #### HTTP Server with Authentication and Proxy Support ```json { "mcpServers": { "searxng-http": { "command": "mcp-searxng", "env": { "SEARXNG_URL": "YOUR_SEARXNG_INSTANCE_URL", "MCP_HTTP_PORT": "3000", "AUTH_USERNAME": "your_username", "AUTH_PASSWORD": "your_password", "HTTP_PROXY": "http://proxy.company.com:8080", "HTTPS_PROXY": "http://proxy.company.com:8080" } } } } ``` </details> **HTTP Endpoints:** - **MCP Protocol**: `POST/GET/DELETE /mcp` - **Health Check**: `GET /health` - **CORS**: Enabled for web clients **Testing HTTP Server:** ```bash # Start HTTP server MCP_HTTP_PORT=3000 SEARXNG_URL=http://localhost:8080 mcp-searxng # Check health curl http://localhost:3000/health ``` ## Running evals The evals package loads an mcp client that then runs the src/index.ts file, so there is no need to rebuild between tests. You can see the full documentation [here](https://www.mcpevals.io/docs). ```bash SEARXNG_URL=SEARXNG_URL OPENAI_API_KEY=your-key npx mcp-eval evals.ts src/index.ts ``` ## For Developers ### Contributing to the Project We welcome contributions! Here's how to get started: #### 0. Coding Guidelines - Use TypeScript for type safety - Follow existing error handling patterns - Keep error messages concise but informative - Write unit tests for new functionality - Ensure all tests pass before submitting PRs - Maintain test coverage above 90% - Test changes with the MCP inspector - Run evals before submitting PRs #### 1. Fork and Clone ```bash # Fork the repository on GitHub, then clone your fork git clone https://github.com/YOUR_USERNAME/mcp-searxng.git cd mcp-searxng # Add the original repository as upstream git remote add upstream https://github.com/ihor-sokoliuk/mcp-searxng.git ``` #### 2. Development Setup ```bash # Install dependencies npm install # Start development with file watching npm run watch ``` #### 3. Development Workflow 1. **Create a feature branch:** ```bash git checkout -b feature/your-feature-name ``` 2. **Make your changes** in `src/` directory - Main server logic: `src/index.ts` - Error handling: `src/error-handler.ts` 3. **Build and test:** ```bash npm run build # Build the project npm test # Run unit tests npm run test:coverage # Run tests with coverage report npm run inspector # Run MCP inspector ``` 4. **Run evals to ensure functionality:** ```bash SEARXNG_URL=http://localhost:8080 OPENAI_API_KEY=your-key npx mcp-eval evals.ts src/index.ts ``` #### 4. Submitting Changes ```bash # Commit your changes git add . git commit -m "feat: description of your changes" # Push to your fork git push origin feature/your-feature-name # Create a Pull Request on GitHub ``` ### Testing The project includes comprehensive unit tests with excellent coverage. #### Running Tests ```bash # Run all tests npm test # Run with coverage reporting npm run test:coverage # Watch mode for development npm run test:watch ``` #### Test Statistics - **Unit tests** covering all core modules - **100% success rate** with dynamic coverage reporting via c8 - **HTML coverage reports** generated in `coverage/` directory #### What's Tested - Error handling (network, server, configuration errors) - Type validation and schema guards - Proxy configurations and environment variables - Resource generation and logging functionality - All module imports and function availability ## License This MCP server is licensed under the MIT License. This means you are free to use, modify, and distribute the software, subject to the terms and conditions of the MIT License. For more details, please see the LICENSE file in the project repository. ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile FROM node:lts-alpine AS builder WORKDIR /app COPY ./ /app RUN --mount=type=cache,target=/root/.npm npm run bootstrap FROM node:lts-alpine AS release RUN apk update && apk upgrade WORKDIR /app COPY --from=builder /app/dist /app/dist COPY --from=builder /app/package.json /app/package.json COPY --from=builder /app/package-lock.json /app/package-lock.json ENV NODE_ENV=production RUN npm ci --ignore-scripts --omit-dev ENTRYPOINT ["node", "dist/index.js"] ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "outDir": "./dist", "rootDir": "./src", "isolatedModules": true, "declaration": true, "types": ["node"] }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist", "evals.ts"] } ``` -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- ```yaml name: Publish NPM Package on: push: tags: - 'v*' jobs: build-and-publish: runs-on: self-hosted steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '18' registry-url: 'https://registry.npmjs.org/' - name: Install dependencies run: npm install --ignore-scripts - name: Build package run: npm run build - name: Publish to npm run: npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ``` -------------------------------------------------------------------------------- /evals.ts: -------------------------------------------------------------------------------- ```typescript //evals.ts import { EvalConfig } from 'mcp-evals'; import { openai } from "@ai-sdk/openai"; import { grade, EvalFunction } from "mcp-evals"; const searxng_web_searchEval: EvalFunction = { name: "searxng_web_search Tool Evaluation", description: "Evaluates searxng_web_search tool functionality", run: async () => { const result = await grade(openai("gpt-4o"), "Search for the latest news on climate change using the searxng_web_search tool and summarize the top findings."); return JSON.parse(result); } }; const config: EvalConfig = { model: openai("gpt-4o"), evals: [searxng_web_searchEval ] }; export default config; export const evals = [searxng_web_searchEval ]; ``` -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- ```yaml name: Publish Docker image on: push: tags: - 'v*' jobs: push_to_registry: name: Push Docker image to Docker Hub runs-on: self-hosted steps: - name: Check out the repo uses: actions/checkout@v4 - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v5 with: images: isokoliuk/mcp-searxng tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=raw,value=latest - name: Build and push Docker image uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} ``` -------------------------------------------------------------------------------- /scripts/update-version.js: -------------------------------------------------------------------------------- ```javascript #!/usr/bin/env node import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { createRequire } from 'module'; // Setup dirname equivalent for ES modules const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Use createRequire to load JSON files const require = createRequire(import.meta.url); const packageJson = require('../package.json'); const version = packageJson.version; // Path to index.ts const indexPath = path.join(__dirname, '..', 'src', 'index.ts'); // Read the file let content = fs.readFileSync(indexPath, 'utf8'); // Define a static version string to replace const staticVersionRegex = /const packageVersion = "([\d\.]+|unknown)";/; // Replace with updated version from package.json if (staticVersionRegex.test(content)) { content = content.replace(staticVersionRegex, `const packageVersion = "${version}";`); // Write the updated content fs.writeFileSync(indexPath, content); console.log(`Updated version in index.ts to ${version}`); } else { console.error('Could not find static version declaration in index.ts'); process.exit(1); } ``` -------------------------------------------------------------------------------- /src/logging.ts: -------------------------------------------------------------------------------- ```typescript import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { LoggingLevel } from "@modelcontextprotocol/sdk/types.js"; // Logging state let currentLogLevel: LoggingLevel = "info"; // Logging helper function export function logMessage(server: Server, level: LoggingLevel, message: string, data?: unknown): void { if (shouldLog(level)) { try { server.notification({ method: "notifications/message", params: { level, message, data } }).catch((error) => { // Silently ignore "Not connected" errors during server startup // This can happen when logging occurs before the transport is fully connected if (error instanceof Error && error.message !== "Not connected") { console.error("Logging error:", error); } }); } catch (error) { // Handle synchronous errors as well if (error instanceof Error && error.message !== "Not connected") { console.error("Logging error:", error); } } } } export function shouldLog(level: LoggingLevel): boolean { const levels: LoggingLevel[] = ["debug", "info", "warning", "error"]; return levels.indexOf(level) >= levels.indexOf(currentLogLevel); } export function setLogLevel(level: LoggingLevel): void { currentLogLevel = level; } export function getCurrentLogLevel(): LoggingLevel { return currentLogLevel; } ``` -------------------------------------------------------------------------------- /src/proxy.ts: -------------------------------------------------------------------------------- ```typescript import { HttpsProxyAgent } from "https-proxy-agent"; import { HttpProxyAgent } from "http-proxy-agent"; export function createProxyAgent(targetUrl: string) { const proxyUrl = process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy; if (!proxyUrl) { return undefined; } // Validate and normalize proxy URL let parsedProxyUrl: URL; try { parsedProxyUrl = new URL(proxyUrl); } catch (error) { throw new Error( `Invalid proxy URL: ${proxyUrl}. ` + "Please provide a valid URL (e.g., http://proxy:8080 or http://user:pass@proxy:8080)" ); } // Ensure proxy protocol is supported if (!['http:', 'https:'].includes(parsedProxyUrl.protocol)) { throw new Error( `Unsupported proxy protocol: ${parsedProxyUrl.protocol}. ` + "Only HTTP and HTTPS proxies are supported." ); } // Reconstruct base proxy URL preserving credentials but removing any path const auth = parsedProxyUrl.username ? (parsedProxyUrl.password ? `${parsedProxyUrl.username}:${parsedProxyUrl.password}@` : `${parsedProxyUrl.username}@`) : ''; const normalizedProxyUrl = `${parsedProxyUrl.protocol}//${auth}${parsedProxyUrl.host}`; // Determine if target URL is HTTPS const isHttps = targetUrl.startsWith('https:'); // Create appropriate agent based on target protocol return isHttps ? new HttpsProxyAgent(normalizedProxyUrl) : new HttpProxyAgent(normalizedProxyUrl); } ``` -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- ```typescript interface CacheEntry { htmlContent: string; markdownContent: string; timestamp: number; } class SimpleCache { private cache = new Map<string, CacheEntry>(); private readonly ttlMs: number; private cleanupInterval: NodeJS.Timeout | null = null; constructor(ttlMs: number = 60000) { // Default 1 minute TTL this.ttlMs = ttlMs; this.startCleanup(); } private startCleanup(): void { // Clean up expired entries every 30 seconds this.cleanupInterval = setInterval(() => { this.cleanupExpired(); }, 30000); } private cleanupExpired(): void { const now = Date.now(); for (const [key, entry] of this.cache.entries()) { if (now - entry.timestamp > this.ttlMs) { this.cache.delete(key); } } } get(url: string): CacheEntry | null { const entry = this.cache.get(url); if (!entry) { return null; } // Check if expired if (Date.now() - entry.timestamp > this.ttlMs) { this.cache.delete(url); return null; } return entry; } set(url: string, htmlContent: string, markdownContent: string): void { this.cache.set(url, { htmlContent, markdownContent, timestamp: Date.now() }); } clear(): void { this.cache.clear(); } destroy(): void { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } this.clear(); } // Get cache statistics for debugging getStats(): { size: number; entries: Array<{ url: string; age: number }> } { const now = Date.now(); const entries = Array.from(this.cache.entries()).map(([url, entry]) => ({ url, age: now - entry.timestamp })); return { size: this.cache.size, entries }; } } // Global cache instance export const urlCache = new SimpleCache(); // Export for testing and cleanup export { SimpleCache }; ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "mcp-searxng", "version": "0.7.0", "description": "MCP server for SearXNG integration", "license": "MIT", "author": "Ihor Sokoliuk (https://github.com/ihor-sokoliuk)", "homepage": "https://github.com/ihor-sokoliuk/mcp-searxng", "bugs": "https://github.com/ihor-sokoliuk/mcp-searxng/issues", "repository": { "type": "git", "url": "https://github.com/ihor-sokoliuk/mcp-searxng" }, "keywords": [ "mcp", "modelcontextprotocol", "searxng", "search", "web-search", "claude", "ai", "pagination", "smithery", "url-reader" ], "type": "module", "bin": { "mcp-searxng": "dist/index.js" }, "main": "dist/index.js", "files": [ "dist" ], "engines": { "node": ">=18" }, "scripts": { "build": "tsc && shx chmod +x dist/*.js", "watch": "tsc --watch", "test": "tsx test-suite.ts", "bootstrap": "npm install && npm run build", "test:watch": "tsx --watch test-suite.ts", "test:coverage": "c8 --reporter=text --reporter=lcov --reporter=html tsx test-suite.ts", "test:ci": "c8 --reporter=text --reporter=lcov tsx test-suite.ts", "inspector": "DANGEROUSLY_OMIT_AUTH=true npx @modelcontextprotocol/inspector node dist/index.js", "postversion": "node scripts/update-version.js && git add src/index.ts && git commit --amend --no-edit" }, "dependencies": { "@modelcontextprotocol/sdk": "1.17.4", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "cors": "^2.8.5", "express": "^5.1.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "node-html-markdown": "^1.3.0" }, "devDependencies": { "mcp-evals": "^1.0.18", "@types/node": "^22.17.2", "@types/supertest": "^6.0.3", "c8": "^10.1.3", "shx": "^0.4.0", "supertest": "^7.1.4", "tsx": "^4.20.5", "typescript": "^5.8.3" } } ``` -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js"; export interface SearXNGWeb { results: Array<{ title: string; content: string; url: string; score: number; }>; } export function isSearXNGWebSearchArgs(args: unknown): args is { query: string; pageno?: number; time_range?: string; language?: string; safesearch?: string; } { return ( typeof args === "object" && args !== null && "query" in args && typeof (args as { query: string }).query === "string" ); } export const WEB_SEARCH_TOOL: Tool = { name: "searxng_web_search", description: "Performs a web search using the SearXNG API, ideal for general queries, news, articles, and online content. " + "Use this for broad information gathering, recent events, or when you need diverse web sources.", inputSchema: { type: "object", properties: { query: { type: "string", description: "The search query. This is the main input for the web search", }, pageno: { type: "number", description: "Search page number (starts at 1)", default: 1, }, time_range: { type: "string", description: "Time range of search (day, month, year)", enum: ["day", "month", "year"], }, language: { type: "string", description: "Language code for search results (e.g., 'en', 'fr', 'de'). Default is instance-dependent.", default: "all", }, safesearch: { type: "string", description: "Safe search filter level (0: None, 1: Moderate, 2: Strict)", enum: ["0", "1", "2"], default: "0", }, }, required: ["query"], }, }; export const READ_URL_TOOL: Tool = { name: "web_url_read", description: "Read the content from an URL. " + "Use this for further information retrieving to understand the content of each URL.", inputSchema: { type: "object", properties: { url: { type: "string", description: "URL", }, startChar: { type: "number", description: "Starting character position for content extraction (default: 0)", minimum: 0, }, maxLength: { type: "number", description: "Maximum number of characters to return", minimum: 1, }, section: { type: "string", description: "Extract content under a specific heading (searches for heading text)", }, paragraphRange: { type: "string", description: "Return specific paragraph ranges (e.g., '1-5', '3', '10-')", }, readHeadings: { type: "boolean", description: "Return only a list of headings instead of full content", }, }, required: ["url"], }, }; ``` -------------------------------------------------------------------------------- /src/resources.ts: -------------------------------------------------------------------------------- ```typescript import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { getCurrentLogLevel } from "./logging.js"; import { packageVersion } from "./index.js"; export function createConfigResource() { const config = { serverInfo: { name: "ihor-sokoliuk/mcp-searxng", version: packageVersion, description: "MCP server for SearXNG integration" }, environment: { searxngUrl: process.env.SEARXNG_URL || "(not configured)", hasAuth: !!(process.env.AUTH_USERNAME && process.env.AUTH_PASSWORD), hasProxy: !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy), nodeVersion: process.version, currentLogLevel: getCurrentLogLevel() }, capabilities: { tools: ["searxng_web_search", "web_url_read"], logging: true, resources: true, transports: process.env.MCP_HTTP_PORT ? ["stdio", "http"] : ["stdio"] } }; return JSON.stringify(config, null, 2); } export function createHelpResource() { return `# SearXNG MCP Server Help ## Overview This is a Model Context Protocol (MCP) server that provides web search capabilities through SearXNG and URL content reading functionality. ## Available Tools ### 1. searxng_web_search Performs web searches using the configured SearXNG instance. **Parameters:** - \`query\` (required): The search query string - \`pageno\` (optional): Page number (default: 1) - \`time_range\` (optional): Filter by time - "day", "month", or "year" - \`language\` (optional): Language code like "en", "fr", "de" (default: "all") - \`safesearch\` (optional): Safe search level - "0" (none), "1" (moderate), "2" (strict) ### 2. web_url_read Reads and converts web page content to Markdown format. **Parameters:** - \`url\` (required): The URL to fetch and convert ## Configuration ### Required Environment Variables - \`SEARXNG_URL\`: URL of your SearXNG instance (e.g., http://localhost:8080) ### Optional Environment Variables - \`AUTH_USERNAME\` & \`AUTH_PASSWORD\`: Basic authentication for SearXNG - \`HTTP_PROXY\` / \`HTTPS_PROXY\`: Proxy server configuration - \`MCP_HTTP_PORT\`: Enable HTTP transport on specified port ## Transport Modes ### STDIO (Default) Standard input/output transport for desktop clients like Claude Desktop. ### HTTP (Optional) RESTful HTTP transport for web applications. Set \`MCP_HTTP_PORT\` to enable. ## Usage Examples ### Search for recent news \`\`\` Tool: searxng_web_search Args: {"query": "latest AI developments", "time_range": "day"} \`\`\` ### Read a specific article \`\`\` Tool: web_url_read Args: {"url": "https://example.com/article"} \`\`\` ## Troubleshooting 1. **"SEARXNG_URL not set"**: Configure the SEARXNG_URL environment variable 2. **Network errors**: Check if SearXNG is running and accessible 3. **Empty results**: Try different search terms or check SearXNG instance 4. **Timeout errors**: The server has a 10-second timeout for URL fetching Use logging level "debug" for detailed request information. ## Current Configuration See the "Current Configuration" resource for live settings. `; } ``` -------------------------------------------------------------------------------- /src/search.ts: -------------------------------------------------------------------------------- ```typescript import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { SearXNGWeb } from "./types.js"; import { createProxyAgent } from "./proxy.js"; import { logMessage } from "./logging.js"; import { createConfigurationError, createNetworkError, createServerError, createJSONError, createDataError, createNoResultsMessage, type ErrorContext } from "./error-handler.js"; export async function performWebSearch( server: Server, query: string, pageno: number = 1, time_range?: string, language: string = "all", safesearch?: string ) { const startTime = Date.now(); logMessage(server, "info", `Starting web search: "${query}" (page ${pageno}, lang: ${language})`); const searxngUrl = process.env.SEARXNG_URL; if (!searxngUrl) { logMessage(server, "error", "SEARXNG_URL not configured"); throw createConfigurationError( "SEARXNG_URL not set. Set it to your SearXNG instance (e.g., http://localhost:8080 or https://search.example.com)" ); } // Validate that searxngUrl is a valid URL let parsedUrl: URL; try { parsedUrl = new URL(searxngUrl); } catch (error) { throw createConfigurationError( `Invalid SEARXNG_URL format: ${searxngUrl}. Use format: http://localhost:8080` ); } // Construct the search URL const baseUrl = parsedUrl.origin; const url = new URL(`${baseUrl}/search`); url.searchParams.set("q", query); url.searchParams.set("format", "json"); url.searchParams.set("pageno", pageno.toString()); if ( time_range !== undefined && ["day", "month", "year"].includes(time_range) ) { url.searchParams.set("time_range", time_range); } if (language && language !== "all") { url.searchParams.set("language", language); } if (safesearch !== undefined && ["0", "1", "2"].includes(safesearch)) { url.searchParams.set("safesearch", safesearch); } // Prepare request options with headers const requestOptions: RequestInit = { method: "GET" }; // Add proxy agent if proxy is configured const proxyAgent = createProxyAgent(url.toString()); if (proxyAgent) { (requestOptions as any).agent = proxyAgent; } // Add basic authentication if credentials are provided const username = process.env.AUTH_USERNAME; const password = process.env.AUTH_PASSWORD; if (username && password) { const base64Auth = Buffer.from(`${username}:${password}`).toString('base64'); requestOptions.headers = { ...requestOptions.headers, 'Authorization': `Basic ${base64Auth}` }; } // Fetch with enhanced error handling let response: Response; try { logMessage(server, "debug", `Making request to: ${url.toString()}`); response = await fetch(url.toString(), requestOptions); } catch (error: any) { logMessage(server, "error", `Network error during search request: ${error.message}`, { query, url: url.toString() }); const context: ErrorContext = { url: url.toString(), searxngUrl, proxyAgent: !!proxyAgent, username }; throw createNetworkError(error, context); } if (!response.ok) { let responseBody: string; try { responseBody = await response.text(); } catch { responseBody = '[Could not read response body]'; } const context: ErrorContext = { url: url.toString(), searxngUrl }; throw createServerError(response.status, response.statusText, responseBody, context); } // Parse JSON response let data: SearXNGWeb; try { data = (await response.json()) as SearXNGWeb; } catch (error: any) { let responseText: string; try { responseText = await response.text(); } catch { responseText = '[Could not read response text]'; } const context: ErrorContext = { url: url.toString() }; throw createJSONError(responseText, context); } if (!data.results) { const context: ErrorContext = { url: url.toString(), query }; throw createDataError(data, context); } const results = data.results.map((result) => ({ title: result.title || "", content: result.content || "", url: result.url || "", score: result.score || 0, })); if (results.length === 0) { logMessage(server, "info", `No results found for query: "${query}"`); return createNoResultsMessage(query); } const duration = Date.now() - startTime; logMessage(server, "info", `Search completed: "${query}" - ${results.length} results in ${duration}ms`); return results .map((r) => `Title: ${r.title}\nDescription: ${r.content}\nURL: ${r.url}\nRelevance Score: ${r.score.toFixed(3)}`) .join("\n\n"); } ``` -------------------------------------------------------------------------------- /src/http-server.ts: -------------------------------------------------------------------------------- ```typescript import express from "express"; import cors from "cors"; import { randomUUID } from "crypto"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { logMessage } from "./logging.js"; import { packageVersion } from "./index.js"; export async function createHttpServer(server: Server): Promise<express.Application> { const app = express(); app.use(express.json()); // Add CORS support for web clients app.use(cors({ origin: '*', // Configure appropriately for production exposedHeaders: ['Mcp-Session-Id'], allowedHeaders: ['Content-Type', 'mcp-session-id'], })); // Map to store transports by session ID const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; // Handle POST requests for client-to-server communication app.post('/mcp', async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; let transport: StreamableHTTPServerTransport; if (sessionId && transports[sessionId]) { // Reuse existing transport transport = transports[sessionId]; logMessage(server, "debug", `Reusing session: ${sessionId}`); } else if (!sessionId && isInitializeRequest(req.body)) { // New initialization request logMessage(server, "info", "Creating new HTTP session"); transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sessionId) => { transports[sessionId] = transport; logMessage(server, "debug", `Session initialized: ${sessionId}`); }, // DNS rebinding protection disabled by default for backwards compatibility // For production, consider enabling: // enableDnsRebindingProtection: true, // allowedHosts: ['127.0.0.1', 'localhost'], }); // Clean up transport when closed transport.onclose = () => { if (transport.sessionId) { logMessage(server, "debug", `Session closed: ${transport.sessionId}`); delete transports[transport.sessionId]; } }; // Connect the existing server to the new transport await server.connect(transport); } else { // Invalid request console.warn(`⚠️ POST request rejected - invalid request:`, { clientIP: req.ip || req.connection.remoteAddress, sessionId: sessionId || 'undefined', hasInitializeRequest: isInitializeRequest(req.body), userAgent: req.headers['user-agent'], contentType: req.headers['content-type'], accept: req.headers['accept'] }); res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: No valid session ID provided', }, id: null, }); return; } // Handle the request try { await transport.handleRequest(req, res, req.body); } catch (error) { // Log header-related rejections for debugging if (error instanceof Error && error.message.includes('accept')) { console.warn(`⚠️ Connection rejected due to missing headers:`, { clientIP: req.ip || req.connection.remoteAddress, userAgent: req.headers['user-agent'], contentType: req.headers['content-type'], accept: req.headers['accept'], error: error.message }); } throw error; } }); // Handle GET requests for server-to-client notifications via SSE app.get('/mcp', async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (!sessionId || !transports[sessionId]) { console.warn(`⚠️ GET request rejected - missing or invalid session ID:`, { clientIP: req.ip || req.connection.remoteAddress, sessionId: sessionId || 'undefined', userAgent: req.headers['user-agent'] }); res.status(400).send('Invalid or missing session ID'); return; } const transport = transports[sessionId]; try { await transport.handleRequest(req, res); } catch (error) { console.warn(`⚠️ GET request failed:`, { clientIP: req.ip || req.connection.remoteAddress, sessionId, error: error instanceof Error ? error.message : String(error) }); throw error; } }); // Handle DELETE requests for session termination app.delete('/mcp', async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (!sessionId || !transports[sessionId]) { console.warn(`⚠️ DELETE request rejected - missing or invalid session ID:`, { clientIP: req.ip || req.connection.remoteAddress, sessionId: sessionId || 'undefined', userAgent: req.headers['user-agent'] }); res.status(400).send('Invalid or missing session ID'); return; } const transport = transports[sessionId]; try { await transport.handleRequest(req, res); } catch (error) { console.warn(`⚠️ DELETE request failed:`, { clientIP: req.ip || req.connection.remoteAddress, sessionId, error: error instanceof Error ? error.message : String(error) }); throw error; } }); // Health check endpoint app.get('/health', (_req, res) => { res.json({ status: 'healthy', server: 'ihor-sokoliuk/mcp-searxng', version: packageVersion, transport: 'http' }); }); return app; } ``` -------------------------------------------------------------------------------- /src/error-handler.ts: -------------------------------------------------------------------------------- ```typescript /** * Concise error handling for MCP SearXNG server * Provides clear, focused error messages that identify the root cause */ export interface ErrorContext { url?: string; searxngUrl?: string; proxyAgent?: boolean; username?: string; timeout?: number; query?: string; } export class MCPSearXNGError extends Error { constructor(message: string) { super(message); this.name = 'MCPSearXNGError'; } } export function createConfigurationError(message: string): MCPSearXNGError { return new MCPSearXNGError(`🔧 Configuration Error: ${message}`); } export function createNetworkError(error: any, context: ErrorContext): MCPSearXNGError { const target = context.searxngUrl ? 'SearXNG server' : 'website'; if (error.code === 'ECONNREFUSED') { return new MCPSearXNGError(`🌐 Connection Error: ${target} is not responding (${context.url})`); } if (error.code === 'ENOTFOUND' || error.code === 'EAI_NONAME') { const hostname = context.url ? new URL(context.url).hostname : 'unknown'; return new MCPSearXNGError(`🌐 DNS Error: Cannot resolve hostname "${hostname}"`); } if (error.code === 'ETIMEDOUT') { return new MCPSearXNGError(`🌐 Timeout Error: ${target} is too slow to respond`); } if (error.message?.includes('certificate')) { return new MCPSearXNGError(`🌐 SSL Error: Certificate problem with ${target}`); } // For generic fetch failures, provide root cause guidance const errorMsg = error.message || error.code || 'Connection failed'; if (errorMsg === 'fetch failed' || errorMsg === 'Connection failed') { const guidance = context.searxngUrl ? 'Check if the SEARXNG_URL is correct and the SearXNG server is available' : 'Check if the website URL is accessible'; return new MCPSearXNGError(`🌐 Network Error: ${errorMsg}. ${guidance}`); } return new MCPSearXNGError(`🌐 Network Error: ${errorMsg}`); } export function createServerError(status: number, statusText: string, responseBody: string, context: ErrorContext): MCPSearXNGError { const target = context.searxngUrl ? 'SearXNG server' : 'Website'; if (status === 403) { const reason = context.searxngUrl ? 'Authentication required or IP blocked' : 'Access blocked (bot detection or geo-restriction)'; return new MCPSearXNGError(`🚫 ${target} Error (${status}): ${reason}`); } if (status === 404) { const reason = context.searxngUrl ? 'Search endpoint not found' : 'Page not found'; return new MCPSearXNGError(`🚫 ${target} Error (${status}): ${reason}`); } if (status === 429) { return new MCPSearXNGError(`🚫 ${target} Error (${status}): Rate limit exceeded`); } if (status >= 500) { return new MCPSearXNGError(`🚫 ${target} Error (${status}): Internal server error`); } return new MCPSearXNGError(`🚫 ${target} Error (${status}): ${statusText}`); } export function createJSONError(responseText: string, context: ErrorContext): MCPSearXNGError { const preview = responseText.substring(0, 100).replace(/\n/g, ' '); return new MCPSearXNGError(`🔍 SearXNG Response Error: Invalid JSON format. Response: "${preview}..."`); } export function createDataError(data: any, context: ErrorContext): MCPSearXNGError { return new MCPSearXNGError(`🔍 SearXNG Data Error: Missing results array in response`); } export function createNoResultsMessage(query: string): string { return `🔍 No results found for "${query}". Try different search terms or check if SearXNG search engines are working.`; } export function createURLFormatError(url: string): MCPSearXNGError { return new MCPSearXNGError(`🔧 URL Format Error: Invalid URL "${url}"`); } export function createContentError(message: string, url: string): MCPSearXNGError { return new MCPSearXNGError(`📄 Content Error: ${message} (${url})`); } export function createConversionError(error: any, url: string, htmlContent: string): MCPSearXNGError { return new MCPSearXNGError(`🔄 Conversion Error: Cannot convert HTML to Markdown (${url})`); } export function createTimeoutError(timeout: number, url: string): MCPSearXNGError { const hostname = new URL(url).hostname; return new MCPSearXNGError(`⏱️ Timeout Error: ${hostname} took longer than ${timeout}ms to respond`); } export function createEmptyContentWarning(url: string, htmlLength: number, htmlPreview: string): string { return `📄 Content Warning: Page fetched but appears empty after conversion (${url}). May contain only media or require JavaScript.`; } export function createUnexpectedError(error: any, context: ErrorContext): MCPSearXNGError { return new MCPSearXNGError(`❓ Unexpected Error: ${error.message || String(error)}`); } export function validateEnvironment(): string | null { const issues: string[] = []; const searxngUrl = process.env.SEARXNG_URL; if (!searxngUrl) { issues.push("SEARXNG_URL not set"); } else { try { const url = new URL(searxngUrl); if (!['http:', 'https:'].includes(url.protocol)) { issues.push(`SEARXNG_URL invalid protocol: ${url.protocol}`); } } catch (error) { issues.push(`SEARXNG_URL invalid format: ${searxngUrl}`); } } const authUsername = process.env.AUTH_USERNAME; const authPassword = process.env.AUTH_PASSWORD; if (authUsername && !authPassword) { issues.push("AUTH_USERNAME set but AUTH_PASSWORD missing"); } else if (!authUsername && authPassword) { issues.push("AUTH_PASSWORD set but AUTH_USERNAME missing"); } if (issues.length === 0) { return null; } return `⚠️ Configuration Issues: ${issues.join(', ')}. Set SEARXNG_URL (e.g., http://localhost:8080 or https://search.example.com)`; } ``` -------------------------------------------------------------------------------- /src/url-reader.ts: -------------------------------------------------------------------------------- ```typescript import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { NodeHtmlMarkdown } from "node-html-markdown"; import { createProxyAgent } from "./proxy.js"; import { logMessage } from "./logging.js"; import { urlCache } from "./cache.js"; import { createURLFormatError, createNetworkError, createServerError, createContentError, createConversionError, createTimeoutError, createEmptyContentWarning, createUnexpectedError, type ErrorContext } from "./error-handler.js"; interface PaginationOptions { startChar?: number; maxLength?: number; section?: string; paragraphRange?: string; readHeadings?: boolean; } function applyCharacterPagination(content: string, startChar: number = 0, maxLength?: number): string { if (startChar >= content.length) { return ""; } const start = Math.max(0, startChar); const end = maxLength ? Math.min(content.length, start + maxLength) : content.length; return content.slice(start, end); } function extractSection(markdownContent: string, sectionHeading: string): string { const lines = markdownContent.split('\n'); const sectionRegex = new RegExp(`^#{1,6}\s*.*${sectionHeading}.*$`, 'i'); let startIndex = -1; let currentLevel = 0; // Find the section start for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (sectionRegex.test(line)) { startIndex = i; currentLevel = (line.match(/^#+/) || [''])[0].length; break; } } if (startIndex === -1) { return ""; } // Find the section end (next heading of same or higher level) let endIndex = lines.length; for (let i = startIndex + 1; i < lines.length; i++) { const line = lines[i]; const match = line.match(/^#+/); if (match && match[0].length <= currentLevel) { endIndex = i; break; } } return lines.slice(startIndex, endIndex).join('\n'); } function extractParagraphRange(markdownContent: string, range: string): string { const paragraphs = markdownContent.split('\n\n').filter(p => p.trim().length > 0); // Parse range (e.g., "1-5", "3", "10-") const rangeMatch = range.match(/^(\d+)(?:-(\d*))?$/); if (!rangeMatch) { return ""; } const start = parseInt(rangeMatch[1]) - 1; // Convert to 0-based index const endStr = rangeMatch[2]; if (start < 0 || start >= paragraphs.length) { return ""; } if (endStr === undefined) { // Single paragraph (e.g., "3") return paragraphs[start] || ""; } else if (endStr === "") { // Range to end (e.g., "10-") return paragraphs.slice(start).join('\n\n'); } else { // Specific range (e.g., "1-5") const end = parseInt(endStr); return paragraphs.slice(start, end).join('\n\n'); } } function extractHeadings(markdownContent: string): string { const lines = markdownContent.split('\n'); const headings = lines.filter(line => /^#{1,6}\s/.test(line)); if (headings.length === 0) { return "No headings found in the content."; } return headings.join('\n'); } function applyPaginationOptions(markdownContent: string, options: PaginationOptions): string { let result = markdownContent; // Apply heading extraction first if requested if (options.readHeadings) { return extractHeadings(result); } // Apply section extraction if (options.section) { result = extractSection(result, options.section); if (result === "") { return `Section "${options.section}" not found in the content.`; } } // Apply paragraph range filtering if (options.paragraphRange) { result = extractParagraphRange(result, options.paragraphRange); if (result === "") { return `Paragraph range "${options.paragraphRange}" is invalid or out of bounds.`; } } // Apply character-based pagination last if (options.startChar !== undefined || options.maxLength !== undefined) { result = applyCharacterPagination(result, options.startChar, options.maxLength); } return result; } export async function fetchAndConvertToMarkdown( server: Server, url: string, timeoutMs: number = 10000, paginationOptions: PaginationOptions = {} ) { const startTime = Date.now(); logMessage(server, "info", `Fetching URL: ${url}`); // Check cache first const cachedEntry = urlCache.get(url); if (cachedEntry) { logMessage(server, "info", `Using cached content for URL: ${url}`); const result = applyPaginationOptions(cachedEntry.markdownContent, paginationOptions); const duration = Date.now() - startTime; logMessage(server, "info", `Processed cached URL: ${url} (${result.length} chars in ${duration}ms)`); return result; } // Validate URL format let parsedUrl: URL; try { parsedUrl = new URL(url); } catch (error) { logMessage(server, "error", `Invalid URL format: ${url}`); throw createURLFormatError(url); } // Create an AbortController instance const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { // Prepare request options with proxy support const requestOptions: RequestInit = { signal: controller.signal, }; // Add proxy agent if proxy is configured const proxyAgent = createProxyAgent(url); if (proxyAgent) { (requestOptions as any).agent = proxyAgent; } let response: Response; try { // Fetch the URL with the abort signal response = await fetch(url, requestOptions); } catch (error: any) { const context: ErrorContext = { url, proxyAgent: !!proxyAgent, timeout: timeoutMs }; throw createNetworkError(error, context); } if (!response.ok) { let responseBody: string; try { responseBody = await response.text(); } catch { responseBody = '[Could not read response body]'; } const context: ErrorContext = { url }; throw createServerError(response.status, response.statusText, responseBody, context); } // Retrieve HTML content let htmlContent: string; try { htmlContent = await response.text(); } catch (error: any) { throw createContentError( `Failed to read website content: ${error.message || 'Unknown error reading content'}`, url ); } if (!htmlContent || htmlContent.trim().length === 0) { throw createContentError("Website returned empty content.", url); } // Convert HTML to Markdown let markdownContent: string; try { markdownContent = NodeHtmlMarkdown.translate(htmlContent); } catch (error: any) { throw createConversionError(error, url, htmlContent); } if (!markdownContent || markdownContent.trim().length === 0) { logMessage(server, "warning", `Empty content after conversion: ${url}`); // DON'T cache empty/failed conversions - return warning directly return createEmptyContentWarning(url, htmlContent.length, htmlContent); } // Only cache successful markdown conversion urlCache.set(url, htmlContent, markdownContent); // Apply pagination options const result = applyPaginationOptions(markdownContent, paginationOptions); const duration = Date.now() - startTime; logMessage(server, "info", `Successfully fetched and converted URL: ${url} (${result.length} chars in ${duration}ms)`); return result; } catch (error: any) { if (error.name === "AbortError") { logMessage(server, "error", `Timeout fetching URL: ${url} (${timeoutMs}ms)`); throw createTimeoutError(timeoutMs, url); } // Re-throw our enhanced errors if (error.name === 'MCPSearXNGError') { logMessage(server, "error", `Error fetching URL: ${url} - ${error.message}`); throw error; } // Catch any unexpected errors logMessage(server, "error", `Unexpected error fetching URL: ${url}`, error); const context: ErrorContext = { url }; throw createUnexpectedError(error, context); } finally { // Clean up the timeout to prevent memory leaks clearTimeout(timeoutId); } } ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, SetLevelRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, LoggingLevel, } from "@modelcontextprotocol/sdk/types.js"; // Import modularized functionality import { WEB_SEARCH_TOOL, READ_URL_TOOL, isSearXNGWebSearchArgs } from "./types.js"; import { logMessage, setLogLevel } from "./logging.js"; import { performWebSearch } from "./search.js"; import { fetchAndConvertToMarkdown } from "./url-reader.js"; import { createConfigResource, createHelpResource } from "./resources.js"; import { createHttpServer } from "./http-server.js"; import { validateEnvironment as validateEnv } from "./error-handler.js"; // Use a static version string that will be updated by the version script const packageVersion = "0.7.0"; // Export the version for use in other modules export { packageVersion }; // Global state for logging level let currentLogLevel: LoggingLevel = "info"; // Type guard for URL reading args export function isWebUrlReadArgs(args: unknown): args is { url: string; startChar?: number; maxLength?: number; section?: string; paragraphRange?: string; readHeadings?: boolean; } { if ( typeof args !== "object" || args === null || !("url" in args) || typeof (args as { url: string }).url !== "string" ) { return false; } const urlArgs = args as any; // Convert empty strings to undefined for optional string parameters if (urlArgs.section === "") urlArgs.section = undefined; if (urlArgs.paragraphRange === "") urlArgs.paragraphRange = undefined; // Validate optional parameters if (urlArgs.startChar !== undefined && (typeof urlArgs.startChar !== "number" || urlArgs.startChar < 0)) { return false; } if (urlArgs.maxLength !== undefined && (typeof urlArgs.maxLength !== "number" || urlArgs.maxLength < 1)) { return false; } if (urlArgs.section !== undefined && typeof urlArgs.section !== "string") { return false; } if (urlArgs.paragraphRange !== undefined && typeof urlArgs.paragraphRange !== "string") { return false; } if (urlArgs.readHeadings !== undefined && typeof urlArgs.readHeadings !== "boolean") { return false; } return true; } // Server implementation const server = new Server( { name: "ihor-sokoliuk/mcp-searxng", version: packageVersion, }, { capabilities: { logging: {}, resources: {}, tools: { searxng_web_search: { description: WEB_SEARCH_TOOL.description, schema: WEB_SEARCH_TOOL.inputSchema, }, web_url_read: { description: READ_URL_TOOL.description, schema: READ_URL_TOOL.inputSchema, }, }, }, } ); // List tools handler server.setRequestHandler(ListToolsRequestSchema, async () => { logMessage(server, "debug", "Handling list_tools request"); return { tools: [WEB_SEARCH_TOOL, READ_URL_TOOL], }; }); // Call tool handler server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; logMessage(server, "debug", `Handling call_tool request: ${name}`); try { if (name === "searxng_web_search") { if (!isSearXNGWebSearchArgs(args)) { throw new Error("Invalid arguments for web search"); } const result = await performWebSearch( server, args.query, args.pageno, args.time_range, args.language, args.safesearch ); return { content: [ { type: "text", text: result, }, ], }; } else if (name === "web_url_read") { if (!isWebUrlReadArgs(args)) { throw new Error("Invalid arguments for URL reading"); } const paginationOptions = { startChar: args.startChar, maxLength: args.maxLength, section: args.section, paragraphRange: args.paragraphRange, readHeadings: args.readHeadings, }; const result = await fetchAndConvertToMarkdown(server, args.url, 10000, paginationOptions); return { content: [ { type: "text", text: result, }, ], }; } else { throw new Error(`Unknown tool: ${name}`); } } catch (error) { logMessage(server, "error", `Tool execution error: ${error instanceof Error ? error.message : String(error)}`, { tool: name, args: args, error: error instanceof Error ? error.stack : String(error) }); throw error; } }); // Logging level handler server.setRequestHandler(SetLevelRequestSchema, async (request) => { const { level } = request.params; logMessage(server, "info", `Setting log level to: ${level}`); currentLogLevel = level; setLogLevel(level); return {}; }); // List resources handler server.setRequestHandler(ListResourcesRequestSchema, async () => { logMessage(server, "debug", "Handling list_resources request"); return { resources: [ { uri: "config://server-config", mimeType: "application/json", name: "Server Configuration", description: "Current server configuration and environment variables" }, { uri: "help://usage-guide", mimeType: "text/markdown", name: "Usage Guide", description: "How to use the MCP SearXNG server effectively" } ] }; }); // Read resource handler server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const { uri } = request.params; logMessage(server, "debug", `Handling read_resource request for: ${uri}`); switch (uri) { case "config://server-config": return { contents: [ { uri: uri, mimeType: "application/json", text: createConfigResource() } ] }; case "help://usage-guide": return { contents: [ { uri: uri, mimeType: "text/markdown", text: createHelpResource() } ] }; default: throw new Error(`Unknown resource: ${uri}`); } }); // Main function async function main() { // Environment validation const validationError = validateEnv(); if (validationError) { console.error(`❌ ${validationError}`); process.exit(1); } // Check for HTTP transport mode const httpPort = process.env.MCP_HTTP_PORT; if (httpPort) { const port = parseInt(httpPort, 10); if (isNaN(port) || port < 1 || port > 65535) { console.error(`Invalid HTTP port: ${httpPort}. Must be between 1-65535.`); process.exit(1); } console.log(`Starting HTTP transport on port ${port}`); const app = await createHttpServer(server); const httpServer = app.listen(port, () => { console.log(`HTTP server listening on port ${port}`); console.log(`Health check: http://localhost:${port}/health`); console.log(`MCP endpoint: http://localhost:${port}/mcp`); }); // Handle graceful shutdown const shutdown = (signal: string) => { console.log(`Received ${signal}. Shutting down HTTP server...`); httpServer.close(() => { console.log("HTTP server closed"); process.exit(0); }); }; process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGTERM', () => shutdown('SIGTERM')); } else { // Default STDIO transport // Show helpful message when running in terminal if (process.stdin.isTTY) { console.log(`🔍 MCP SearXNG Server v${packageVersion} - Ready`); console.log("✅ Configuration valid"); console.log(`🌐 SearXNG URL: ${process.env.SEARXNG_URL}`); console.log("📡 Waiting for MCP client connection via STDIO...\n"); } const transport = new StdioServerTransport(); await server.connect(transport); // Log after connection is established logMessage(server, "info", `MCP SearXNG Server v${packageVersion} connected via STDIO`); logMessage(server, "info", `Log level: ${currentLogLevel}`); logMessage(server, "info", `Environment: ${process.env.NODE_ENV || 'development'}`); logMessage(server, "info", `SearXNG URL: ${process.env.SEARXNG_URL || 'not configured'}`); } } // Handle uncaught errors process.on('uncaughtException', (error) => { console.error('Uncaught Exception:', error); process.exit(1); }); process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled Rejection at:', promise, 'reason:', reason); process.exit(1); }); // Start the server (CLI entrypoint) main().catch((error) => { console.error("Failed to start server:", error); process.exit(1); }); ``` -------------------------------------------------------------------------------- /test-suite.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env tsx /** * MCP SearXNG Server - Enhanced Comprehensive Test Suite * * This test suite validates the core functionality of all modular components * and ensures high code coverage for production quality assurance. * * Features: * - Comprehensive testing of all 8 core modules * - Error handling and edge case validation * - Environment configuration testing * - Type safety and schema validation * - Proxy configuration scenarios * - Enhanced coverage with integration tests * * Run with: npm test (basic) or npm run test:coverage (with coverage report) */ import { strict as assert } from 'node:assert'; // Core module imports import { logMessage, shouldLog, setLogLevel, getCurrentLogLevel } from './src/logging.js'; import { WEB_SEARCH_TOOL, READ_URL_TOOL, isSearXNGWebSearchArgs } from './src/types.js'; import { createProxyAgent } from './src/proxy.js'; import { LoggingLevel } from '@modelcontextprotocol/sdk/types.js'; import { MCPSearXNGError, createConfigurationError, createNetworkError, createServerError, createJSONError, createDataError, createNoResultsMessage, createURLFormatError, createContentError, createConversionError, createTimeoutError, createEmptyContentWarning, createUnexpectedError, validateEnvironment } from './src/error-handler.js'; import { createConfigResource, createHelpResource } from './src/resources.js'; import { performWebSearch } from './src/search.js'; import { fetchAndConvertToMarkdown } from './src/url-reader.js'; import { createHttpServer } from './src/http-server.js'; import { packageVersion, isWebUrlReadArgs } from './src/index.js'; import { SimpleCache, urlCache } from './src/cache.js'; let testResults = { passed: 0, failed: 0, errors: [] as string[] }; function testFunction(name: string, fn: () => void | Promise<void>) { console.log(`Testing ${name}...`); try { const result = fn(); if (result instanceof Promise) { return result.then(() => { testResults.passed++; console.log(`✅ ${name} passed`); }).catch((error: Error) => { testResults.failed++; testResults.errors.push(`❌ ${name} failed: ${error.message}`); console.log(`❌ ${name} failed: ${error.message}`); }); } else { testResults.passed++; console.log(`✅ ${name} passed`); } } catch (error: any) { testResults.failed++; testResults.errors.push(`❌ ${name} failed: ${error.message}`); console.log(`❌ ${name} failed: ${error.message}`); } } async function runTests() { console.log('🧪 MCP SearXNG Server - Enhanced Comprehensive Test Suite\n'); // === LOGGING MODULE TESTS === await testFunction('Logging - Log level filtering', () => { setLogLevel('error'); assert.equal(shouldLog('error'), true); assert.equal(shouldLog('info'), false); setLogLevel('debug'); assert.equal(shouldLog('error'), true); assert.equal(shouldLog('debug'), true); }); await testFunction('Logging - Get/Set current log level', () => { setLogLevel('warning'); assert.equal(getCurrentLogLevel(), 'warning'); }); await testFunction('Logging - All log levels work correctly', () => { const levels = ['error', 'warning', 'info', 'debug']; for (const level of levels) { setLogLevel(level as any); for (const testLevel of levels) { const result = shouldLog(testLevel as any); assert.equal(typeof result, 'boolean'); } } }); await testFunction('Logging - logMessage with different levels and mock server', () => { const mockNotificationCalls: any[] = []; const mockServer = { notification: (method: string, params: any) => { mockNotificationCalls.push({ method, params }); }, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; // Test different log levels setLogLevel('debug'); // Allow all messages logMessage(mockServer, 'info', 'Test info message'); logMessage(mockServer, 'warning', 'Test warning message'); logMessage(mockServer, 'error', 'Test error message'); // Should have called notification for each message assert.ok(mockNotificationCalls.length >= 0); // Notification calls depend on implementation assert.ok(true); // Test completed without throwing }); await testFunction('Logging - shouldLog edge cases', () => { // Test with all combinations of log levels setLogLevel('error'); assert.equal(shouldLog('error'), true); assert.equal(shouldLog('warning'), false); assert.equal(shouldLog('info'), false); assert.equal(shouldLog('debug'), false); setLogLevel('warning'); assert.equal(shouldLog('error'), true); assert.equal(shouldLog('warning'), true); assert.equal(shouldLog('info'), false); assert.equal(shouldLog('debug'), false); setLogLevel('info'); assert.equal(shouldLog('error'), true); assert.equal(shouldLog('warning'), true); assert.equal(shouldLog('info'), true); assert.equal(shouldLog('debug'), false); setLogLevel('debug'); assert.equal(shouldLog('error'), true); assert.equal(shouldLog('warning'), true); assert.equal(shouldLog('info'), true); assert.equal(shouldLog('debug'), true); }); // === TYPES MODULE TESTS === await testFunction('Types - isSearXNGWebSearchArgs type guard', () => { assert.equal(isSearXNGWebSearchArgs({ query: 'test', language: 'en' }), true); assert.equal(isSearXNGWebSearchArgs({ notQuery: 'test' }), false); assert.equal(isSearXNGWebSearchArgs(null), false); }); // === CACHE MODULE TESTS === await testFunction('Cache - Basic cache operations', () => { const testCache = new SimpleCache(1000); // 1 second TTL // Test set and get testCache.set('test-url', '<html>test</html>', '# Test'); const entry = testCache.get('test-url'); assert.ok(entry); assert.equal(entry.htmlContent, '<html>test</html>'); assert.equal(entry.markdownContent, '# Test'); // Test non-existent key assert.equal(testCache.get('non-existent'), null); testCache.destroy(); }); await testFunction('Cache - TTL expiration', async () => { const testCache = new SimpleCache(50); // 50ms TTL testCache.set('short-lived', '<html>test</html>', '# Test'); // Should exist immediately assert.ok(testCache.get('short-lived')); // Wait for expiration await new Promise(resolve => setTimeout(resolve, 100)); // Should be expired assert.equal(testCache.get('short-lived'), null); testCache.destroy(); }); await testFunction('Cache - Clear functionality', () => { const testCache = new SimpleCache(1000); testCache.set('url1', '<html>1</html>', '# 1'); testCache.set('url2', '<html>2</html>', '# 2'); assert.ok(testCache.get('url1')); assert.ok(testCache.get('url2')); testCache.clear(); assert.equal(testCache.get('url1'), null); assert.equal(testCache.get('url2'), null); testCache.destroy(); }); await testFunction('Cache - Statistics and cleanup', () => { const testCache = new SimpleCache(1000); testCache.set('url1', '<html>1</html>', '# 1'); testCache.set('url2', '<html>2</html>', '# 2'); const stats = testCache.getStats(); assert.equal(stats.size, 2); assert.equal(stats.entries.length, 2); // Check that entries have age information assert.ok(stats.entries[0].age >= 0); assert.ok(stats.entries[0].url); testCache.destroy(); }); await testFunction('Cache - Global cache instance', () => { // Test that global cache exists and works urlCache.clear(); // Start fresh urlCache.set('global-test', '<html>global</html>', '# Global'); const entry = urlCache.get('global-test'); assert.ok(entry); assert.equal(entry.markdownContent, '# Global'); urlCache.clear(); }); // === PROXY MODULE TESTS === await testFunction('Proxy - No proxy configuration', () => { delete process.env.HTTP_PROXY; delete process.env.HTTPS_PROXY; const agent = createProxyAgent('https://example.com'); assert.equal(agent, undefined); }); await testFunction('Proxy - HTTP proxy configuration', () => { process.env.HTTP_PROXY = 'http://proxy:8080'; const agent = createProxyAgent('http://example.com'); assert.ok(agent); delete process.env.HTTP_PROXY; }); await testFunction('Proxy - HTTPS proxy configuration', () => { process.env.HTTPS_PROXY = 'https://proxy:8080'; const agent = createProxyAgent('https://example.com'); assert.ok(agent); delete process.env.HTTPS_PROXY; }); await testFunction('Proxy - Proxy with authentication', () => { process.env.HTTPS_PROXY = 'https://user:pass@proxy:8080'; const agent = createProxyAgent('https://example.com'); assert.ok(agent); delete process.env.HTTPS_PROXY; }); await testFunction('Proxy - Edge cases and error handling', () => { // Test with malformed proxy URLs process.env.HTTP_PROXY = 'not-a-url'; try { const agent = createProxyAgent('http://example.com'); // Should handle malformed URLs gracefully assert.ok(agent === undefined || agent !== null); } catch (error) { // Error handling is acceptable for malformed URLs assert.ok(true); } delete process.env.HTTP_PROXY; // Test with different URL schemes const testUrls = ['http://example.com', 'https://example.com', 'ftp://example.com']; for (const url of testUrls) { try { const agent = createProxyAgent(url); assert.ok(agent === undefined || agent !== null); } catch (error) { // Some URL schemes might not be supported, that's ok assert.ok(true); } } }); // === ERROR HANDLER MODULE TESTS === await testFunction('Error handler - Custom error class', () => { const error = new MCPSearXNGError('test error'); assert.ok(error instanceof Error); assert.equal(error.name, 'MCPSearXNGError'); assert.equal(error.message, 'test error'); }); await testFunction('Error handler - Configuration errors', () => { const error = createConfigurationError('test config error'); assert.ok(error instanceof MCPSearXNGError); assert.ok(error.message.includes('Configuration Error')); }); await testFunction('Error handler - Network errors with different codes', () => { const errors = [ { code: 'ECONNREFUSED', message: 'Connection refused' }, { code: 'ETIMEDOUT', message: 'Timeout' }, { code: 'EAI_NONAME', message: 'DNS error' }, { code: 'ENOTFOUND', message: 'DNS error' }, { message: 'certificate error' } ]; for (const testError of errors) { const context = { url: 'https://example.com' }; const error = createNetworkError(testError, context); assert.ok(error instanceof MCPSearXNGError); } }); await testFunction('Error handler - Edge case error types', () => { // Test more error scenarios const networkErrors = [ { code: 'EHOSTUNREACH', message: 'Host unreachable' }, { code: 'ECONNRESET', message: 'Connection reset' }, { code: 'EPIPE', message: 'Broken pipe' }, ]; for (const testError of networkErrors) { const context = { url: 'https://example.com' }; const error = createNetworkError(testError, context); assert.ok(error instanceof MCPSearXNGError); assert.ok(error.message.length > 0); } }); await testFunction('Error handler - Server errors with different status codes', () => { const statusCodes = [403, 404, 429, 500, 502]; for (const status of statusCodes) { const context = { url: 'https://example.com' }; const error = createServerError(status, 'Error', 'Response body', context); assert.ok(error instanceof MCPSearXNGError); assert.ok(error.message.includes(String(status))); } }); await testFunction('Error handler - More server error scenarios', () => { const statusCodes = [400, 401, 418, 503, 504]; for (const status of statusCodes) { const context = { url: 'https://example.com' }; const error = createServerError(status, `HTTP ${status}`, 'Response body', context); assert.ok(error instanceof MCPSearXNGError); assert.ok(error.message.includes(String(status))); } }); await testFunction('Error handler - Specialized error creators', () => { const context = { searxngUrl: 'https://searx.example.com' }; assert.ok(createJSONError('invalid json', context) instanceof MCPSearXNGError); assert.ok(createDataError({}, context) instanceof MCPSearXNGError); assert.ok(createURLFormatError('invalid-url') instanceof MCPSearXNGError); assert.ok(createContentError('test error', 'https://example.com') instanceof MCPSearXNGError); assert.ok(createConversionError(new Error('test'), 'https://example.com', '<html>') instanceof MCPSearXNGError); assert.ok(createTimeoutError(5000, 'https://example.com') instanceof MCPSearXNGError); assert.ok(createUnexpectedError(new Error('test'), context) instanceof MCPSearXNGError); assert.ok(typeof createNoResultsMessage('test query') === 'string'); assert.ok(typeof createEmptyContentWarning('https://example.com', 100, '<html>') === 'string'); }); await testFunction('Error handler - Additional utility functions', () => { // Test more warning and message creators const longQuery = 'a'.repeat(200); const noResultsMsg = createNoResultsMessage(longQuery); assert.ok(typeof noResultsMsg === 'string'); assert.ok(noResultsMsg.includes('No results found')); const warningMsg = createEmptyContentWarning('https://example.com', 50, '<html><head></head><body></body></html>'); assert.ok(typeof warningMsg === 'string'); assert.ok(warningMsg.includes('Content Warning')); // Test with various content scenarios const contents = ['', '<html></html>', '<div>content</div>', 'plain text']; for (const content of contents) { const warning = createEmptyContentWarning('https://test.com', content.length, content); assert.ok(typeof warning === 'string'); } }); await testFunction('Error handler - Environment validation success', () => { const originalUrl = process.env.SEARXNG_URL; process.env.SEARXNG_URL = 'https://valid-url.com'; const result = validateEnvironment(); assert.equal(result, null); if (originalUrl) process.env.SEARXNG_URL = originalUrl; }); await testFunction('Error handler - Environment validation failures', () => { const originalUrl = process.env.SEARXNG_URL; const originalUsername = process.env.AUTH_USERNAME; const originalPassword = process.env.AUTH_PASSWORD; // Test missing SEARXNG_URL delete process.env.SEARXNG_URL; let result = validateEnvironment(); assert.ok(typeof result === 'string'); assert.ok(result!.includes('SEARXNG_URL not set')); // Test invalid URL format process.env.SEARXNG_URL = 'not-a-valid-url'; result = validateEnvironment(); assert.ok(typeof result === 'string'); assert.ok(result!.includes('invalid format')); // Test invalid auth configuration process.env.SEARXNG_URL = 'https://valid.com'; process.env.AUTH_USERNAME = 'user'; delete process.env.AUTH_PASSWORD; result = validateEnvironment(); assert.ok(typeof result === 'string'); assert.ok(result!.includes('AUTH_PASSWORD missing')); // Restore original values if (originalUrl) process.env.SEARXNG_URL = originalUrl; if (originalUsername) process.env.AUTH_USERNAME = originalUsername; else delete process.env.AUTH_USERNAME; if (originalPassword) process.env.AUTH_PASSWORD = originalPassword; }); await testFunction('Error handler - Complex environment scenarios', () => { const originalUrl = process.env.SEARXNG_URL; const originalUsername = process.env.AUTH_USERNAME; const originalPassword = process.env.AUTH_PASSWORD; // Test various invalid URL scenarios const invalidUrls = [ 'htp://invalid', // typo in protocol 'not-a-url-at-all', // completely invalid 'ftp://invalid', // wrong protocol (should be http/https) 'javascript:alert(1)', // non-http protocol ]; for (const invalidUrl of invalidUrls) { process.env.SEARXNG_URL = invalidUrl; const result = validateEnvironment(); assert.ok(typeof result === 'string', `Expected string error for URL ${invalidUrl}, got ${result}`); // The error message should mention either protocol issues or invalid format assert.ok(result!.includes('invalid protocol') || result!.includes('invalid format') || result!.includes('Configuration Issues'), `Error message should mention protocol/format issues for ${invalidUrl}. Got: ${result}`); } // Test opposite auth scenario (password without username) delete process.env.AUTH_USERNAME; process.env.AUTH_PASSWORD = 'password'; process.env.SEARXNG_URL = 'https://valid.com'; const result2 = validateEnvironment(); assert.ok(typeof result2 === 'string'); assert.ok(result2!.includes('AUTH_USERNAME missing')); // Restore original values if (originalUrl) process.env.SEARXNG_URL = originalUrl; else delete process.env.SEARXNG_URL; if (originalUsername) process.env.AUTH_USERNAME = originalUsername; else delete process.env.AUTH_USERNAME; if (originalPassword) process.env.AUTH_PASSWORD = originalPassword; else delete process.env.AUTH_PASSWORD; }); // === RESOURCES MODULE TESTS === // (Basic resource generation tests removed as they only test static structure) // === SEARCH MODULE TESTS === await testFunction('Search - Error handling for missing SEARXNG_URL', async () => { const originalUrl = process.env.SEARXNG_URL; delete process.env.SEARXNG_URL; try { // Create a minimal mock server object const mockServer = { notification: () => {}, // Add minimal required properties to satisfy Server type _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; await performWebSearch(mockServer, 'test query'); assert.fail('Should have thrown configuration error'); } catch (error: any) { assert.ok(error.message.includes('SEARXNG_URL not configured') || error.message.includes('Configuration')); } if (originalUrl) process.env.SEARXNG_URL = originalUrl; }); await testFunction('Search - Error handling for invalid SEARXNG_URL format', async () => { const originalUrl = process.env.SEARXNG_URL; process.env.SEARXNG_URL = 'not-a-valid-url'; try { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; await performWebSearch(mockServer, 'test query'); assert.fail('Should have thrown configuration error for invalid URL'); } catch (error: any) { assert.ok(error.message.includes('Configuration Error') || error.message.includes('Invalid SEARXNG_URL')); } if (originalUrl) process.env.SEARXNG_URL = originalUrl; }); await testFunction('Search - Parameter validation and URL construction', async () => { const originalUrl = process.env.SEARXNG_URL; process.env.SEARXNG_URL = 'https://test-searx.example.com'; const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; // Mock fetch to avoid actual network calls and inspect URL construction const originalFetch = global.fetch; let capturedUrl = ''; let capturedOptions: RequestInit | undefined; global.fetch = async (url: string | URL | Request, options?: RequestInit) => { capturedUrl = url.toString(); capturedOptions = options; // Return a mock response that will cause a network error to avoid further processing throw new Error('MOCK_NETWORK_ERROR'); }; try { await performWebSearch(mockServer, 'test query', 2, 'day', 'en', '1'); } catch (error: any) { // We expect this to fail with our mock error assert.ok(error.message.includes('MOCK_NETWORK_ERROR') || error.message.includes('Network Error')); } // Verify URL construction const url = new URL(capturedUrl); assert.ok(url.pathname.includes('/search')); assert.ok(url.searchParams.get('q') === 'test query'); assert.ok(url.searchParams.get('pageno') === '2'); assert.ok(url.searchParams.get('time_range') === 'day'); assert.ok(url.searchParams.get('language') === 'en'); assert.ok(url.searchParams.get('safesearch') === '1'); assert.ok(url.searchParams.get('format') === 'json'); // Restore original fetch global.fetch = originalFetch; if (originalUrl) process.env.SEARXNG_URL = originalUrl; }); await testFunction('Search - Authentication header construction', async () => { const originalUrl = process.env.SEARXNG_URL; const originalUsername = process.env.AUTH_USERNAME; const originalPassword = process.env.AUTH_PASSWORD; process.env.SEARXNG_URL = 'https://test-searx.example.com'; process.env.AUTH_USERNAME = 'testuser'; process.env.AUTH_PASSWORD = 'testpass'; const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; const originalFetch = global.fetch; let capturedOptions: RequestInit | undefined; global.fetch = async (url: string | URL | Request, options?: RequestInit) => { capturedOptions = options; throw new Error('MOCK_NETWORK_ERROR'); }; try { await performWebSearch(mockServer, 'test query'); } catch (error: any) { // Expected to fail with mock error } // Verify auth header was added assert.ok(capturedOptions?.headers); const headers = capturedOptions.headers as Record<string, string>; assert.ok(headers['Authorization']); assert.ok(headers['Authorization'].startsWith('Basic ')); // Restore global.fetch = originalFetch; if (originalUrl) process.env.SEARXNG_URL = originalUrl; else delete process.env.SEARXNG_URL; if (originalUsername) process.env.AUTH_USERNAME = originalUsername; else delete process.env.AUTH_USERNAME; if (originalPassword) process.env.AUTH_PASSWORD = originalPassword; else delete process.env.AUTH_PASSWORD; }); await testFunction('Search - Server error handling with different status codes', async () => { const originalUrl = process.env.SEARXNG_URL; process.env.SEARXNG_URL = 'https://test-searx.example.com'; const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; const originalFetch = global.fetch; // Test different HTTP error status codes const statusCodes = [404, 500, 502, 503]; for (const statusCode of statusCodes) { global.fetch = async () => { return { ok: false, status: statusCode, statusText: `HTTP ${statusCode}`, text: async () => `Server error: ${statusCode}` } as any; }; try { await performWebSearch(mockServer, 'test query'); assert.fail(`Should have thrown server error for status ${statusCode}`); } catch (error: any) { assert.ok(error.message.includes('Server Error') || error.message.includes(`${statusCode}`)); } } global.fetch = originalFetch; if (originalUrl) process.env.SEARXNG_URL = originalUrl; }); await testFunction('Search - JSON parsing error handling', async () => { const originalUrl = process.env.SEARXNG_URL; process.env.SEARXNG_URL = 'https://test-searx.example.com'; const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; const originalFetch = global.fetch; global.fetch = async () => { return { ok: true, json: async () => { throw new Error('Invalid JSON'); }, text: async () => 'Invalid JSON response' } as any; }; try { await performWebSearch(mockServer, 'test query'); assert.fail('Should have thrown JSON parsing error'); } catch (error: any) { assert.ok(error.message.includes('JSON Error') || error.message.includes('Invalid JSON') || error.name === 'MCPSearXNGError'); } global.fetch = originalFetch; if (originalUrl) process.env.SEARXNG_URL = originalUrl; }); await testFunction('Search - Missing results data error handling', async () => { const originalUrl = process.env.SEARXNG_URL; process.env.SEARXNG_URL = 'https://test-searx.example.com'; const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; const originalFetch = global.fetch; global.fetch = async () => { return { ok: true, json: async () => ({ // Missing results field query: 'test' }) } as any; }; try { await performWebSearch(mockServer, 'test query'); assert.fail('Should have thrown data error for missing results'); } catch (error: any) { assert.ok(error.message.includes('Data Error') || error.message.includes('results')); } global.fetch = originalFetch; if (originalUrl) process.env.SEARXNG_URL = originalUrl; }); await testFunction('Search - Empty results handling', async () => { const originalUrl = process.env.SEARXNG_URL; process.env.SEARXNG_URL = 'https://test-searx.example.com'; const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; const originalFetch = global.fetch; global.fetch = async () => { return { ok: true, json: async () => ({ results: [] // Empty results array }) } as any; }; try { const result = await performWebSearch(mockServer, 'test query'); assert.ok(typeof result === 'string'); assert.ok(result.includes('No results found')); } catch (error) { assert.fail(`Should not have thrown error for empty results: ${error}`); } global.fetch = originalFetch; if (originalUrl) process.env.SEARXNG_URL = originalUrl; }); await testFunction('Search - Successful search with results formatting', async () => { const originalUrl = process.env.SEARXNG_URL; process.env.SEARXNG_URL = 'https://test-searx.example.com'; const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; const originalFetch = global.fetch; global.fetch = async () => { return { ok: true, json: async () => ({ results: [ { title: 'Test Result 1', content: 'This is test content 1', url: 'https://example.com/1', score: 0.95 }, { title: 'Test Result 2', content: 'This is test content 2', url: 'https://example.com/2', score: 0.87 } ] }) } as any; }; try { const result = await performWebSearch(mockServer, 'test query'); assert.ok(typeof result === 'string'); assert.ok(result.includes('Test Result 1')); assert.ok(result.includes('Test Result 2')); assert.ok(result.includes('https://example.com/1')); assert.ok(result.includes('https://example.com/2')); assert.ok(result.includes('0.950')); // Score formatting assert.ok(result.includes('0.870')); // Score formatting } catch (error) { assert.fail(`Should not have thrown error for successful search: ${error}`); } global.fetch = originalFetch; if (originalUrl) process.env.SEARXNG_URL = originalUrl; }); await testFunction('Search - Parameter filtering (invalid values ignored)', async () => { const originalUrl = process.env.SEARXNG_URL; process.env.SEARXNG_URL = 'https://test-searx.example.com'; const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; const originalFetch = global.fetch; let capturedUrl = ''; global.fetch = async (url: string | URL | Request, options?: RequestInit) => { capturedUrl = url.toString(); throw new Error('MOCK_NETWORK_ERROR'); }; try { // Test with invalid parameter values that should be filtered out await performWebSearch(mockServer, 'test query', 1, 'invalid_time_range', 'all', 'invalid_safesearch'); } catch (error: any) { // Expected to fail with mock error } // Verify invalid parameters are NOT included in URL const url = new URL(capturedUrl); assert.ok(!url.searchParams.has('time_range') || url.searchParams.get('time_range') !== 'invalid_time_range'); assert.ok(!url.searchParams.has('safesearch') || url.searchParams.get('safesearch') !== 'invalid_safesearch'); assert.ok(!url.searchParams.has('language') || url.searchParams.get('language') !== 'all'); // But valid parameters should still be there assert.ok(url.searchParams.get('q') === 'test query'); assert.ok(url.searchParams.get('pageno') === '1'); global.fetch = originalFetch; if (originalUrl) process.env.SEARXNG_URL = originalUrl; }); // === URL READER MODULE TESTS === await testFunction('URL Reader - Error handling for invalid URL', async () => { try { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; await fetchAndConvertToMarkdown(mockServer, 'not-a-valid-url'); assert.fail('Should have thrown URL format error'); } catch (error: any) { assert.ok(error.message.includes('URL Format Error') || error.message.includes('Invalid URL')); } }); await testFunction('URL Reader - Various invalid URL formats', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; const invalidUrls = [ '', 'not-a-url', 'invalid://protocol' ]; for (const invalidUrl of invalidUrls) { try { await fetchAndConvertToMarkdown(mockServer, invalidUrl); assert.fail(`Should have thrown error for invalid URL: ${invalidUrl}`); } catch (error: any) { assert.ok(error.message.includes('URL Format Error') || error.message.includes('Invalid URL') || error.name === 'MCPSearXNGError', `Expected URL format error for ${invalidUrl}, got: ${error.message}`); } } }); await testFunction('URL Reader - Network error handling', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; const originalFetch = global.fetch; // Test different network errors const networkErrors = [ { code: 'ECONNREFUSED', message: 'Connection refused' }, { code: 'ETIMEDOUT', message: 'Request timeout' }, { code: 'ENOTFOUND', message: 'DNS resolution failed' }, { code: 'ECONNRESET', message: 'Connection reset' } ]; for (const networkError of networkErrors) { global.fetch = async () => { const error = new Error(networkError.message); (error as any).code = networkError.code; throw error; }; try { await fetchAndConvertToMarkdown(mockServer, 'https://example.com'); assert.fail(`Should have thrown network error for ${networkError.code}`); } catch (error: any) { assert.ok(error.message.includes('Network Error') || error.message.includes('Connection') || error.name === 'MCPSearXNGError'); } } global.fetch = originalFetch; }); await testFunction('URL Reader - HTTP error status codes', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; const originalFetch = global.fetch; const statusCodes = [404, 403, 500, 502, 503, 429]; for (const statusCode of statusCodes) { global.fetch = async () => { return { ok: false, status: statusCode, statusText: `HTTP ${statusCode}`, text: async () => `Error ${statusCode} response body` } as any; }; try { await fetchAndConvertToMarkdown(mockServer, 'https://example.com'); assert.fail(`Should have thrown server error for status ${statusCode}`); } catch (error: any) { assert.ok(error.message.includes('Server Error') || error.message.includes(`${statusCode}`) || error.name === 'MCPSearXNGError'); } } global.fetch = originalFetch; }); await testFunction('URL Reader - Timeout handling', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; const originalFetch = global.fetch; global.fetch = async (url: string | URL | Request, options?: RequestInit): Promise<Response> => { // Simulate a timeout by checking the abort signal return new Promise((resolve, reject) => { const timeout = setTimeout(() => { const abortError = new Error('The operation was aborted'); abortError.name = 'AbortError'; reject(abortError); }, 50); // Short delay to simulate timeout if (options?.signal) { options.signal.addEventListener('abort', () => { clearTimeout(timeout); const abortError = new Error('The operation was aborted'); abortError.name = 'AbortError'; reject(abortError); }); } }); }; try { await fetchAndConvertToMarkdown(mockServer, 'https://example.com', 100); // 100ms timeout assert.fail('Should have thrown timeout error'); } catch (error: any) { assert.ok(error.message.includes('Timeout Error') || error.message.includes('timeout') || error.name === 'MCPSearXNGError'); } global.fetch = originalFetch; }); await testFunction('URL Reader - Empty content handling', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; const originalFetch = global.fetch; // Test empty HTML content global.fetch = async () => { return { ok: true, text: async () => '' } as any; }; try { await fetchAndConvertToMarkdown(mockServer, 'https://example.com'); assert.fail('Should have thrown content error for empty content'); } catch (error: any) { assert.ok(error.message.includes('Content Error') || error.message.includes('empty') || error.name === 'MCPSearXNGError'); } // Test whitespace-only content global.fetch = async () => { return { ok: true, text: async () => ' \n\t ' } as any; }; try { await fetchAndConvertToMarkdown(mockServer, 'https://example.com'); assert.fail('Should have thrown content error for whitespace-only content'); } catch (error: any) { assert.ok(error.message.includes('Content Error') || error.message.includes('empty') || error.name === 'MCPSearXNGError'); } global.fetch = originalFetch; }); await testFunction('URL Reader - Content reading error', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; const originalFetch = global.fetch; global.fetch = async () => { return { ok: true, text: async () => { throw new Error('Failed to read response body'); } } as any; }; try { await fetchAndConvertToMarkdown(mockServer, 'https://example.com'); assert.fail('Should have thrown content error when reading fails'); } catch (error: any) { assert.ok(error.message.includes('Content Error') || error.message.includes('Failed to read') || error.name === 'MCPSearXNGError'); } global.fetch = originalFetch; }); await testFunction('URL Reader - Successful HTML to Markdown conversion', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; const originalFetch = global.fetch; global.fetch = async () => { return { ok: true, text: async () => ` <html> <head><title>Test Page</title></head> <body> <h1>Main Title</h1> <p>This is a test paragraph with <strong>bold text</strong>.</p> <ul> <li>First item</li> <li>Second item</li> </ul> <a href="https://example.com">Test Link</a> </body> </html> ` } as any; }; try { const result = await fetchAndConvertToMarkdown(mockServer, 'https://example.com'); assert.ok(typeof result === 'string'); assert.ok(result.length > 0); // Check for markdown conversion assert.ok(result.includes('Main Title') || result.includes('#')); assert.ok(result.includes('bold text') || result.includes('**')); } catch (error) { assert.fail(`Should not have thrown error for successful conversion: ${error}`); } global.fetch = originalFetch; }); await testFunction('URL Reader - Markdown conversion error handling', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; const originalFetch = global.fetch; global.fetch = async () => { return { ok: true, text: async () => '<html><body><h1>Test</h1></body></html>' } as any; }; // Mock NodeHtmlMarkdown to throw an error const { NodeHtmlMarkdown } = await import('node-html-markdown'); const originalTranslate = NodeHtmlMarkdown.translate; (NodeHtmlMarkdown as any).translate = () => { throw new Error('Markdown conversion failed'); }; try { await fetchAndConvertToMarkdown(mockServer, 'https://example.com'); assert.fail('Should have thrown conversion error'); } catch (error: any) { assert.ok(error.message.includes('Conversion Error') || error.message.includes('conversion') || error.name === 'MCPSearXNGError'); } // Restore original function (NodeHtmlMarkdown as any).translate = originalTranslate; global.fetch = originalFetch; }); await testFunction('URL Reader - Empty markdown after conversion warning', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; // Clear cache to ensure fresh results urlCache.clear(); const originalFetch = global.fetch; global.fetch = async () => { return { ok: true, text: async () => '<html><body><div></div></body></html>' // HTML that converts to empty markdown } as any; }; // Mock NodeHtmlMarkdown to return empty string const { NodeHtmlMarkdown } = await import('node-html-markdown'); const originalTranslate = NodeHtmlMarkdown.translate; (NodeHtmlMarkdown as any).translate = (html: string) => ''; try { const result = await fetchAndConvertToMarkdown(mockServer, 'https://example.com'); assert.ok(typeof result === 'string'); assert.ok(result.includes('Content Warning') || result.includes('empty')); } catch (error) { assert.fail(`Should not have thrown error for empty markdown conversion: ${error}`); } // Restore original function (NodeHtmlMarkdown as any).translate = originalTranslate; global.fetch = originalFetch; }); await testFunction('URL Reader - Proxy agent integration', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; const originalFetch = global.fetch; const originalProxy = process.env.HTTPS_PROXY; let capturedOptions: RequestInit | undefined; // Clear cache to ensure we hit the network urlCache.clear(); process.env.HTTPS_PROXY = 'https://proxy.example.com:8080'; global.fetch = async (url: string | URL | Request, options?: RequestInit) => { capturedOptions = options; return { ok: true, text: async () => '<html><body><h1>Test with proxy</h1></body></html>' } as any; }; try { await fetchAndConvertToMarkdown(mockServer, 'https://example.com'); // We can't easily verify the proxy agent is set, but we can verify options were passed assert.ok(capturedOptions !== undefined); assert.ok(capturedOptions?.signal instanceof AbortSignal); } catch (error) { assert.fail(`Should not have thrown error with proxy: ${error}`); } global.fetch = originalFetch; if (originalProxy) process.env.HTTPS_PROXY = originalProxy; else delete process.env.HTTPS_PROXY; }); await testFunction('URL Reader - Unexpected error handling', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; // Clear cache to ensure we hit the network urlCache.clear(); const originalFetch = global.fetch; global.fetch = async () => { // Throw an unexpected error that's not a network, server, or abort error const error = new Error('Unexpected system error'); error.name = 'UnexpectedError'; throw error; }; try { await fetchAndConvertToMarkdown(mockServer, 'https://example.com'); assert.fail('Should have thrown unexpected error'); } catch (error: any) { assert.ok(error.message.includes('Unexpected Error') || error.message.includes('system error') || error.name === 'MCPSearXNGError'); } global.fetch = originalFetch; }); await testFunction('URL Reader - Custom timeout parameter', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; const originalFetch = global.fetch; let timeoutUsed = 0; global.fetch = async (url: string | URL | Request, options?: RequestInit): Promise<Response> => { // Check if abort signal is set and track timing return new Promise((resolve) => { if (options?.signal) { options.signal.addEventListener('abort', () => { timeoutUsed = Date.now(); }); } resolve({ ok: true, text: async () => '<html><body><h1>Fast response</h1></body></html>' } as any); }); }; const startTime = Date.now(); try { const result = await fetchAndConvertToMarkdown(mockServer, 'https://example.com', 5000); // 5 second timeout assert.ok(typeof result === 'string'); assert.ok(result.length > 0); } catch (error) { assert.fail(`Should not have thrown error with custom timeout: ${error}`); } global.fetch = originalFetch; }); // === URL READER PAGINATION TESTS === await testFunction('URL Reader - Character pagination (startChar and maxLength)', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; // Clear cache to ensure fresh results urlCache.clear(); const originalFetch = global.fetch; const testHtml = '<html><body><h1>Test Title</h1><p>This is a long paragraph with lots of content that we can paginate through.</p></body></html>'; global.fetch = async () => ({ ok: true, text: async () => testHtml } as any); try { // Test maxLength only - be more lenient with expectations const result1 = await fetchAndConvertToMarkdown(mockServer, 'https://test-char-pagination-1.com', 10000, { maxLength: 20 }); assert.ok(typeof result1 === 'string'); assert.ok(result1.length <= 20, `Expected length <= 20, got ${result1.length}: "${result1}"`); // Test startChar only const result2 = await fetchAndConvertToMarkdown(mockServer, 'https://test-char-pagination-2.com', 10000, { startChar: 10 }); assert.ok(typeof result2 === 'string'); assert.ok(result2.length > 0); // Test both startChar and maxLength const result3 = await fetchAndConvertToMarkdown(mockServer, 'https://test-char-pagination-3.com', 10000, { startChar: 5, maxLength: 15 }); assert.ok(typeof result3 === 'string'); assert.ok(result3.length <= 15, `Expected length <= 15, got ${result3.length}`); // Test startChar beyond content length const result4 = await fetchAndConvertToMarkdown(mockServer, 'https://test-char-pagination-4.com', 10000, { startChar: 10000 }); assert.equal(result4, ''); } catch (error) { assert.fail(`Should not have thrown error with character pagination: ${error}`); } global.fetch = originalFetch; }); await testFunction('URL Reader - Section extraction by heading', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; // Clear cache to ensure fresh results urlCache.clear(); const originalFetch = global.fetch; const testHtml = ` <html><body> <h1>Introduction</h1> <p>This is the intro section.</p> <h2>Getting Started</h2> <p>This is the getting started section.</p> <h1>Advanced Topics</h1> <p>This is the advanced section.</p> </body></html> `; global.fetch = async () => ({ ok: true, text: async () => testHtml } as any); try { // Test finding a section const result1 = await fetchAndConvertToMarkdown(mockServer, 'https://example.com', 10000, { section: 'Getting Started' }); assert.ok(typeof result1 === 'string'); assert.ok(result1.includes('getting started') || result1.includes('Getting Started')); // Test section not found const result2 = await fetchAndConvertToMarkdown(mockServer, 'https://example.com', 10000, { section: 'Nonexistent Section' }); assert.ok(result2.includes('Section "Nonexistent Section" not found')); } catch (error) { assert.fail(`Should not have thrown error with section extraction: ${error}`); } global.fetch = originalFetch; }); await testFunction('URL Reader - Paragraph range filtering', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; // Clear cache to ensure fresh results urlCache.clear(); const originalFetch = global.fetch; const testHtml = ` <html><body> <p>First paragraph.</p> <p>Second paragraph.</p> <p>Third paragraph.</p> <p>Fourth paragraph.</p> <p>Fifth paragraph.</p> </body></html> `; global.fetch = async () => ({ ok: true, text: async () => testHtml } as any); try { // Test single paragraph const result1 = await fetchAndConvertToMarkdown(mockServer, 'https://example.com', 10000, { paragraphRange: '2' }); assert.ok(typeof result1 === 'string'); assert.ok(result1.includes('Second') || result1.length > 0); // Test range const result2 = await fetchAndConvertToMarkdown(mockServer, 'https://example.com', 10000, { paragraphRange: '1-3' }); assert.ok(typeof result2 === 'string'); assert.ok(result2.length > 0); // Test range to end const result3 = await fetchAndConvertToMarkdown(mockServer, 'https://example.com', 10000, { paragraphRange: '3-' }); assert.ok(typeof result3 === 'string'); assert.ok(result3.length > 0); // Test invalid range const result4 = await fetchAndConvertToMarkdown(mockServer, 'https://example.com', 10000, { paragraphRange: 'invalid' }); assert.ok(result4.includes('invalid or out of bounds')); } catch (error) { assert.fail(`Should not have thrown error with paragraph range filtering: ${error}`); } global.fetch = originalFetch; }); await testFunction('URL Reader - Headings only extraction', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; // Clear cache to ensure fresh results urlCache.clear(); const originalFetch = global.fetch; const testHtml = ` <html><body> <h1>Main Title</h1> <p>Some content here.</p> <h2>Subtitle</h2> <p>More content.</p> <h3>Sub-subtitle</h3> <p>Even more content.</p> </body></html> `; global.fetch = async () => ({ ok: true, text: async () => testHtml } as any); try { const result = await fetchAndConvertToMarkdown(mockServer, 'https://example.com', 10000, { readHeadings: true }); assert.ok(typeof result === 'string'); assert.ok(result.includes('Main Title') || result.includes('#')); // Should not include regular paragraph content assert.ok(!result.includes('Some content here') || result.length < 100); } catch (error) { assert.fail(`Should not have thrown error with headings extraction: ${error}`); } global.fetch = originalFetch; }); await testFunction('URL Reader - Cache integration with pagination', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, } as any; const originalFetch = global.fetch; let fetchCount = 0; const testHtml = '<html><body><h1>Cached Content</h1><p>This content should be cached.</p></body></html>'; global.fetch = async () => { fetchCount++; return { ok: true, text: async () => testHtml } as any; }; try { // Clear cache first urlCache.clear(); // First request should fetch from network const result1 = await fetchAndConvertToMarkdown(mockServer, 'https://cache-test.com', 10000, { maxLength: 50 }); assert.equal(fetchCount, 1); assert.ok(typeof result1 === 'string'); assert.ok(result1.length <= 50); // Should be truncated to 50 or less // Second request with different pagination should use cache const result2 = await fetchAndConvertToMarkdown(mockServer, 'https://cache-test.com', 10000, { startChar: 10, maxLength: 30 }); assert.equal(fetchCount, 1); // Should not have fetched again assert.ok(typeof result2 === 'string'); assert.ok(result2.length <= 30); // Should be truncated to 30 or less // Third request with no pagination should use cache const result3 = await fetchAndConvertToMarkdown(mockServer, 'https://cache-test.com'); assert.equal(fetchCount, 1); // Should still not have fetched again assert.ok(typeof result3 === 'string'); urlCache.clear(); } catch (error) { assert.fail(`Should not have thrown error with cache integration: ${error}`); } global.fetch = originalFetch; }); // === HTTP SERVER MODULE TESTS === await testFunction('HTTP Server - Health check endpoint', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, connect: async () => {}, } as any; try { const app = await createHttpServer(mockServer); // Mock request and response for health endpoint const mockReq = { method: 'GET', url: '/health', headers: {}, body: {} } as any; const mockRes = { json: (data: any) => { assert.ok(data.status === 'healthy'); assert.ok(data.server === 'ihor-sokoliuk/mcp-searxng'); assert.ok(data.transport === 'http'); return mockRes; }, status: () => mockRes, send: () => mockRes } as any; // Test health endpoint directly by extracting the handler const routes = (app as any)._router?.stack || []; const healthRoute = routes.find((layer: any) => layer.route && layer.route.path === '/health' && layer.route.methods.get ); if (healthRoute) { const handler = healthRoute.route.stack[0].handle; handler(mockReq, mockRes); } else { // Fallback: just verify the app was created successfully assert.ok(app); } } catch (error) { assert.fail(`Should not have thrown error testing health endpoint: ${error}`); } }); await testFunction('HTTP Server - CORS configuration', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, connect: async () => {}, } as any; try { const app = await createHttpServer(mockServer); // Just verify the app was created successfully with CORS // CORS middleware is added during server creation assert.ok(app); assert.ok(typeof app.use === 'function'); } catch (error) { assert.fail(`Should not have thrown error with CORS configuration: ${error}`); } }); await testFunction('HTTP Server - POST /mcp invalid request handling', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, connect: async () => {}, } as any; try { const app = await createHttpServer(mockServer); // Mock request without session ID and not an initialize request const mockReq = { method: 'POST', url: '/mcp', headers: {}, body: { jsonrpc: '2.0', method: 'someMethod', id: 1 } // Not an initialize request } as any; let responseStatus = 200; let responseData: any = null; const mockRes = { status: (code: number) => { responseStatus = code; return mockRes; }, json: (data: any) => { responseData = data; return mockRes; }, send: () => mockRes } as any; // Extract and test the POST /mcp handler const routes = (app as any)._router?.stack || []; const mcpRoute = routes.find((layer: any) => layer.route && layer.route.path === '/mcp' && layer.route.methods.post ); if (mcpRoute) { const handler = mcpRoute.route.stack[0].handle; await handler(mockReq, mockRes); assert.equal(responseStatus, 400); assert.ok(responseData?.error); assert.ok(responseData.error.code === -32000); assert.ok(responseData.error.message.includes('Bad Request')); } else { // Fallback: just verify the app has the route assert.ok(app); } } catch (error) { assert.fail(`Should not have thrown error testing invalid POST request: ${error}`); } }); await testFunction('HTTP Server - GET /mcp invalid session handling', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, connect: async () => {}, } as any; try { const app = await createHttpServer(mockServer); // Mock GET request without valid session ID const mockReq = { method: 'GET', url: '/mcp', headers: {}, body: {} } as any; let responseStatus = 200; let responseMessage = ''; const mockRes = { status: (code: number) => { responseStatus = code; return mockRes; }, send: (message: string) => { responseMessage = message; return mockRes; }, json: () => mockRes } as any; // Extract and test the GET /mcp handler const routes = (app as any)._router?.stack || []; const mcpRoute = routes.find((layer: any) => layer.route && layer.route.path === '/mcp' && layer.route.methods.get ); if (mcpRoute) { const handler = mcpRoute.route.stack[0].handle; await handler(mockReq, mockRes); assert.equal(responseStatus, 400); assert.ok(responseMessage.includes('Invalid or missing session ID')); } else { // Fallback: just verify the app has the route assert.ok(app); } } catch (error) { assert.fail(`Should not have thrown error testing invalid GET request: ${error}`); } }); await testFunction('HTTP Server - DELETE /mcp invalid session handling', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, connect: async () => {}, } as any; try { const app = await createHttpServer(mockServer); // Mock DELETE request without valid session ID const mockReq = { method: 'DELETE', url: '/mcp', headers: {}, body: {} } as any; let responseStatus = 200; let responseMessage = ''; const mockRes = { status: (code: number) => { responseStatus = code; return mockRes; }, send: (message: string) => { responseMessage = message; return mockRes; }, json: () => mockRes } as any; // Extract and test the DELETE /mcp handler const routes = (app as any)._router?.stack || []; const mcpRoute = routes.find((layer: any) => layer.route && layer.route.path === '/mcp' && layer.route.methods.delete ); if (mcpRoute) { const handler = mcpRoute.route.stack[0].handle; await handler(mockReq, mockRes); assert.equal(responseStatus, 400); assert.ok(responseMessage.includes('Invalid or missing session ID')); } else { // Fallback: just verify the app has the route assert.ok(app); } } catch (error) { assert.fail(`Should not have thrown error testing invalid DELETE request: ${error}`); } }); await testFunction('HTTP Server - POST /mcp initialize request handling', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, connect: async (transport: any) => { // Mock successful connection return Promise.resolve(); }, } as any; try { const app = await createHttpServer(mockServer); // Just verify the app was created and has the POST /mcp endpoint // The actual initialize request handling is complex and involves // transport creation which is hard to mock properly assert.ok(app); assert.ok(typeof app.post === 'function'); // The initialize logic exists in the server code // We verify it doesn't throw during setup assert.ok(true); } catch (error) { // Accept that this is a complex integration test // The important part is that the server creation doesn't fail assert.ok(true); } }); await testFunction('HTTP Server - Session reuse with existing session ID', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, connect: async () => Promise.resolve(), } as any; try { const app = await createHttpServer(mockServer); // This test verifies the session reuse logic exists in the code // The actual session management is complex, but we can verify // the server handles the session logic properly assert.ok(app); assert.ok(typeof app.post === 'function'); // The session reuse logic is present in the POST /mcp handler // We verify the server creation includes this functionality assert.ok(true); } catch (error) { assert.fail(`Should not have thrown error testing session reuse: ${error}`); } }); await testFunction('HTTP Server - Transport cleanup on close', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, connect: async () => Promise.resolve(), } as any; try { const app = await createHttpServer(mockServer); // This test verifies that transport cleanup logic exists // The actual cleanup happens when transport.onclose is called // We verify the server creates the cleanup logic assert.ok(app); assert.ok(typeof app.post === 'function'); // The cleanup logic is in the POST /mcp initialize handler // It sets transport.onclose to clean up the transports map assert.ok(true); } catch (error) { assert.fail(`Should not have thrown error testing transport cleanup: ${error}`); } }); await testFunction('HTTP Server - Middleware stack configuration', async () => { const mockServer = { notification: () => {}, _serverInfo: { name: 'test', version: '1.0' }, _capabilities: {}, connect: async () => Promise.resolve(), } as any; try { const app = await createHttpServer(mockServer); // Verify that the server was configured successfully // It should have express.json() middleware, CORS, and route handlers assert.ok(app); assert.ok(typeof app.use === 'function'); assert.ok(typeof app.post === 'function'); assert.ok(typeof app.get === 'function'); assert.ok(typeof app.delete === 'function'); // Server configured successfully with all necessary middleware assert.ok(true); } catch (error) { assert.fail(`Should not have thrown error testing middleware configuration: ${error}`); } }); // 🧪 Index.ts Core Server Tests console.log('\n🔥 Index.ts Core Server Tests'); await testFunction('Index - Type guard isSearXNGWebSearchArgs', () => { // Test the actual exported function assert.equal(isSearXNGWebSearchArgs({ query: 'test search', language: 'en' }), true); assert.equal(isSearXNGWebSearchArgs({ query: 'test', pageno: 1, time_range: 'day' }), true); assert.equal(isSearXNGWebSearchArgs({ notQuery: 'invalid' }), false); assert.equal(isSearXNGWebSearchArgs(null), false); assert.equal(isSearXNGWebSearchArgs(undefined), false); assert.equal(isSearXNGWebSearchArgs('string'), false); assert.equal(isSearXNGWebSearchArgs(123), false); assert.equal(isSearXNGWebSearchArgs({}), false); }); await testFunction('Index - Type guard isWebUrlReadArgs', () => { // Test the actual exported function - basic cases assert.equal(isWebUrlReadArgs({ url: 'https://example.com' }), true); assert.equal(isWebUrlReadArgs({ url: 'http://test.com' }), true); assert.equal(isWebUrlReadArgs({ notUrl: 'invalid' }), false); assert.equal(isWebUrlReadArgs(null), false); assert.equal(isWebUrlReadArgs(undefined), false); assert.equal(isWebUrlReadArgs('string'), false); assert.equal(isWebUrlReadArgs(123), false); assert.equal(isWebUrlReadArgs({}), false); // Test with new pagination parameters assert.equal(isWebUrlReadArgs({ url: 'https://example.com', startChar: 0 }), true); assert.equal(isWebUrlReadArgs({ url: 'https://example.com', maxLength: 100 }), true); assert.equal(isWebUrlReadArgs({ url: 'https://example.com', section: 'intro' }), true); assert.equal(isWebUrlReadArgs({ url: 'https://example.com', paragraphRange: '1-5' }), true); assert.equal(isWebUrlReadArgs({ url: 'https://example.com', readHeadings: true }), true); // Test with all parameters assert.equal(isWebUrlReadArgs({ url: 'https://example.com', startChar: 10, maxLength: 200, section: 'section1', paragraphRange: '2-4', readHeadings: false }), true); // Test invalid parameter types assert.equal(isWebUrlReadArgs({ url: 'https://example.com', startChar: -1 }), false); assert.equal(isWebUrlReadArgs({ url: 'https://example.com', maxLength: 0 }), false); assert.equal(isWebUrlReadArgs({ url: 'https://example.com', startChar: 'invalid' }), false); assert.equal(isWebUrlReadArgs({ url: 'https://example.com', maxLength: 'invalid' }), false); assert.equal(isWebUrlReadArgs({ url: 'https://example.com', section: 123 }), false); assert.equal(isWebUrlReadArgs({ url: 'https://example.com', paragraphRange: 123 }), false); assert.equal(isWebUrlReadArgs({ url: 'https://example.com', readHeadings: 'invalid' }), false); }); // 🧪 Integration Tests - Server Creation and Handlers await testFunction('Index - Type guard isSearXNGWebSearchArgs', () => { // Test the actual exported function assert.equal(isSearXNGWebSearchArgs({ query: 'test search', language: 'en' }), true); assert.equal(isSearXNGWebSearchArgs({ query: 'test', pageno: 1, time_range: 'day' }), true); assert.equal(isSearXNGWebSearchArgs({ notQuery: 'invalid' }), false); assert.equal(isSearXNGWebSearchArgs(null), false); assert.equal(isSearXNGWebSearchArgs(undefined), false); assert.equal(isSearXNGWebSearchArgs('string'), false); assert.equal(isSearXNGWebSearchArgs(123), false); assert.equal(isSearXNGWebSearchArgs({}), false); }); await testFunction('Index - Type guard isWebUrlReadArgs', () => { // Test the actual exported function - basic cases assert.equal(isWebUrlReadArgs({ url: 'https://example.com' }), true); assert.equal(isWebUrlReadArgs({ url: 'http://test.com' }), true); assert.equal(isWebUrlReadArgs({ notUrl: 'invalid' }), false); assert.equal(isWebUrlReadArgs(null), false); assert.equal(isWebUrlReadArgs(undefined), false); assert.equal(isWebUrlReadArgs('string'), false); assert.equal(isWebUrlReadArgs(123), false); assert.equal(isWebUrlReadArgs({}), false); // Test with new pagination parameters assert.equal(isWebUrlReadArgs({ url: 'https://example.com', startChar: 0 }), true); assert.equal(isWebUrlReadArgs({ url: 'https://example.com', maxLength: 100 }), true); assert.equal(isWebUrlReadArgs({ url: 'https://example.com', section: 'intro' }), true); assert.equal(isWebUrlReadArgs({ url: 'https://example.com', paragraphRange: '1-5' }), true); assert.equal(isWebUrlReadArgs({ url: 'https://example.com', readHeadings: true }), true); // Test with all parameters assert.equal(isWebUrlReadArgs({ url: 'https://example.com', startChar: 10, maxLength: 200, section: 'section1', paragraphRange: '2-4', readHeadings: false }), true); // Test invalid parameter types assert.equal(isWebUrlReadArgs({ url: 'https://example.com', startChar: -1 }), false); assert.equal(isWebUrlReadArgs({ url: 'https://example.com', maxLength: 0 }), false); assert.equal(isWebUrlReadArgs({ url: 'https://example.com', startChar: 'invalid' }), false); assert.equal(isWebUrlReadArgs({ url: 'https://example.com', maxLength: 'invalid' }), false); assert.equal(isWebUrlReadArgs({ url: 'https://example.com', section: 123 }), false); assert.equal(isWebUrlReadArgs({ url: 'https://example.com', paragraphRange: 123 }), false); assert.equal(isWebUrlReadArgs({ url: 'https://example.com', readHeadings: 'invalid' }), false); }); // 🧪 Integration Tests - Server Creation and Handlers console.log('\n🔥 Index.ts Integration Tests'); await testFunction('Index - Call tool handler error handling', async () => { // Test error handling for invalid arguments const invalidSearchArgs = { notQuery: 'invalid' }; const invalidUrlArgs = { notUrl: 'invalid' }; assert.ok(!isSearXNGWebSearchArgs(invalidSearchArgs)); assert.ok(!isWebUrlReadArgs(invalidUrlArgs)); // Test unknown tool error const unknownToolRequest = { name: 'unknown_tool', arguments: {} }; assert.notEqual(unknownToolRequest.name, 'searxng_web_search'); assert.notEqual(unknownToolRequest.name, 'web_url_read'); // Simulate error response try { if (unknownToolRequest.name !== 'searxng_web_search' && unknownToolRequest.name !== 'web_url_read') { throw new Error(`Unknown tool: ${unknownToolRequest.name}`); } } catch (error) { assert.ok(error instanceof Error); assert.ok(error.message.includes('Unknown tool')); } }); await testFunction('Index - URL read tool with pagination parameters integration', async () => { // Test that pagination parameters are properly passed through the system const validArgs = { url: 'https://example.com', startChar: 10, maxLength: 100, section: 'introduction', paragraphRange: '1-3', readHeadings: false }; // Verify type guard accepts the parameters assert.ok(isWebUrlReadArgs(validArgs)); // Test individual parameter validation assert.ok(isWebUrlReadArgs({ url: 'https://example.com', startChar: 0 })); assert.ok(isWebUrlReadArgs({ url: 'https://example.com', maxLength: 1 })); assert.ok(isWebUrlReadArgs({ url: 'https://example.com', section: 'test' })); assert.ok(isWebUrlReadArgs({ url: 'https://example.com', paragraphRange: '1' })); assert.ok(isWebUrlReadArgs({ url: 'https://example.com', readHeadings: true })); // Test edge cases that should fail validation assert.ok(!isWebUrlReadArgs({ url: 'https://example.com', startChar: -1 })); assert.ok(!isWebUrlReadArgs({ url: 'https://example.com', maxLength: 0 })); assert.ok(!isWebUrlReadArgs({ url: 'https://example.com', section: null })); assert.ok(!isWebUrlReadArgs({ url: 'https://example.com', paragraphRange: null })); assert.ok(!isWebUrlReadArgs({ url: 'https://example.com', readHeadings: 'not-a-boolean' })); }); await testFunction('Index - Pagination options object construction', async () => { // Simulate what happens in the main tool handler const testArgs = { url: 'https://example.com', startChar: 50, maxLength: 200, section: 'getting-started', paragraphRange: '2-5', readHeadings: true }; // This mimics the pagination options construction in index.ts const paginationOptions = { startChar: testArgs.startChar, maxLength: testArgs.maxLength, section: testArgs.section, paragraphRange: testArgs.paragraphRange, readHeadings: testArgs.readHeadings, }; assert.equal(paginationOptions.startChar, 50); assert.equal(paginationOptions.maxLength, 200); assert.equal(paginationOptions.section, 'getting-started'); assert.equal(paginationOptions.paragraphRange, '2-5'); assert.equal(paginationOptions.readHeadings, true); // Test with undefined values (should work fine) const testArgsPartial = { url: 'https://example.com', maxLength: 100 }; const paginationOptionsPartial = { startChar: testArgsPartial.startChar, maxLength: testArgsPartial.maxLength, section: testArgsPartial.section, paragraphRange: testArgsPartial.paragraphRange, readHeadings: testArgsPartial.readHeadings, }; assert.equal(paginationOptionsPartial.startChar, undefined); assert.equal(paginationOptionsPartial.maxLength, 100); assert.equal(paginationOptionsPartial.section, undefined); }); await testFunction('Index - Set log level handler simulation', async () => { const { setLogLevel } = await import('./src/logging.js'); // Test valid log level const validLevel = 'debug' as LoggingLevel; // This would be the handler logic let currentTestLevel = 'info' as LoggingLevel; currentTestLevel = validLevel; setLogLevel(validLevel); assert.equal(currentTestLevel, 'debug'); // Response should be empty object const response = {}; assert.deepEqual(response, {}); }); await testFunction('Index - Read resource handler simulation', async () => { // Test config resource const configUri = "config://server-config"; const configContent = createConfigResource(); const configResponse = { contents: [ { uri: configUri, mimeType: "application/json", text: configContent } ] }; assert.equal(configResponse.contents[0].uri, configUri); assert.equal(configResponse.contents[0].mimeType, "application/json"); assert.ok(typeof configResponse.contents[0].text === 'string'); // Test help resource const helpUri = "help://usage-guide"; const helpContent = createHelpResource(); const helpResponse = { contents: [ { uri: helpUri, mimeType: "text/markdown", text: helpContent } ] }; assert.equal(helpResponse.contents[0].uri, helpUri); assert.equal(helpResponse.contents[0].mimeType, "text/markdown"); assert.ok(typeof helpResponse.contents[0].text === 'string'); // Test unknown resource error const testUnknownResource = (uri: string) => { if (uri !== "config://server-config" && uri !== "help://usage-guide") { throw new Error(`Unknown resource: ${uri}`); } }; try { testUnknownResource("unknown://resource"); } catch (error) { assert.ok(error instanceof Error); assert.ok(error.message.includes('Unknown resource')); } }); // === TEST RESULTS SUMMARY === console.log('\n🏁 Test Results Summary:'); console.log(`✅ Passed: ${testResults.passed}`); console.log(`❌ Failed: ${testResults.failed}`); if (testResults.failed > 0) { console.log(`📊 Success Rate: ${Math.round((testResults.passed / (testResults.passed + testResults.failed)) * 100)}%`); } else { console.log('📊 Success Rate: 100%'); } if (testResults.errors.length > 0) { console.log('\n❌ Failed Tests:'); testResults.errors.forEach(error => console.log(error)); } console.log('\n📋 Enhanced Test Suite Summary:'); console.log(`• Total Tests: ${testResults.passed + testResults.failed}`); console.log(`• Tests Passed: ${testResults.passed}`); console.log(`• Success Rate: ${testResults.failed === 0 ? '100%' : Math.round((testResults.passed / (testResults.passed + testResults.failed)) * 100) + '%'}`); console.log('• Coverage: See detailed report above ⬆️'); console.log('• Enhanced testing includes error handling, edge cases, and integration scenarios'); if (testResults.failed === 0) { console.log('\n🎉 SUCCESS: All tests passed!'); console.log('📋 Enhanced comprehensive unit tests covering all core modules'); process.exit(0); } else { console.log('\n⚠️ Some tests failed - check the errors above'); process.exit(1); } } runTests().catch(console.error); ```