Initial commit: Search MCP Server

This commit is contained in:
2026-04-10 21:57:18 -07:00
commit ee65d5f7be
11 changed files with 1985 additions and 0 deletions
+55
View File
@@ -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/
+190
View File
@@ -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.
+37
View File
@@ -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
+246
View File
@@ -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
+197
View File
@@ -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)
}
+14
View File
@@ -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
)
+26
View File
@@ -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=
+206
View File
@@ -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
}
+679
View File
@@ -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
}
+148
View File
@@ -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
}
+187
View File
@@ -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)
}