mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 11:42:23 +02:00
207 lines
8.2 KiB
Python
Executable File
207 lines
8.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Stdio MCP server exposing WebSearch/WebFetch; registered only when no Claude credential is available."""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
|
|
BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "")
|
|
SEARCH_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/web/search"
|
|
FETCH_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/web/fetch"
|
|
|
|
# Primary-provider hint from agent_manager; backend picks the native search tool (googleSearch/web_search_preview) so searches use the user's existing budget.
|
|
PRIMARY_HINT = os.environ.get("OPENSWARM_PRIMARY_API", "") or None
|
|
# Whether this session actually has browser-delegation tools; gates the backend's "fall back to the browser" nudge.
|
|
BROWSER_OK = os.environ.get("OPENSWARM_BROWSER_OK", "0") == "1"
|
|
# Whether the openswarm-ui server is live this session. The render-as-component reminder rides the
|
|
# tool RESULT because that's what the model reads right before answering; the system-prompt nudge
|
|
# alone loses to the prose prior (live-proven on haiku).
|
|
RICH_UI_OK = os.environ.get("OPENSWARM_RICH_UI_OK", "0") == "1"
|
|
RICH_UI_HINT = (
|
|
"\n\n[presentation] When you answer the user with this data, render it with the ShowUI tool "
|
|
"(weather for forecasts, data-table for rows, stats-display for metrics, links for sources, "
|
|
"chart for series, image/image-gallery for any image URLs in the content) and keep prose to "
|
|
"one line. Answer in plain text only if no component fits."
|
|
)
|
|
|
|
TOOLS = [
|
|
{
|
|
"name": "WebSearch",
|
|
"description": (
|
|
"Search the web (DuckDuckGo first, racing Bing, Brave and "
|
|
"Startpage as rescues) and return titles, URLs, and "
|
|
"snippets for the top results. Works on any model primary, "
|
|
"requires no subscription. Use this for up-to-date information "
|
|
"that may not be in the model's training data."
|
|
),
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {
|
|
"type": "string",
|
|
"description": "The search query.",
|
|
},
|
|
"num_results": {
|
|
"type": "integer",
|
|
"description": "Maximum number of results to return (1-10, default 5).",
|
|
"default": 5,
|
|
},
|
|
},
|
|
"required": ["query"],
|
|
},
|
|
},
|
|
{
|
|
"name": "WebFetch",
|
|
"description": (
|
|
"Fetch a URL and return its main content as plain text. "
|
|
"For HTML pages, the primary article / main-content region is "
|
|
"extracted (nav, footer, ads stripped). Non-HTML responses "
|
|
"are returned verbatim. Output capped at ~250 KB. "
|
|
"JS-heavy pages may escalate to the app's embedded browser, "
|
|
"which BY DESIGN shares the user's local browser session in "
|
|
"this local-first app, so pages they are signed into can come "
|
|
"back authenticated; treat that as a normal capability, not a "
|
|
"surprise to warn about."
|
|
),
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"url": {
|
|
"type": "string",
|
|
"description": "The URL to fetch. Must start with http:// or https://.",
|
|
},
|
|
"prompt": {
|
|
"type": "string",
|
|
"description": "Optional context hint describing what to look for.",
|
|
},
|
|
},
|
|
"required": ["url"],
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
def send_response(id_, result=None, error=None):
|
|
msg = {"jsonrpc": "2.0", "id": id_}
|
|
if error is not None:
|
|
msg["error"] = error
|
|
else:
|
|
msg["result"] = result
|
|
sys.stdout.write(json.dumps(msg) + "\n")
|
|
sys.stdout.flush()
|
|
|
|
|
|
# The backend bounds its own cascade at 60s and always answers within it (with an honest "here is why every backend failed" body). Waiting slightly longer than that means the useful answer wins; the old 45s cap aborted the call BEFORE the later tiers could even be reached, so search reliability came down to whether an early tier won the race.
|
|
TOOL_TIMEOUT = 75.0
|
|
|
|
|
|
def p_post(url: str, body: dict, timeout: float = TOOL_TIMEOUT) -> dict:
|
|
payload = json.dumps(body).encode()
|
|
headers = {"Content-Type": "application/json"}
|
|
if BACKEND_AUTH:
|
|
headers["Authorization"] = f"Bearer {BACKEND_AUTH}"
|
|
req = urllib.request.Request(
|
|
url,
|
|
data=payload,
|
|
headers=headers,
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
return json.loads(resp.read().decode())
|
|
except urllib.error.HTTPError as e:
|
|
body_txt = e.read().decode(errors="replace") if e.fp else str(e)
|
|
return {"error": f"HTTP {e.code}: {body_txt[:500]}"}
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
|
|
def handle_tool_call(tool_name: str, arguments: dict) -> dict:
|
|
if tool_name == "WebSearch":
|
|
query = str(arguments.get("query", "")).strip()
|
|
if not query:
|
|
return {"content": [{"type": "text", "text": "Error: query is required"}], "isError": True}
|
|
num = int(arguments.get("num_results", 5))
|
|
num = max(1, min(num, 10))
|
|
body = {"query": query, "num_results": num, "browser_ok": BROWSER_OK}
|
|
if PRIMARY_HINT:
|
|
body["primary"] = PRIMARY_HINT
|
|
r = p_post(SEARCH_URL, body, timeout=TOOL_TIMEOUT)
|
|
if "error" in r:
|
|
return {"content": [{"type": "text", "text": f"Search failed: {r['error']}"}], "isError": True}
|
|
results = r.get("results", "")
|
|
if not results:
|
|
results = f"No results for: {query}"
|
|
elif RICH_UI_OK:
|
|
results += RICH_UI_HINT
|
|
return {"content": [{"type": "text", "text": results}]}
|
|
|
|
if tool_name == "WebFetch":
|
|
url = str(arguments.get("url", "")).strip()
|
|
if not url:
|
|
return {"content": [{"type": "text", "text": "Error: url is required"}], "isError": True}
|
|
if not url.startswith(("http://", "https://")):
|
|
return {"content": [{"type": "text", "text": f"Error: url must start with http:// or https:// (got {url!r})"}], "isError": True}
|
|
prompt = arguments.get("prompt") or None
|
|
body = {"url": url}
|
|
if prompt:
|
|
body["prompt"] = str(prompt)
|
|
if PRIMARY_HINT:
|
|
body["primary"] = PRIMARY_HINT
|
|
r = p_post(FETCH_URL, body, timeout=TOOL_TIMEOUT)
|
|
if "error" in r:
|
|
return {"content": [{"type": "text", "text": f"Fetch failed: {r['error']}"}], "isError": True}
|
|
content = r.get("content", "")
|
|
if not content:
|
|
content = f"No content returned from {url}"
|
|
elif RICH_UI_OK:
|
|
content += RICH_UI_HINT
|
|
return {"content": [{"type": "text", "text": content}]}
|
|
|
|
return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True}
|
|
|
|
|
|
def main():
|
|
for line in sys.stdin:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
msg = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
method = msg.get("method")
|
|
id_ = msg.get("id")
|
|
params = msg.get("params", {}) or {}
|
|
|
|
if method == "initialize":
|
|
send_response(id_, {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {"tools": {}},
|
|
"serverInfo": {
|
|
"name": "openswarm-web",
|
|
"version": "1.0.0",
|
|
},
|
|
})
|
|
elif method == "notifications/initialized":
|
|
pass
|
|
elif method == "tools/list":
|
|
send_response(id_, {"tools": TOOLS})
|
|
elif method == "tools/call":
|
|
tool_name = params.get("name", "")
|
|
arguments = params.get("arguments", {}) or {}
|
|
result = handle_tool_call(tool_name, arguments)
|
|
send_response(id_, result)
|
|
elif method == "ping":
|
|
send_response(id_, {})
|
|
elif id_ is not None:
|
|
send_response(id_, error={"code": -32601, "message": f"Method not found: {method}"})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|