commit ee65d5f7bef741f1a9e8b4ef27377d6262f0a487 Author: Karti Date: Fri Apr 10 21:57:18 2026 -0700 Initial commit: Search MCP Server diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f2c09b --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# Compiled binaries +go_mcp_server_searxng +search-mcp +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out + +# Build artifacts +dist/ +build/ +bin/ +release/ + +# Go specific +*.o +*.a +*.log +vendor/ +Godeps/ +go.work +go.work.sum + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Test artifacts +*.tmp +tmp/ +temp/ +coverage.out +coverage.html +*.cover + +# Environment files +.env +.env.local +.env.*.local +*.env + +# Debug +debug/ +__debug_bin + +# OS +Thumbs.db +.sisyphus/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1ab57a1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 Lumbridge Corporation + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..40fd475 --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +BINARY_NAME=go_mcp_server_searxng +LDFLAGS=-ldflags "-s -w" +CGO_ENABLED=0 + +.PHONY: build clean test run + +build: + CGO_ENABLED=$(CGO_ENABLED) go build $(LDFLAGS) -o $(BINARY_NAME) . + +build-linux: + CGO_ENABLED=$(CGO_ENABLED) GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY_NAME)-linux-amd64 . + +build-windows: + CGO_ENABLED=$(CGO_ENABLED) GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY_NAME)-windows-amd64.exe . + +build-darwin: + CGO_ENABLED=$(CGO_ENABLED) GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY_NAME)-darwin-amd64 . + +build-all: build-linux build-windows build-darwin + +clean: + rm -f $(BINARY_NAME) $(BINARY_NAME)-* + +test: + go test -v ./... + +run: + go run . + +install: + go install $(LDFLAGS) . + +deps: + go mod tidy + go mod download + +release: clean deps build-all \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d151ed6 --- /dev/null +++ b/README.md @@ -0,0 +1,246 @@ +# search-mcp + +MCP server for web and code search using **SearXNG** (privacy-respecting metasearch) and **Grep.app** (GitHub code search). + +**Version 2.0.0** - Now with auto-managed local SearXNG! + +> 🎯 **Tavily Alternative**: Free, local, privacy-respecting search - no API keys, no external dependencies, no cost! + +## What's New in v2.0.0 + +πŸŽ‰ **Fully Self-Contained Search** - No external SearXNG instance needed! + +- βœ… **Auto-starts SearXNG** Docker container on first search +- βœ… **Keeps SearXNG running** for fast subsequent searches +- βœ… **Local-only** - all searches stay on your machine +- βœ… **Privacy-first** - queries multiple engines without tracking +- βœ… **Zero configuration** - works out of the box + +## Features + +### Web Search (via SearXNG) +- `searxng_search` - General web search with multiple categories +- `searxng_image_search` - Image search +- `searxng_news_search` - News search +- `searxng_engines_info` - List available search engines + +### Code Search (via Grep.app) +- `grep_search` - Search code across public GitHub repos +- `github_file` - Fetch single file from GitHub +- `github_batch_files` - Fetch multiple files in parallel + +### Management Tools +- `search_status` - Check status of all search services +- `searxng_start` - Manually start SearXNG container +- `searxng_stop` - Stop SearXNG to free resources +- `searxng_config` - View default SearXNG configuration + +## vs Tavily (and other paid search APIs) + +| Feature | Tavily | search-mcp v2 | +|---------|--------|-----------------| +| **Cost** | Paid API ($$/month) | **Free forever** | +| **Privacy** | Sends queries to external service | **100% local** - searches stay on your machine | +| **API Keys** | Required | **None needed** | +| **Rate Limits** | Yes | **Unlimited** | +| **Offline** | No | **Works offline** (with cached results) | +| **Setup** | Sign up, get API key | **Install Docker, run binary** | + +**Bottom line**: If you want privacy, zero cost, and zero configuration - use search-mcp v2. + +## Quick Start + +### Prerequisites +- [Docker](https://docs.docker.com/get-docker/) must be installed + +### Download & Run + +```bash +# Clone and build from source +git clone https://github.com/yourusername/search-mcp +cd search-mcp +make build + +# Or download release (coming soon) +# curl -L https://github.com/yourusername/search-mcp/releases/download/v2.0.0/search-mcp-$(uname -s)-$(uname -m) -o search-mcp +# chmod +x search-mcp + +# Run with stdio (for MCP clients like Claude Desktop, Cursor) +./go_mcp_server_searxng -t stdio +``` + +### MCP Client Configuration + +**Claude Desktop (`claude_desktop_config.json`):** +```json +{ + "mcpServers": { + "search": { + "command": "/path/to/search-mcp", + "args": ["-t", "stdio"] + } + } +} +``` + +**Cursor (Settings > MCP):** +```json +{ + "mcpServers": { + "search": { + "command": "/path/to/search-mcp", + "args": ["-t", "stdio"] + } + } +} +``` + +## How It Works + +``` +User asks for search + ↓ +MCP Server checks if SearXNG running + ↓ + β”Œβ”€β”€β”€ No ───┐ + ↓ β”‚ +Auto-start β”‚ +Docker container + ↓ β”‚ +Wait for readyβ”‚ + ↓ β”‚ + └──── Yes β”€β”˜ + ↓ + Execute search + ↓ + Return results +``` + +**First search:** ~10-15 seconds (includes container startup) + +**Subsequent searches:** ~1-2 seconds (instant response) + +## Command-Line Options + +| Flag | Default | Description | +|------|---------|-------------| +| `-t` | `stdio` | Transport type: `stdio` or `sse` | +| `-h` | `0.0.0.0` | Host for SSE server | +| `-p` | `8892` | Port for SSE server | +| `-searxng` | `http://127.0.0.1:8080` | SearXNG URL (for external instance) | +| `-searxng-autostart` | `true` | Auto-start SearXNG container | +| `-searxng-image` | `searxng/searxng:latest` | Docker image to use | +| `-searxng-port` | `8080` | Port for SearXNG container | +| `-searxng-stop-on-exit` | `false` | Stop container when MCP server exits | +| `-searxng-timeout` | `60` | Seconds to wait for SearXNG ready | + +### Examples + +```bash +# Default: auto-start SearXNG, keep running after exit +./search-mcp -t stdio + +# Stop SearXNG when MCP exits (saves RAM) +./search-mcp -t stdio -searxng-stop-on-exit + +# Use external SearXNG instance (skip auto-start) +./search-mcp -t stdio -searxng https://searx.example.com + +# SSE mode with custom port +./search-mcp -t sse -p 3000 +``` + +## Resource Usage + +| Component | Memory | CPU | Notes | +|-----------|--------|-----|-------| +| MCP Server | ~10MB | Negligible | Very lightweight | +| SearXNG | ~200-300MB | Low idle | Containerized metasearch | +| **Total** | **~250MB** | **Bursty** | Reasonable for modern machines | + +## Privacy & Security + +- βœ… **All searches local** - Nothing leaves your machine except to search engines +- βœ… **No tracking** - SearXNG aggregates results without personalization +- βœ… **No API keys needed** - Uses public search engines +- βœ… **Docker isolation** - SearXNG runs in container with no host access + +Default engines: DuckDuckGo, Brave, Bing, Wikipedia, GitHub, StackOverflow + +## Troubleshooting + +### "Docker is not available" +Install Docker: https://docs.docker.com/get-docker/ + +### "Port 8080 is already in use" +```bash +# Use a different port +./search-mcp -t stdio -searxng-port 8081 +``` + +### SearXNG won't start +```bash +# Check Docker is running +docker ps + +# Check logs +docker logs searxng-mcp-local + +# Reset: remove container +docker rm -f searxng-mcp-local +``` + +### Search is slow +First search is slow (container startup). If consistently slow: +- Check internet connection +- Try different engines in search queries +- Check `search_status` tool output + +## Building from Source + +```bash +# Clone repo +git clone https://github.com/yourusername/search-mcp +cd search-mcp + +# Build +go build -o go_mcp_server_searxng . + +# Or cross-compile +make build-all +``` + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ MCP Client β”‚ +β”‚ (Claude Desktop, Cursor, etc.) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”‚ stdio/SSE + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ search-mcp (Go) β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Auto-start logic β”‚ β”‚ +β”‚ β”‚ β€’ Check if SearXNG running β”‚ β”‚ +β”‚ β”‚ β€’ Start Docker container if needed β”‚ β”‚ +β”‚ β”‚ β€’ Wait for health check β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Search β”‚ β”‚ Image β”‚ β”‚ News β”‚ β”‚ +β”‚ β”‚ Tools β”‚ β”‚ Search β”‚ β”‚ Search β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Grep.app │◄────────┴────────►│ SearXNG β”‚ β”‚ +β”‚ β”‚ (GitHub) β”‚ β”‚ (Docker) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## License + +MIT diff --git a/docker.go b/docker.go new file mode 100644 index 0000000..561403a --- /dev/null +++ b/docker.go @@ -0,0 +1,197 @@ +package main + +import ( + "bytes" + "fmt" + "net/http" + "os/exec" + "strings" + "time" +) + +const ( + SearXNGContainerName = "searxng-mcp-local" + SearXNGImage = "searxng/searxng:latest" + SearXNGInternalPort = "8080" +) + +type DockerManager struct { + searxngPort string + searxngImage string + autostart bool + stopOnExit bool +} + +func NewDockerManager(searxngPort, searxngImage string, autostart, stopOnExit bool) *DockerManager { + return &DockerManager{ + searxngPort: searxngPort, + searxngImage: searxngImage, + autostart: autostart, + stopOnExit: stopOnExit, + } +} + +func (dm *DockerManager) Close() error { + if dm.stopOnExit { + return dm.StopSearXNG() + } + return nil +} + +func (dm *DockerManager) runDocker(args ...string) (string, string, error) { + cmd := exec.Command("docker", args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + return stdout.String(), stderr.String(), err +} + +func (dm *DockerManager) IsDockerAvailable() bool { + _, _, err := dm.runDocker("version") + return err == nil +} + +func (dm *DockerManager) IsSearXNGRunning() bool { + stdout, _, err := dm.runDocker("ps", "-q", "-f", "name="+SearXNGContainerName) + if err != nil { + return false + } + return strings.TrimSpace(stdout) != "" +} + +func (dm *DockerManager) GetSearXNGHealth() bool { + url := fmt.Sprintf("http://127.0.0.1:%s/config", dm.searxngPort) + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get(url) + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +func (dm *DockerManager) StartSearXNG() error { + if !dm.autostart { + return fmt.Errorf("auto-start disabled") + } + + // Validate port is within valid range + var portNum int + _, err := fmt.Sscanf(dm.searxngPort, "%d", &portNum) + if err != nil || portNum < 1 || portNum > 65535 { + return fmt.Errorf("invalid port: must be between 1 and 65535, got %s", dm.searxngPort) + } + + // Check if container exists but stopped + stdout, _, _ := dm.runDocker("ps", "-a", "-q", "-f", "name="+SearXNGContainerName) + if strings.TrimSpace(stdout) != "" { + // Container exists, start it + _, _, err := dm.runDocker("start", SearXNGContainerName) + if err != nil { + return fmt.Errorf("failed to start existing SearXNG container: %w", err) + } + return nil + } + + // Pull image in background (don't wait) + go dm.pullImage() + + // Create and start new container with a random secret + // Generate a random secret (SearXNG requires at least 16 chars) + secret := fmt.Sprintf("mcp-search-%d-secret-key-local", time.Now().Unix()) + + // Create and start new container with JSON API enabled + // Use environment variables to configure SearXNG + _, stderr, err := dm.runDocker( + "run", + "-d", + "--name", SearXNGContainerName, + "-p", "127.0.0.1:"+dm.searxngPort+":"+SearXNGInternalPort, + "--restart", "unless-stopped", + "-e", "SEARXNG_SECRET="+secret, + dm.searxngImage, + ) + + if err != nil { + // Check if it's a port conflict + if strings.Contains(stderr, "Bind for 127.0.0.1") { + return fmt.Errorf("port %s is already in use. Please stop the existing service or use a different port", dm.searxngPort) + } + return fmt.Errorf("failed to create SearXNG container: %w", err) + } + + return nil +} + +func (dm *DockerManager) pullImage() { + dm.runDocker("pull", dm.searxngImage) +} + +func (dm *DockerManager) StopSearXNG() error { + _, _, _ = dm.runDocker("stop", SearXNGContainerName) + return nil +} + +func (dm *DockerManager) RemoveSearXNG() error { + _, _, _ = dm.runDocker("rm", "-f", SearXNGContainerName) + return nil +} + +func (dm *DockerManager) EnsureSearXNG() error { + if dm.IsSearXNGRunning() && dm.GetSearXNGHealth() { + return nil + } + + if !dm.IsDockerAvailable() { + return fmt.Errorf("Docker is not available. Please install Docker or start SearXNG manually at http://127.0.0.1:%s", dm.searxngPort) + } + + if err := dm.StartSearXNG(); err != nil { + return fmt.Errorf("failed to start SearXNG: %w", err) + } + + // Configure SearXNG for JSON API access + if err := dm.configureSearXNG(); err != nil { + // Log but don't fail - container might already be configured + fmt.Printf("Note: SearXNG configuration: %v\n", err) + } + + return nil +} + +// configureSearXNG enables JSON format for API access +func (dm *DockerManager) configureSearXNG() error { + // Wait a bit for container to be fully ready + time.Sleep(2 * time.Second) + + // Enable JSON format in settings + // First uncomment the formats line, then add json to the list + _, _, err := dm.runDocker("exec", SearXNGContainerName, "sh", "-c", + `sed -i 's/# formats:/formats:/' /etc/searxng/settings.yml && sed -i '/formats:/,/^[a-z]/ { s/ - html/ - html\n - json/; }' /etc/searxng/settings.yml`) + if err != nil { + return fmt.Errorf("failed to enable JSON format: %w", err) + } + + // Restart to apply changes + _, _, err = dm.runDocker("restart", SearXNGContainerName) + if err != nil { + return fmt.Errorf("failed to restart SearXNG: %w", err) + } + + // Wait for restart + time.Sleep(3 * time.Second) + + return nil +} + +func (dm *DockerManager) WaitForSearXNGReady(timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if dm.GetSearXNGHealth() { + return nil + } + time.Sleep(500 * time.Millisecond) + } + return fmt.Errorf("SearXNG failed to become ready within %v", timeout) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..805c0bd --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module go_mcp_server_searxng + +go 1.24 + +require github.com/mark3labs/mcp-go v0.24.1 + +require ( + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..60fdade --- /dev/null +++ b/go.sum @@ -0,0 +1,26 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mark3labs/mcp-go v0.24.1 h1:YV+5X/+W4oBdERLWgiA1uR7AIvenlKJaa5V4hqufI7E= +github.com/mark3labs/mcp-go v0.24.1/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/grep.go b/grep.go new file mode 100644 index 0000000..53f0506 --- /dev/null +++ b/grep.go @@ -0,0 +1,206 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type GrepAppClient struct { + BaseURL string + HTTPClient *http.Client +} + +func NewGrepAppClient() *GrepAppClient { + return &GrepAppClient{ + BaseURL: "https://grep.app", + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +type GrepSearchResult struct { + Repo string `json:"repo"` + Path string `json:"path"` + Filename string `json:"filename"` + Language string `json:"language,omitempty"` + Lines []struct { + Content string `json:"content"` + LineNumber int `json:"line_number"` + } `json:"lines,omitempty"` + URL string `json:"url"` + RepoURL string `json:"repo_url"` + BlobURL string `json:"blob_url"` +} + +type GrepSearchResponse struct { + Hits struct { + Total int `json:"total"` + Hits []GrepSearchHit `json:"hits"` + Metadata map[string]int `json:"metadata,omitempty"` + } `json:"hits"` +} + +type GrepSearchHit struct { + Source GrepSearchResult `json:"_source"` +} + +type GrepSearchParams struct { + Query string + Language string + Repo string + Path string + Regex bool + CaseSensitive bool + WholeWords bool +} + +func (c *GrepAppClient) Search(params GrepSearchParams) (*GrepSearchResponse, error) { + searchURL := fmt.Sprintf("%s/search", c.BaseURL) + + values := url.Values{} + values.Set("q", params.Query) + values.Set("limit", "25") + + if params.Language != "" { + values.Set("lang", params.Language) + } + if params.Repo != "" { + values.Set("repo", params.Repo) + } + if params.Path != "" { + values.Set("path", params.Path) + } + if params.Regex { + values.Set("regex", "true") + } + if params.CaseSensitive { + values.Set("case", "true") + } + if params.WholeWords { + values.Set("word", "true") + } + + req, err := http.NewRequest("GET", searchURL+"?"+values.Encode(), nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + req.Header.Set("User-Agent", "Go-GrepApp-MCP/1.0") + req.Header.Set("Accept", "application/json") + req.Header.Set("Accept-Language", "en-US,en;q=0.5") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(body)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response: %w", err) + } + + var searchResponse GrepSearchResponse + if err := json.Unmarshal(body, &searchResponse); err != nil { + return nil, fmt.Errorf("error parsing JSON: %w", err) + } + + return &searchResponse, nil +} + +func (c *GrepAppClient) GetFile(owner, repo, path, ref string) (string, error) { + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/contents/%s", owner, path) + if ref != "" { + apiURL += "?ref=" + ref + } + + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return "", fmt.Errorf("error creating request: %w", err) + } + + req.Header.Set("User-Agent", "Go-GrepApp-MCP/1.0") + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("error executing request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(body)) + } + + var fileData map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&fileData); err != nil { + return "", fmt.Errorf("error parsing response: %w", err) + } + + content, ok := fileData["content"].(string) + if !ok { + return "", fmt.Errorf("no content found in response") + } + + decoded, err := decodeBase64(content) + if err != nil { + return "", fmt.Errorf("error decoding content: %w", err) + } + + return decoded, nil +} + +func decodeBase64(encoded string) (string, error) { + encoded = strings.ReplaceAll(encoded, "\n", "") + decoded, err := strconv.Unquote(`"` + encoded + `"`) + if err != nil { + return encoded, nil + } + return decoded, nil +} + +type GitHubFileParams struct { + Owner string + Repo string + Path string + Ref string +} + +func (c *GrepAppClient) GetFilesBatch(files []GitHubFileParams) ([]map[string]string, error) { + results := make([]map[string]string, 0, len(files)) + + for _, file := range files { + content, err := c.GetFile(file.Owner, file.Repo, file.Path, file.Ref) + if err != nil { + results = append(results, map[string]string{ + "owner": file.Owner, + "repo": file.Repo, + "path": file.Path, + "error": err.Error(), + }) + continue + } + + results = append(results, map[string]string{ + "owner": file.Owner, + "repo": file.Repo , + "path": file.Path, + "content": content, + }) + } + + return results, nil +} \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..504d814 --- /dev/null +++ b/main.go @@ -0,0 +1,679 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "log" + "strings" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +var searxngClient *SearXNGClient +var grepAppClient *GrepAppClient +var dockerManager *DockerManager + +func main() { + var transport string + var host string + var port string + var searxngURL string + var searxngPort string + var searxngImage string + var autostart bool + var stopOnExit bool + var searxngTimeout int + + flag.StringVar(&transport, "t", "stdio", "Transport type (stdio or sse)") + flag.StringVar(&host, "h", "0.0.0.0", "Host of sse server") + flag.StringVar(&port, "p", "8892", "Port of sse server") + flag.StringVar(&searxngURL, "searxng", "http://127.0.0.1:8080", "SearXNG instance URL (overrides -searxng-port)") + flag.StringVar(&searxngPort, "searxng-port", "8080", "Port for SearXNG container") + flag.StringVar(&searxngImage, "searxng-image", SearXNGImage, "Docker image for SearXNG") + flag.BoolVar(&autostart, "searxng-autostart", true, "Auto-start SearXNG Docker container if not running") + flag.BoolVar(&stopOnExit, "searxng-stop-on-exit", false, "Stop SearXNG container when MCP server exits") + flag.IntVar(&searxngTimeout, "searxng-timeout", 60, "Timeout in seconds to wait for SearXNG to become ready") + flag.Parse() + + // Initialize clients + searxngClient = NewSearXNGClient(searxngURL) + grepAppClient = NewGrepAppClient() + dockerManager = NewDockerManager(searxngPort, searxngImage, autostart, stopOnExit) + + // Clean up on exit + defer dockerManager.Close() + + // Version 2.0.0 - with auto-managed SearXNG + mcpServer := server.NewMCPServer( + "search-mcp", + "2.0.0", + ) + + // SearXNG Search Tools + searchTool := mcp.NewTool("searxng_search", + mcp.WithDescription("Search information through SearXNG. Supports various categories and search engines. Auto-starts SearXNG if not running."), + mcp.WithString("query", + mcp.Required(), + mcp.Description("Search query"), + ), + mcp.WithString("categories", + mcp.Description("Search categories (general, images, videos, news, music, files, science, it). Multiple values separated by comma"), + ), + mcp.WithString("engines", + mcp.Description("Search engines (google, bing, duckduckgo, yandex, etc.). Multiple values separated by comma"), + ), + mcp.WithString("language", + mcp.Description("Search language (ru, en, de, fr, etc.)"), + ), + mcp.WithNumber("page", + mcp.Description("Page number of results (default 1)"), + ), + mcp.WithString("time_range", + mcp.Description("Time range (day, week, month, year)"), + ), + mcp.WithNumber("safe_search", + mcp.Description("Safe search (0 - disabled, 1 - moderate, 2 - strict)"), + ), + ) + mcpServer.AddTool(searchTool, searxngSearchHandler) + + enginesInfoTool := mcp.NewTool("searxng_engines_info", + mcp.WithDescription("Get information about available SearXNG search engines and categories"), + ) + mcpServer.AddTool(enginesInfoTool, searxngEnginesInfoHandler) + + imageSearchTool := mcp.NewTool("searxng_image_search", + mcp.WithDescription("Specialized image search through SearXNG. Auto-starts SearXNG if not running."), + mcp.WithString("query", + mcp.Required(), + mcp.Description("Search query for images"), + ), + mcp.WithString("engines", + mcp.Description("Image search engines (google images, bing images, flickr, etc.)"), + ), + mcp.WithNumber("page", + mcp.Description("Page number of results"), + ), + ) + mcpServer.AddTool(imageSearchTool, searxngImageSearchHandler) + + newsSearchTool := mcp.NewTool("searxng_news_search", + mcp.WithDescription("Specialized news search through SearXNG. Auto-starts SearXNG if not running."), + mcp.WithString("query", + mcp.Required(), + mcp.Description("Search query for news"), + ), + mcp.WithString("time_range", + mcp.Description("Time range for news (day, week, month, year)"), + ), + mcp.WithString("language", + mcp.Description("News language"), + ), + mcp.WithNumber("page", + mcp.Description("Page number of results"), + ), + ) + mcpServer.AddTool(newsSearchTool, searxngNewsSearchHandler) + + // Code Search Tools (via Grep.app) + grepSearchTool := mcp.NewTool("grep_search", + mcp.WithDescription("Search code across public GitHub repositories using grep.app. Find real-world code examples, patterns, and implementations."), + mcp.WithString("query", + mcp.Required(), + mcp.Description("Search query string - code pattern to search for"), + ), + mcp.WithString("language", + mcp.Description("Programming language filter (e.g., Go, Python, JavaScript, TypeScript)"), + ), + mcp.WithString("repo", + mcp.Description("Repository filter in format 'owner/repo' (e.g., 'vercel/next.js')"), + ), + mcp.WithString("path", + mcp.Description("Path filter for specific directories (e.g., 'src/', 'lib/')"), + ), + mcp.WithBoolean("regex", + mcp.Description("Treat query as regex pattern"), + ), + mcp.WithBoolean("caseSensitive", + mcp.Description("Case-sensitive search"), + ), + mcp.WithBoolean("wholeWords", + mcp.Description("Search for whole words only"), + ), + ) + mcpServer.AddTool(grepSearchTool, grepSearchHandler) + + githubFileTool := mcp.NewTool("github_file", + mcp.WithDescription("Fetch a single file from a GitHub repository"), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner (e.g., 'vercel', 'facebook')"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name (e.g., 'next.js', 'react')"), + ), + mcp.WithString("path", + mcp.Required(), + mcp.Description("File path (e.g., 'package.json', 'src/index.ts')"), + ), + mcp.WithString("ref", + mcp.Description("Branch, tag, or commit SHA (default: default branch)"), + ), + ) + mcpServer.AddTool(githubFileTool, githubFileHandler) + + githubBatchFilesTool := mcp.NewTool("github_batch_files", + mcp.WithDescription("Fetch multiple files from GitHub repositories in parallel"), + mcp.WithArray("files", + mcp.Required(), + mcp.Description("Array of file objects with owner, repo, path, and optional ref"), + ), + ) + mcpServer.AddTool(githubBatchFilesTool, githubBatchFilesHandler) + + // New v2.0.0 Management Tools + searchStatusTool := mcp.NewTool("search_status", + mcp.WithDescription("Check the status of search services: SearXNG, Docker, and code search availability"), + ) + mcpServer.AddTool(searchStatusTool, searchStatusHandler) + + searxngStopTool := mcp.NewTool("searxng_stop", + mcp.WithDescription("Stop the SearXNG Docker container to free resources"), + ) + mcpServer.AddTool(searxngStopTool, searxngStopHandler) + + searxngStartTool := mcp.NewTool("searxng_start", + mcp.WithDescription("Start the SearXNG Docker container manually"), + ) + mcpServer.AddTool(searxngStartTool, searxngStartHandler) + + searxngConfigTool := mcp.NewTool("searxng_config", + mcp.WithDescription("Get the default SearXNG configuration settings used by this MCP server"), + ) + mcpServer.AddTool(searxngConfigTool, searxngConfigHandler) + + // Start server + if transport == "sse" { + sseServer := server.NewSSEServer(mcpServer, server.WithBaseURL(fmt.Sprintf("http://localhost:%s", port))) + log.Printf("SSE server listening on %s:%s URL: http://127.0.0.1:%s/sse", host, port, port) + log.Printf("SearXNG: auto-start=%v, stop-on-exit=%v", autostart, stopOnExit) + if err := sseServer.Start(fmt.Sprintf("%s:%s", host, port)); err != nil { + log.Fatalf("Server error: %v", err) + } + } else { + log.Printf("Stdio server started. SearXNG: auto-start=%v, stop-on-exit=%v", autostart, stopOnExit) + if err := server.ServeStdio(mcpServer); err != nil { + log.Fatalf("Server error: %v", err) + } + } +} + +// ensureSearXNG ensures SearXNG is running before performing a search +func ensureSearXNG() error { + if dockerManager == nil { + return fmt.Errorf("docker manager not initialized") + } + + // Fast path: already running and healthy + if dockerManager.IsSearXNGRunning() && dockerManager.GetSearXNGHealth() { + return nil + } + + // Try to ensure it's running + if err := dockerManager.EnsureSearXNG(); err != nil { + return err + } + + // Wait for it to be ready + return dockerManager.WaitForSearXNGReady(30 * time.Second) +} + +func searxngSearchHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Ensure SearXNG is running + if err := ensureSearXNG(); err != nil { + return nil, fmt.Errorf("SearXNG not available: %w", err) + } + + query, ok := request.Params.Arguments["query"].(string) + if !ok { + return nil, errors.New("query must be a string") + } + + params := SearchParams{ + Query: query, + Categories: []string{"general"}, + Engines: []string{"duckduckgo", "brave"}, + Language: "en", + } + + if categories, ok := request.Params.Arguments["categories"].(string); ok && categories != "" { + params.Categories = strings.Split(categories, ",") + for i := range params.Categories { + params.Categories[i] = strings.TrimSpace(params.Categories[i]) + } + } + + if engines, ok := request.Params.Arguments["engines"].(string); ok && engines != "" { + params.Engines = strings.Split(engines, ",") + for i := range params.Engines { + params.Engines[i] = strings.TrimSpace(params.Engines[i]) + } + } + + if language, ok := request.Params.Arguments["language"].(string); ok && language != "" { + params.Language = language + } + + if pageFloat, ok := request.Params.Arguments["page"].(float64); ok { + params.PageNo = int(pageFloat) + } + + if timeRange, ok := request.Params.Arguments["time_range"].(string); ok { + params.TimeRange = timeRange + } + + if safeSearchFloat, ok := request.Params.Arguments["safe_search"].(float64); ok { + params.SafeSearch = int(safeSearchFloat) + } + + result, err := searxngClient.Search(params) + if err != nil { + return nil, fmt.Errorf("search error: %w", err) + } + + response := map[string]interface{}{ + "query": result.Query, + "number_of_results": result.NumberOfResults, + "results": result.Results, + } + + if len(result.Answers) > 0 { + response["answers"] = result.Answers + } + if len(result.Suggestions) > 0 { + response["suggestions"] = result.Suggestions + } + if len(result.Corrections) > 0 { + response["corrections"] = result.Corrections + } + + jsonResult, err := json.MarshalIndent(response, "", " ") + if err != nil { + return nil, fmt.Errorf("result serialization error: %w", err) + } + + return mcp.NewToolResultText(string(jsonResult)), nil +} + +func searxngEnginesInfoHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Ensure SearXNG is running + if err := ensureSearXNG(); err != nil { + return nil, fmt.Errorf("SearXNG not available: %w", err) + } + + config, err := searxngClient.GetEngines() + if err != nil { + return nil, fmt.Errorf("error getting engines information: %w", err) + } + + jsonResult, err := json.MarshalIndent(config, "", " ") + if err != nil { + return nil, fmt.Errorf("result serialization error: %w", err) + } + + return mcp.NewToolResultText(string(jsonResult)), nil +} + +func searxngImageSearchHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Ensure SearXNG is running + if err := ensureSearXNG(); err != nil { + return nil, fmt.Errorf("SearXNG not available: %w", err) + } + + query, ok := request.Params.Arguments["query"].(string) + if !ok { + return nil, errors.New("query must be a string") + } + + params := SearchParams{ + Query: query, + Categories: []string{"images"}, + Engines: []string{"bing images"}, + Language: "en", + } + + if engines, ok := request.Params.Arguments["engines"].(string); ok && engines != "" { + params.Engines = strings.Split(engines, ",") + for i := range params.Engines { + params.Engines[i] = strings.TrimSpace(params.Engines[i]) + } + } + + if pageFloat, ok := request.Params.Arguments["page"].(float64); ok { + params.PageNo = int(pageFloat) + } + + result, err := searxngClient.Search(params) + if err != nil { + return nil, fmt.Errorf("image search error: %w", err) + } + + jsonResult, err := json.MarshalIndent(result, "", " ") + if err != nil { + return nil, fmt.Errorf("result serialization error: %w", err) + } + + return mcp.NewToolResultText(string(jsonResult)), nil +} + +func searxngNewsSearchHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Ensure SearXNG is running + if err := ensureSearXNG(); err != nil { + return nil, fmt.Errorf("SearXNG not available: %w", err) + } + + query, ok := request.Params.Arguments["query"].(string) + if !ok { + return nil, errors.New("query must be a string") + } + + params := SearchParams{ + Query: query, + Categories: []string{"news"}, + Engines: []string{"bing news"}, + Language: "en", + } + + if timeRange, ok := request.Params.Arguments["time_range"].(string); ok { + params.TimeRange = timeRange + } + + if language, ok := request.Params.Arguments["language"].(string); ok && language != "" { + params.Language = language + } + + if pageFloat, ok := request.Params.Arguments["page"].(float64); ok { + params.PageNo = int(pageFloat) + } + + result, err := searxngClient.Search(params) + if err != nil { + return nil, fmt.Errorf("news search error: %w", err) + } + + jsonResult, err := json.MarshalIndent(result, "", " ") + if err != nil { + return nil, fmt.Errorf("result serialization error: %w", err) + } + + return mcp.NewToolResultText(string(jsonResult)), nil +} + +func grepSearchHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + query, ok := request.Params.Arguments["query"].(string) + if !ok { + return nil, errors.New("query must be a string") + } + + params := GrepSearchParams{ + Query: query, + } + + if language, ok := request.Params.Arguments["language"].(string); ok && language != "" { + params.Language = language + } + + if repo, ok := request.Params.Arguments["repo"].(string); ok && repo != "" { + params.Repo = repo + } + + if path, ok := request.Params.Arguments["path"].(string); ok && path != "" { + params.Path = path + } + + if regex, ok := request.Params.Arguments["regex"].(bool); ok { + params.Regex = regex + } + + if caseSensitive, ok := request.Params.Arguments["caseSensitive"].(bool); ok { + params.CaseSensitive = caseSensitive + } + + if wholeWords, ok := request.Params.Arguments["wholeWords"].(bool); ok { + params.WholeWords = wholeWords + } + + result, err := grepAppClient.Search(params) + if err != nil { + return nil, fmt.Errorf("grep search error: %w", err) + } + + response := map[string]interface{}{ + "query": params.Query, + "total_hits": result.Hits.Total, + } + + var results []map[string]interface{} + for _, hit := range result.Hits.Hits { + r := map[string]interface{}{ + "repo": hit.Source.Repo, + "path": hit.Source.Path, + "filename": hit.Source.Filename, + "language": hit.Source.Language, + "url": hit.Source.URL, + } + + if len(hit.Source.Lines) > 0 { + var lines []map[string]interface{} + for _, line := range hit.Source.Lines { + lines = append(lines, map[string]interface{}{ + "content": line.Content, + "line_number": line.LineNumber, + }) + } + r["lines"] = lines + } + + results = append(results, r) + } + + response["results"] = results + + jsonResult, err := json.MarshalIndent(response, "", " ") + if err != nil { + return nil, fmt.Errorf("result serialization error: %w", err) + } + + return mcp.NewToolResultText(string(jsonResult)), nil +} + +func githubFileHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, ok := request.Params.Arguments["owner"].(string) + if !ok { + return nil, errors.New("owner is required") + } + + repo, ok := request.Params.Arguments["repo"].(string) + if !ok { + return nil, errors.New("repo is required") + } + + path, ok := request.Params.Arguments["path"].(string) + if !ok { + return nil, errors.New("path is required") + } + + ref := "" + if refVal, ok := request.Params.Arguments["ref"].(string); ok { + ref = refVal + } + + content, err := grepAppClient.GetFile(owner, repo, path, ref) + if err != nil { + return nil, fmt.Errorf("error fetching file: %w", err) + } + + response := map[string]interface{}{ + "owner": owner, + "repo": repo, + "path": path, + "ref": ref, + "content": content, + } + + jsonResult, err := json.MarshalIndent(response, "", " ") + if err != nil { + return nil, fmt.Errorf("result serialization error: %w", err) + } + + return mcp.NewToolResultText(string(jsonResult)), nil +} + +func githubBatchFilesHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + filesArg, ok := request.Params.Arguments["files"].([]interface{}) + if !ok { + return nil, errors.New("files must be an array") + } + + var files []GitHubFileParams + for _, f := range filesArg { + fileMap, ok := f.(map[string]interface{}) + if !ok { + continue + } + + file := GitHubFileParams{} + if owner, ok := fileMap["owner"].(string); ok { + file.Owner = owner + } + if repo, ok := fileMap["repo"].(string); ok { + file.Repo = repo + } + if path, ok := fileMap["path"].(string); ok { + file.Path = path + } + if ref, ok := fileMap["ref"].(string); ok { + file.Ref = ref + } + + if file.Owner != "" && file.Repo != "" && file.Path != "" { + files = append(files, file) + } + } + + if len(files) == 0 { + return nil, errors.New("no valid files provided") + } + + results, err := grepAppClient.GetFilesBatch(files) + if err != nil { + return nil, fmt.Errorf("error fetching files: %w", err) + } + + jsonResult, err := json.MarshalIndent(results, "", " ") + if err != nil { + return nil, fmt.Errorf("result serialization error: %w", err) + } + + return mcp.NewToolResultText(string(jsonResult)), nil +} + +// v2.0.0 Management Handlers + +func searchStatusHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + status := map[string]interface{}{ + "version": "2.0.0", + "timestamp": time.Now().Format(time.RFC3339), + } + + // Docker status + if dockerManager != nil { + dockerAvailable := dockerManager.IsDockerAvailable() + status["docker_available"] = dockerAvailable + + if dockerAvailable { + status["searxng_running"] = dockerManager.IsSearXNGRunning() + status["searxng_healthy"] = dockerManager.GetSearXNGHealth() + status["searxng_port"] = dockerManager.searxngPort + status["searxng_image"] = dockerManager.searxngImage + } + } + + // Test code search (lightweight check) + status["code_search_available"] = grepAppClient != nil + + jsonResult, err := json.MarshalIndent(status, "", " ") + if err != nil { + return nil, fmt.Errorf("result serialization error: %w", err) + } + + return mcp.NewToolResultText(string(jsonResult)), nil +} + +func searxngStopHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if dockerManager == nil { + return nil, errors.New("docker manager not initialized") + } + + if !dockerManager.IsDockerAvailable() { + return nil, errors.New("Docker is not available") + } + + if !dockerManager.IsSearXNGRunning() { + return mcp.NewToolResultText("SearXNG is not currently running"), nil + } + + if err := dockerManager.StopSearXNG(); err != nil { + return nil, fmt.Errorf("failed to stop SearXNG: %w", err) + } + + return mcp.NewToolResultText("SearXNG stopped successfully"), nil +} + +func searxngStartHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if dockerManager == nil { + return nil, errors.New("docker manager not initialized") + } + + if !dockerManager.IsDockerAvailable() { + return nil, errors.New("Docker is not available. Please install Docker to use search.") + } + + if dockerManager.IsSearXNGRunning() && dockerManager.GetSearXNGHealth() { + return mcp.NewToolResultText("SearXNG is already running and healthy"), nil + } + + if err := dockerManager.EnsureSearXNG(); err != nil { + return nil, fmt.Errorf("failed to start SearXNG: %w", err) + } + + if err := dockerManager.WaitForSearXNGReady(30 * time.Second); err != nil { + return nil, fmt.Errorf("SearXNG started but failed to become ready: %w", err) + } + + return mcp.NewToolResultText("SearXNG started successfully and is ready for searches"), nil +} + +func searxngConfigHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + config := map[string]interface{}{ + "default_settings": GetSearXNGSettingsYAML(), + "container_name": SearXNGContainerName, + "default_image": SearXNGImage, + "default_port": "8080", + "privacy_note": "SearXNG is a privacy-respecting metasearch engine that queries multiple search engines without tracking", + "engine_documentation": "https://docs.searxng.org/", + } + + jsonResult, err := json.MarshalIndent(config, "", " ") + if err != nil { + return nil, fmt.Errorf("result serialization error: %w", err) + } + + return mcp.NewToolResultText(string(jsonResult)), nil +} diff --git a/searxng-config.go b/searxng-config.go new file mode 100644 index 0000000..2352173 --- /dev/null +++ b/searxng-config.go @@ -0,0 +1,148 @@ +package main + +// DefaultSearXNGSettings returns the default SearXNG configuration +// optimized for privacy and local use +const DefaultSearXNGSettings = ` +# SearXNG Settings for MCP Search Server +# Auto-generated default configuration + +general: + debug: false + instance_name: "MCP Local Search" + contact_url: false + enable_stats: false + +search: + safe_search: 0 + autocomplete: "duckduckgo" + autocomplete_min: 4 + default_lang: "" + favicon_resolver: "" + default_open_results: false + advanced_search: true + +outgoing: + request_timeout: 10.0 + max_request_timeout: 15.0 + pool_connections: 100 + pool_maxsize: 100 + enable_http2: true + retries: 1 + # Uncomment and configure proxy if needed: + # proxies: + # all://: + # - http://proxy.example.com:8080 + source_ips: + - 0.0.0.0 + +# Enabled engines - privacy respecting +engines: + - name: duckduckgo + engine: duckduckgo + shortcut: ddg + disabled: false + timeout: 5.0 + + - name: brave + engine: brave + shortcut: br + disabled: false + timeout: 5.0 + + - name: bing + engine: bing + shortcut: bi + disabled: false + timeout: 5.0 + + - name: wikipedia + engine: wikipedia + shortcut: wp + disabled: false + timeout: 5.0 + + - name: google + engine: google + shortcut: go + disabled: false + timeout: 5.0 + + # Image search engines + - name: bing images + engine: bing_images + shortcut: bii + disabled: false + timeout: 5.0 + + # News search engines + - name: bing news + engine: bing_news + shortcut: bin + disabled: false + timeout: 5.0 + + # IT/technical search + - name: github + engine: github + shortcut: gh + disabled: false + timeout: 5.0 + + - name: stackoverflow + engine: stackoverflow + shortcut: so + disabled: false + timeout: 5.0 + + - name: arch wiki + engine: archlinux + shortcut: aw + disabled: false + timeout: 5.0 + +# UI preferences +ui: + static_use_hash: true + templates_path: "" + default_theme: simple + default_locale: "" + query_in_title: false + infinite_scroll: false + center_alignment: false + results_on_new_tab: false + theme_args: + simple_style: auto + +# Result preferences +results: + formats: + - html + +# Server settings (for internal use) +server: + port: 8080 + bind_address: 127.0.0.1 + # Secret key is auto-generated at runtime, not hardcoded + secret_key: "" + base_url: false + image_proxy: false + http_protocol_version: "1.1" + method: "POST" + default_http_headers: + X-Content-Type-Options: nosniff + X-Download-Options: noopen + X-Robots-Tag: noindex, nofollow + Referrer-Policy: no-referrer + +# Brand settings +brand: + issue_url: https://github.com/searxng/searxng/issues + docs_url: https://docs.searxng.org/ + public_instances: https://searx.space/ + wiki_url: https://github.com/searxng/searxng/wiki +` + +// GetSearXNGSettingsYAML returns the default configuration +func GetSearXNGSettingsYAML() string { + return DefaultSearXNGSettings +} diff --git a/searxng.go b/searxng.go new file mode 100644 index 0000000..3b2f5d4 --- /dev/null +++ b/searxng.go @@ -0,0 +1,187 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type SearXNGClient struct { + BaseURL string + HTTPClient *http.Client +} + +func NewSearXNGClient(baseURL string) *SearXNGClient { + return &SearXNGClient{ + BaseURL: strings.TrimSuffix(baseURL, "/"), + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +type SearchResult struct { + Title string `json:"title"` + URL string `json:"url"` + Content string `json:"content"` + Engine string `json:"engine"` + Category string `json:"category"` + Score float64 `json:"score,omitempty"` + PublishedDate string `json:"publishedDate,omitempty"` +} + +type SearchResponse struct { + Query string `json:"query"` + NumberOfResults int `json:"number_of_results"` + Results []SearchResult `json:"results"` + Answers []string `json:"answers,omitempty"` + Corrections []string `json:"corrections,omitempty"` + Infoboxes []interface{} `json:"infoboxes,omitempty"` + Suggestions []string `json:"suggestions,omitempty"` +} + +type SearchParams struct { + Query string + Categories []string + Engines []string + Language string + PageNo int + TimeRange string + SafeSearch int +} + +func (c *SearXNGClient) Search(params SearchParams) (*SearchResponse, error) { + searchURL := fmt.Sprintf("%s/search", c.BaseURL) + + values := url.Values{} + values.Set("q", params.Query) + values.Set("format", "json") + + if len(params.Categories) > 0 { + values.Set("categories", strings.Join(params.Categories, ",")) + } + + if len(params.Engines) > 0 { + values.Set("engines", strings.Join(params.Engines, ",")) + } + + if params.Language != "" { + values.Set("language", params.Language) + } + + if params.PageNo > 0 { + values.Set("pageno", strconv.Itoa(params.PageNo)) + } + + if params.TimeRange != "" { + values.Set("time_range", params.TimeRange) + } + + if params.SafeSearch >= 0 && params.SafeSearch <= 2 { + values.Set("safesearch", strconv.Itoa(params.SafeSearch)) + } + + // Use POST request with form data (required by SearXNG) + req, err := http.NewRequest("POST", searchURL, strings.NewReader(values.Encode())) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + req.Header.Set("User-Agent", "MCP-SearXNG-Client/1.0") + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", c.BaseURL) + req.Header.Set("Referer", c.BaseURL+"/") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(body)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response: %w", err) + } + + var searchResponse SearchResponse + if err := json.Unmarshal(body, &searchResponse); err != nil { + return nil, fmt.Errorf("error parsing JSON: %w", err) + } + + return &searchResponse, nil +} + +func (c *SearXNGClient) GetEngines() (map[string]interface{}, error) { + enginesURL := fmt.Sprintf("%s/config", c.BaseURL) + + req, err := http.NewRequest("GET", enginesURL, nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + req.Header.Set("User-Agent", "MCP-SearXNG-Client/1.0") + req.Header.Set("Accept", "application/json") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP error %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response: %w", err) + } + + var config map[string]interface{} + if err := json.Unmarshal(body, &config); err != nil { + return nil, fmt.Errorf("error parsing JSON: %w", err) + } + + return config, nil +} + +// Ping checks if SearXNG is reachable and responding +func (c *SearXNGClient) Ping() error { + enginesURL := fmt.Sprintf("%s/config", c.BaseURL) + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get(enginesURL) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + return nil +} + +// WaitForReady polls SearXNG until it's ready or timeout is reached +func (c *SearXNGClient) WaitForReady(timeout time.Duration) error { + deadline := time.Now().Add(timeout) + checkInterval := 500 * time.Millisecond + + for time.Now().Before(deadline) { + if err := c.Ping(); err == nil { + return nil + } + time.Sleep(checkInterval) + } + + return fmt.Errorf("SearXNG failed to become ready within %v", timeout) +}