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:
connor committed 2026-11-02 11:16:08 -05:00
1 parent b4ced1c649
commit b4bc5e1031
5 files changed
+159

No files matched your search

+9
View File
@@ -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",
+128
View File
@@ -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()
+1
View File
@@ -3,6 +3,7 @@
"enabled_providers": [
"litellm"
],
"lsp": true,
"agent": {
"plan": {
"disable": true
+8
View File
@@ -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",
+13
View File
@@ -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 "$@"
}