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