Initial commit: Search MCP Server
This commit is contained in:
+55
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user