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.
555 lines
24 KiB
Python
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()
|