From ee65d5f7bef741f1a9e8b4ef27377d6262f0a487 Mon Sep 17 00:00:00 2001 From: Karti Date: Fri, 10 Apr 2026 21:57:18 -0700 Subject: [PATCH] Initial commit: Search MCP Server --- .gitignore | 55 ++++ LICENSE | 190 +++++++++++++ Makefile | 37 +++ README.md | 246 +++++++++++++++++ docker.go | 197 ++++++++++++++ go.mod | 14 + go.sum | 26 ++ grep.go | 206 ++++++++++++++ main.go | 679 ++++++++++++++++++++++++++++++++++++++++++++++ searxng-config.go | 148 ++++++++++ searxng.go | 187 +++++++++++++ 11 files changed, 1985 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 docker.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 grep.go create mode 100644 main.go create mode 100644 searxng-config.go create mode 100644 searxng.go 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) +}