opencode: SearXNG search MCP, LSP tool, Gitea MCP in oc-local
searxng-mcp.py is a stdlib-only stdio MCP server with one web_search tool against search.rcjohnstone.com. It is in both profiles and asks first on oc-work. lsp: true in opencode.json, and the launcher sets OPENCODE_EXPERIMENTAL_LSP_TOOL and OPENCODE_DISABLE_LSP_DOWNLOAD. The launcher adds gitea-mcp to oc-local through OPENCODE_CONFIG_CONTENT when the binary and GITEA_MCP_TOKEN exist, limited to 15 tools. gitea_issue_write asks.
This commit is contained in:
1 parent
b4ced1c649
commit
b4bc5e1031
5 files changed
+159
No files matched your search
@@ -277,9 +277,18 @@
|
||||
],
|
||||
"model": "litellm/qwen3.8-flash-next-iq3s-med",
|
||||
"small_model": "litellm/qwen3.8-flash-next-iq3s-med",
|
||||
"mcp": {
|
||||
"searxng": {
|
||||
"type": "local",
|
||||
"command": ["python3", "{env:HOME}/.dotfiles/common/opencode/mcp/searxng-mcp.py"],
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"edit": "allow",
|
||||
"webfetch": "allow",
|
||||
"searxng_*": "allow",
|
||||
"gitea_issue_write": "ask",
|
||||
"external_directory": "ask",
|
||||
"read": {
|
||||
"*": "allow",
|
||||
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MCP server (stdio) exposing one tool, web_search, backed by a SearXNG instance.
|
||||
|
||||
Standard library only. Set SEARXNG_URL to override the instance.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
SEARXNG_URL = os.environ.get("SEARXNG_URL", "https://search.rcjohnstone.com").rstrip("/")
|
||||
MAX_RESULTS = 10
|
||||
TIMEOUT_S = 20
|
||||
KNOWN_VERSIONS = ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"]
|
||||
|
||||
TOOL = {
|
||||
"name": "web_search",
|
||||
"description": (
|
||||
"Search the web through Connor's self-hosted SearXNG. Returns up to "
|
||||
f"{MAX_RESULTS} results with title, URL and snippet. Use webfetch on a "
|
||||
"result URL to read the page."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search terms."},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"time_range": {
|
||||
"type": "string",
|
||||
"enum": ["day", "month", "year"],
|
||||
"description": "Only results from this period.",
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": ["general", "it", "news", "science"],
|
||||
"default": "general",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def search(args):
|
||||
query = str(args.get("query", "")).strip()
|
||||
if not query:
|
||||
raise ValueError("query is empty")
|
||||
params = {
|
||||
"q": query,
|
||||
"format": "json",
|
||||
"pageno": int(args.get("page", 1)),
|
||||
"categories": args.get("category", "general"),
|
||||
}
|
||||
if args.get("time_range"):
|
||||
params["time_range"] = args["time_range"]
|
||||
url = f"{SEARXNG_URL}/search?{urllib.parse.urlencode(params)}"
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT_S) as resp:
|
||||
data = json.load(resp)
|
||||
|
||||
lines = []
|
||||
for answer in data.get("answers", [])[:2]:
|
||||
text = answer.get("answer") if isinstance(answer, dict) else answer
|
||||
if text:
|
||||
lines.append(f"Answer: {text}\n")
|
||||
for i, r in enumerate(data.get("results", [])[:MAX_RESULTS], 1):
|
||||
snippet = " ".join((r.get("content") or "").split())
|
||||
lines.append(f"{i}. {r.get('title', '').strip()}\n {r.get('url', '')}\n {snippet}")
|
||||
if not lines:
|
||||
lines.append("No results.")
|
||||
if data.get("suggestions"):
|
||||
lines.append("Suggestions: " + ", ".join(data["suggestions"][:5]))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def handle(msg):
|
||||
method = msg.get("method")
|
||||
if method == "initialize":
|
||||
requested = msg.get("params", {}).get("protocolVersion")
|
||||
version = requested if requested in KNOWN_VERSIONS else KNOWN_VERSIONS[-2]
|
||||
return {
|
||||
"protocolVersion": version,
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "searxng", "version": "1.0.0"},
|
||||
}
|
||||
if method == "ping":
|
||||
return {}
|
||||
if method == "tools/list":
|
||||
return {"tools": [TOOL]}
|
||||
if method == "tools/call":
|
||||
params = msg.get("params", {})
|
||||
if params.get("name") != TOOL["name"]:
|
||||
raise LookupError(f"unknown tool: {params.get('name')}")
|
||||
try:
|
||||
text, is_error = search(params.get("arguments") or {}), False
|
||||
except (urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError) as e:
|
||||
text, is_error = f"Search failed: {e}", True
|
||||
return {"content": [{"type": "text", "text": text}], "isError": is_error}
|
||||
raise LookupError(f"method not found: {method}")
|
||||
|
||||
|
||||
def main():
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if "id" not in msg:
|
||||
continue
|
||||
reply = {"jsonrpc": "2.0", "id": msg["id"]}
|
||||
try:
|
||||
reply["result"] = handle(msg)
|
||||
except LookupError as e:
|
||||
reply["error"] = {"code": -32601, "message": str(e)}
|
||||
except Exception as e:
|
||||
reply["error"] = {"code": -32603, "message": str(e)}
|
||||
sys.stdout.write(json.dumps(reply) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -3,6 +3,7 @@
|
||||
"enabled_providers": [
|
||||
"litellm"
|
||||
],
|
||||
"lsp": true,
|
||||
"agent": {
|
||||
"plan": {
|
||||
"disable": true
|
||||
|
||||
@@ -124,9 +124,17 @@
|
||||
"instructions": [
|
||||
"~/.dotfiles/common/opencode/instructions/work.md"
|
||||
],
|
||||
"mcp": {
|
||||
"searxng": {
|
||||
"type": "local",
|
||||
"command": ["python3", "{env:HOME}/.dotfiles/common/opencode/mcp/searxng-mcp.py"],
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"edit": "ask",
|
||||
"webfetch": "ask",
|
||||
"searxng_*": "ask",
|
||||
"external_directory": "ask",
|
||||
"read": {
|
||||
"*": "allow",
|
||||
|
||||
@@ -84,11 +84,24 @@ _oc_launch() {
|
||||
print -u2 "${colour}┌─ opencode profile: ${(U)profile} ─┐\e[0m"
|
||||
printf '\e]0;opencode [%s]\a' "$profile"
|
||||
|
||||
# Gitea MCP: oc-local only, and only where the binary and token exist.
|
||||
# --tools keeps 15 of its 61 tools; each one is sent with every request.
|
||||
local extra=""
|
||||
if [[ $profile == local ]] && (( $+commands[gitea-mcp] )) && [[ -n $GITEA_MCP_TOKEN ]]; then
|
||||
local tools=get_me,search_repos,list_my_repos,search_issues,list_issues,issue_read,issue_write,label_read,list_pull_requests,pull_request_read,get_file_contents,get_dir_contents,list_commits,get_commit,actions_run_read
|
||||
extra='{"mcp":{"gitea":{"type":"local","enabled":true,"command":["gitea-mcp","-t","stdio","--host","https://git.rcjohnstone.com","--tools","'$tools'"],"environment":{"GITEA_ACCESS_TOKEN":"{env:GITEA_MCP_TOKEN}"}}}}'
|
||||
fi
|
||||
|
||||
# Never set OPENCODE_EXPERIMENTAL=true for the LSP tool: that umbrella flag
|
||||
# also enables the built-in websearch, which sends queries to Exa.
|
||||
# OpenCode detects its install method with `npm list -g` and upgrades with
|
||||
# `npm install -g`; both use nvm's prefix unless pointed at this one.
|
||||
LITELLM_KEY="$key" \
|
||||
OPENCODE_PROFILE="$profile" \
|
||||
OPENCODE_CONFIG="$cfg" \
|
||||
OPENCODE_CONFIG_CONTENT="$extra" \
|
||||
OPENCODE_EXPERIMENTAL_LSP_TOOL=true \
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD=true \
|
||||
npm_config_prefix="${OPENCODE_BIN:h}" \
|
||||
command opencode "$@"
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user