pryscraper/pry_mcp/fallback_server.py
cryptorugmunch 0ecc250349 refactor(pry): rename 19 root modules to pry_<name> to remove naming collision with routers/
The duplicate file names (advanced, agency, auth, compliance, costing, etc.) in
both root and routers/ made imports ambiguous. Renamed root modules to
pry_<name>.py so:

  from quality import X     ->  from pry_quality import X
  from routers.quality import router  (unchanged)

Also:
- Renamed x402.py to pry_x402/ package directory
- Fixed 21+ bare imports across api.py, deps.py, routers/, tests/, llm_providers/

Tests: 593 passed, 1 skipped (test_ready_returns_200 fails pre-existing
because Ollama is unreachable from test env, unrelated to this refactor).

Audit item 8.
2026-07-06 10:25:44 +02:00

555 lines
24 KiB
Python

"""Pry - MCP fallback server implementation.
Implements the MCP protocol when the official MCP SDK is not available.
Contains FallbackMCPServer and make_fallback_server().
Split from mcp_production.py.
"""
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry - https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
from __future__ import annotations
import asyncio
import json
import logging
import sys
from collections.abc import Callable, Coroutine
from typing import Any
import httpx
from pry_mcp.constants import DEFAULT_BASE_URL, MCP_PROTOCOL_VERSION, SERVER_NAME, SERVER_VERSION
from pry_mcp.notifications import (
_attach_mcp_logging,
set_active_mcp_server,
)
logger = logging.getLogger(__name__)
def make_fallback_server(
base_url: str = DEFAULT_BASE_URL,
tool_executor: Callable[[str, dict[str, Any]], Coroutine[Any, Any, dict[str, Any]]]
| None = None,
) -> Any:
"""Build a minimal stdio JSON-RPC server if official MCP SDK not available.
This implements the MCP 2024-11-05 protocol with all required methods."""
class FallbackMCPServer:
def __init__(
self,
base_url: str = DEFAULT_BASE_URL,
tool_executor: Callable[[str, dict[str, Any]], Coroutine[Any, Any, dict[str, Any]]]
| None = None,
) -> None:
self.tools: dict[str, dict[str, Any]] = {}
self.resources: dict[str, dict[str, Any]] = {}
self.prompts: dict[str, dict[str, Any]] = {}
self._initialized = False
self._client_info: dict[str, Any] = {}
self.base_url = base_url.rstrip("/")
self.tool_executor = tool_executor
# MCP logging state
self._mcp_log_enabled = False
self._mcp_log_level = logging.INFO
# Resource subscription state: uri -> set of subscriber session/client ids
self._resource_subscribers: dict[str, set[str]] = {}
def register_tool(self, name: str, definition: dict[str, Any]) -> None:
self.tools[name] = definition
def register_resource(self, uri: str, definition: dict[str, Any]) -> None:
self.resources[uri] = definition
def register_prompt(self, name: str, definition: dict[str, Any]) -> None:
self.prompts[name] = definition
def list_tools(self) -> dict[str, Any]:
return {"tools": list(self.tools.values())}
def subscribe_resource(self, uri: str, subscriber_id: str = "default") -> bool:
"""Subscribe a client to updates for a resource URI."""
if uri not in self.resources:
return False
self._resource_subscribers.setdefault(uri, set()).add(subscriber_id)
return True
def unsubscribe_resource(self, uri: str, subscriber_id: str = "default") -> bool:
"""Unsubscribe a client from updates for a resource URI."""
subscribers = self._resource_subscribers.get(uri)
if subscribers is None:
return False
subscribers.discard(subscriber_id)
if not subscribers:
del self._resource_subscribers[uri]
return True
def set_mcp_log_level(self, level_name: str) -> None:
"""Set the MCP log level (debug/info/warning/error/critical)."""
level_map = {
"debug": logging.DEBUG,
"info": logging.INFO,
"warning": logging.WARNING,
"error": logging.ERROR,
"critical": logging.CRITICAL,
}
self._mcp_log_level = level_map.get(level_name.lower(), logging.INFO)
self._mcp_log_enabled = True
def disable_mcp_log(self) -> None:
"""Disable MCP log forwarding."""
self._mcp_log_enabled = False
async def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
"""Execute a tool. Returns proper MCP-compliant response."""
if name not in self.tools:
return {"error": {"code": -32602, "message": f"Unknown tool: {name}"}}
# If a local executor is provided (e.g., when mounted inside api.py), use it.
if self.tool_executor is not None:
try:
result = await self.tool_executor(name, arguments)
return self._tool_result_to_content(result, name)
except Exception as e: # noqa: BLE001
logger.warning(
"mcp_tool_executor_failed", extra={"tool": name, "error": str(e)}
)
return {
"content": [{"type": "text", "text": f"Tool {name} failed: {e}"}],
"isError": True,
}
tool_map = {
"pry_scrape": ("POST", "/v1/scrape"),
"pry_crawl": ("POST", "/v1/crawl"),
"pry_extract": ("POST", "/v1/extract/css"),
"pry_template": ("POST", "/v1/templates/execute"),
"pry_search_templates": ("GET", "/v1/templates"),
"pry_monitor": ("POST", "/v1/monitor"),
"pry_compliance": ("POST", "/v1/compliance/check"),
"pry_enrich": ("POST", "/v1/enrich"),
"pry_parse_document": ("POST", "/v1/parse"),
"pry_screenshot": ("POST", "/v1/screenshot"),
"pry_x402_pricing": ("GET", "/v1/x402/pricing"),
"pry_referrals": ("GET", "/v1/referrals/catalog"),
}
if name not in tool_map:
return {
"content": [{"type": "text", "text": f"Tool {name} not yet wired to API"}],
"isError": False,
}
method, path = tool_map[name]
# Try to call the Pry API directly.
try:
async with httpx.AsyncClient(timeout=60) as client:
if method == "GET":
response = await client.get(f"{self.base_url}{path}", params=arguments)
else:
response = await client.post(f"{self.base_url}{path}", json=arguments)
response.raise_for_status()
data = response.json()
return self._tool_result_to_content(data, name)
except (httpx.HTTPError, httpx.RequestError) as e:
logger.warning("mcp_tool_api_call_failed", extra={"tool": name, "error": str(e)})
return {
"content": [
{
"type": "text",
"text": (
f"Would call {method} {path} with {arguments}. "
f"Live execution unavailable: {e}"
),
}
],
"isError": False,
}
def _tool_result_to_content(self, result: Any, tool_name: str) -> dict[str, Any]:
"""Normalize a tool result into MCP content array."""
if isinstance(result, dict):
if "content" in result and isinstance(result["content"], list):
return result
text = json.dumps(result, indent=2)
elif isinstance(result, list):
text = json.dumps(result, indent=2)
else:
text = str(result)
return {
"content": [{"type": "text", "text": text}],
"isError": False,
}
def read_resource(self, uri: str) -> dict[str, Any]:
"""Read a resource. Returns proper MCP-compliant response."""
if uri not in self.resources:
return {"error": {"code": -32602, "message": f"Resource not found: {uri}"}}
res = self.resources[uri]
text = self._resource_text(uri)
return {
"contents": [
{
"uri": uri,
"mimeType": res.get("mimeType", "text/plain"),
"text": text,
}
],
}
def _resource_text(self, uri: str) -> str:
"""Generate resource contents without external I/O."""
if uri == "pry://catalog":
try:
from template_engine import list_templates
templates = list_templates()
categories: dict[str, list[str]] = {}
for t in templates:
cat = t.get("category", "general")
categories.setdefault(cat, []).append(t.get("template_id", "unknown"))
return json.dumps(
{
"total_templates": len(templates),
"categories": categories,
"note": "Use pry_search_templates to query dynamically.",
},
indent=2,
)
except (json.JSONDecodeError, ValueError) as e:
return json.dumps({"error": f"Could not load template catalog: {e}"})
if uri == "pry://stats":
return json.dumps(
{
"server": SERVER_NAME,
"version": SERVER_VERSION,
"protocol_version": MCP_PROTOCOL_VERSION,
"tools_count": len(self.tools),
"resources_count": len(self.resources),
"prompts_count": len(self.prompts),
},
indent=2,
)
if uri == "pry://x402/pricing":
try:
from pry_x402 import X402Handler
return json.dumps(X402Handler().get_stats(), indent=2)
except (json.JSONDecodeError, ValueError) as e:
return json.dumps({"error": f"Could not load x402 pricing: {e}"})
if uri == "pry://referrals":
try:
from referrals import ReferralTracker
return json.dumps(ReferralTracker().get_catalog(), indent=2) # type: ignore[no-untyped-call]
except (json.JSONDecodeError, ValueError) as e:
return json.dumps({"error": f"Could not load referrals: {e}"})
return f"Resource {uri} (placeholder)"
def get_prompt(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
"""Get a prompt. Returns proper MCP-compliant response."""
if name not in self.prompts:
return {"error": {"code": -32602, "message": f"Prompt not found: {name}"}}
prompt = self.prompts[name]
messages = self._prompt_messages(name, arguments)
return {
"description": prompt.get("description", ""),
"messages": messages,
}
def _prompt_messages(self, name: str, arguments: dict[str, Any]) -> list[dict[str, Any]]:
"""Build real prompt messages for named prompts."""
if name == "research_company":
company = arguments.get("company_name", "the company")
website = arguments.get("website", "")
return [
{
"role": "user",
"content": {
"type": "text",
"text": (
f"Research {company}. "
f"{'Start by scraping ' + website + '. ' if website else ''}"
"Use pry_scrape and pry_enrich to gather data, then summarize: "
"what they do, target market, key products, competitive positioning, "
"and any risks or opportunities."
),
},
}
]
if name == "compare_products":
query = arguments.get("product_query", "the product")
sites = arguments.get("sites", [])
return [
{
"role": "user",
"content": {
"type": "text",
"text": (
f"Compare prices and details for '{query}' across {', '.join(sites)}. "
"Use pry_template with amazon-product, walmart-product, etc. "
"Return a side-by-side comparison table."
),
},
}
]
if name == "extract_contacts":
url = arguments.get("url", "")
max_pages = arguments.get("max_pages", 20)
return [
{
"role": "user",
"content": {
"type": "text",
"text": (
f"Extract all contact information from {url}. "
f"Crawl up to {max_pages} pages. "
"Use pry_crawl and regex to find emails, phones, and social profiles."
),
},
}
]
if name == "monitor_competitor":
url = arguments.get("url", "")
what = arguments.get("what_to_track", "changes")
webhook = arguments.get("webhook_url", "")
return [
{
"role": "user",
"content": {
"type": "text",
"text": (
f"Set up monitoring for {url}. Track {what}. "
f"{'Notify via webhook: ' + webhook + '. ' if webhook else ''}"
"Use pry_monitor."
),
},
}
]
if name == "analyze_article":
url = arguments.get("url", "")
focus = arguments.get("focus", "")
return [
{
"role": "user",
"content": {
"type": "text",
"text": (
f"Scrape and analyze this article: {url}. "
f"{'Focus on: ' + focus + '. ' if focus else ''}"
"Summarize key points, sentiment, entities, and claims."
),
},
}
]
if name == "extract_table":
url = arguments.get("url", "")
desc = arguments.get("table_description", "")
return [
{
"role": "user",
"content": {
"type": "text",
"text": (
f"Extract the data table from {url}. Table: {desc}. "
"Use pry_extract with CSS selectors or pry_scrape + structured output."
),
},
}
]
if name == "scrape_with_schema":
url = arguments.get("url", "")
fields = arguments.get("fields", "")
return [
{
"role": "user",
"content": {
"type": "text",
"text": (
f"Scrape {url} and extract these fields: {fields}. "
"Use pry_extract with a custom JSON schema."
),
},
}
]
arg_text = ", ".join(f"{k}={v}" for k, v in arguments.items())
return [
{
"role": "user",
"content": {
"type": "text",
"text": f"Execute the {self.prompts[name].get('title', name)} prompt with arguments: {arg_text}",
},
}
]
def complete(self, ref: dict[str, Any], argument: dict[str, Any]) -> dict[str, Any]:
"""Provide argument completion suggestions."""
ref_type = ref.get("type", "")
ref_name = ref.get("name", "") or ref.get("uri", "")
arg_name = argument.get("name", "")
arg_value = argument.get("value", "")
values: list[str] = []
if (
ref_type == "ref/prompt"
and ref_name == "extract_contacts"
and arg_name == "max_pages"
):
values = ["10", "20", "50", "100"]
elif ref_type == "ref/tool" and arg_name == "template_id":
try:
from template_engine import list_templates
templates = list_templates()
values = [
t["template_id"]
for t in templates
if arg_value.lower() in t.get("template_id", "").lower()
][:10]
except Exception: # noqa: BLE001
values = []
elif ref_type == "ref/tool" and arg_name == "url":
values = []
elif arg_name in ("format", "mime_type"):
values = ["json", "csv", "markdown", "html"]
return {"values": values}
async def handle_request(self, request: dict[str, Any]) -> dict[str, Any] | None:
"""Handle a JSON-RPC 2.0 request. Implements MCP 2024-11-05."""
method = request.get("method", "")
req_id = request.get("id", 0)
params = request.get("params", {})
try:
if method == "initialize":
self._initialized = True
self._client_info = params.get("clientInfo", {})
_attach_mcp_logging(self)
set_active_mcp_server(self)
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"protocolVersion": MCP_PROTOCOL_VERSION,
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
"capabilities": {
"tools": {"listChanged": True},
"resources": {"subscribe": True, "listChanged": True},
"prompts": {"listChanged": True},
"logging": {},
},
},
}
if method == "notifications/initialized":
return None
if method == "ping":
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
if method == "tools/list":
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {"tools": list(self.tools.values())},
}
if method == "tools/call":
tool_result = await self.call_tool(
params.get("name", ""), params.get("arguments", {})
)
if "error" in tool_result:
return {
"jsonrpc": "2.0",
"id": req_id,
"error": tool_result["error"],
}
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"content": tool_result.get("content", []),
"isError": tool_result.get("isError", False),
},
}
if method == "resources/list":
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {"resources": list(self.resources.values())},
}
if method == "resources/read":
read_result = self.read_resource(params.get("uri", ""))
if "error" in read_result:
return {"jsonrpc": "2.0", "id": req_id, "error": read_result["error"]}
return {"jsonrpc": "2.0", "id": req_id, "result": read_result}
if method == "resources/subscribe":
uri = params.get("uri", "")
ok = self.subscribe_resource(uri)
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {"subscribed": ok},
}
if method == "resources/unsubscribe":
uri = params.get("uri", "")
ok = self.unsubscribe_resource(uri)
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {"unsubscribed": ok},
}
if method == "prompts/list":
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {"prompts": list(self.prompts.values())},
}
if method == "prompts/get":
prompt_result = self.get_prompt(
params.get("name", ""), params.get("arguments", {})
)
if "error" in prompt_result:
return {"jsonrpc": "2.0", "id": req_id, "error": prompt_result["error"]}
return {"jsonrpc": "2.0", "id": req_id, "result": prompt_result}
if method == "completion/complete":
completion = self.complete(params.get("ref", {}), params.get("argument", {}))
return {"jsonrpc": "2.0", "id": req_id, "result": completion}
if method == "logging/setLevel":
level = params.get("level", "info")
self.set_mcp_log_level(level)
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
return {
"jsonrpc": "2.0",
"id": req_id,
"error": {"code": -32601, "message": f"Method not found: {method}"},
}
except Exception as e: # noqa: BLE001
return {
"jsonrpc": "2.0",
"id": req_id,
"error": {"code": -32603, "message": f"Internal error: {e}"},
}
async def run_stdio(self) -> None:
"""Run the server over stdio (JSON-RPC 2.0)."""
while True:
try:
line = await asyncio.get_event_loop().run_in_executor(None, sys.stdin.readline)
if not line:
break
line = line.strip()
if not line:
continue
request = json.loads(line)
response = await self.handle_request(request)
if response is not None:
sys.stdout.write(json.dumps(response) + "\n")
sys.stdout.flush()
except json.JSONDecodeError:
continue
except (EOFError, KeyboardInterrupt):
break
return FallbackMCPServer()