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.
348 lines
10 KiB
Python
348 lines
10 KiB
Python
# 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.
|
|
"""Tests for x402 and MCP spec compliance."""
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
from typing import Any
|
|
|
|
from pry_mcp import (
|
|
MCP_PROTOCOL_VERSION,
|
|
PRY_PROMPTS,
|
|
PRY_RESOURCES,
|
|
PRY_TOOLS,
|
|
make_fallback_server,
|
|
register_all,
|
|
)
|
|
from pry_x402 import (
|
|
X402_PRICING,
|
|
FacilitatorRouter,
|
|
X402Asset,
|
|
X402Handler,
|
|
X402Network,
|
|
X402Scheme,
|
|
_decode_payment_signature,
|
|
_encode_payment_required_body,
|
|
build_payment_required,
|
|
set_facilitator_router,
|
|
)
|
|
|
|
|
|
class _AlwaysVerifyRouter(FacilitatorRouter):
|
|
"""Test router that always verifies payments without network calls."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__(facilitators=[])
|
|
|
|
async def verify(
|
|
self,
|
|
payment_id: str,
|
|
tx_hash: str,
|
|
network: str,
|
|
asset: str,
|
|
amount_usd: float,
|
|
) -> dict[str, Any]:
|
|
return {"verified": True, "reason": "", "settlement": None, "facilitator": "test"}
|
|
|
|
async def settle(
|
|
self,
|
|
payment_id: str,
|
|
tx_hash: str,
|
|
network: str,
|
|
asset: str,
|
|
amount_usd: float,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"success": True,
|
|
"transaction": tx_hash,
|
|
"network": network,
|
|
"payment_id": payment_id,
|
|
"facilitator": "test",
|
|
"settled_at": "2026-01-01T00:00:00+00:00",
|
|
}
|
|
|
|
|
|
# ── MCP spec compliance ──
|
|
|
|
|
|
def test_mcp_protocol_version() -> None:
|
|
"""MCP 2024-11-05 is the current spec version."""
|
|
assert MCP_PROTOCOL_VERSION == "2024-11-05"
|
|
|
|
|
|
def test_tools_have_input_schema() -> None:
|
|
"""Every tool must have a JSON schema for inputs."""
|
|
for tool in PRY_TOOLS:
|
|
assert "name" in tool
|
|
assert "description" in tool
|
|
assert "inputSchema" in tool
|
|
schema = tool["inputSchema"]
|
|
assert schema["type"] == "object"
|
|
assert "properties" in schema
|
|
assert "required" in schema
|
|
|
|
|
|
def test_tools_have_output_schema() -> None:
|
|
"""Best practice: tools should declare output schemas."""
|
|
tools_with_output = [t for t in PRY_TOOLS if "outputSchema" in t]
|
|
assert len(tools_with_output) >= 3
|
|
|
|
|
|
def test_resources_have_required_fields() -> None:
|
|
for r in PRY_RESOURCES:
|
|
assert "uri" in r
|
|
assert "name" in r
|
|
assert "description" in r
|
|
assert "mimeType" in r
|
|
|
|
|
|
def test_prompts_have_required_fields() -> None:
|
|
for p in PRY_PROMPTS:
|
|
assert "name" in p
|
|
assert "description" in p
|
|
assert "arguments" in p
|
|
for arg in p["arguments"]:
|
|
assert "name" in arg
|
|
assert "description" in arg
|
|
assert "required" in arg
|
|
|
|
|
|
def test_initialize_response() -> None:
|
|
"""Test that initialize returns proper protocol version and capabilities."""
|
|
server = make_fallback_server()
|
|
response = asyncio.run(
|
|
server.handle_request(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {"clientInfo": {"name": "test", "version": "1.0"}},
|
|
}
|
|
)
|
|
)
|
|
assert response["jsonrpc"] == "2.0"
|
|
assert response["id"] == 1
|
|
assert response["result"]["protocolVersion"] == MCP_PROTOCOL_VERSION
|
|
assert "tools" in response["result"]["capabilities"]
|
|
assert "resources" in response["result"]["capabilities"]
|
|
assert "prompts" in response["result"]["capabilities"]
|
|
|
|
|
|
def test_tools_list() -> None:
|
|
server = make_fallback_server()
|
|
register_all(server)
|
|
response = asyncio.run(
|
|
server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
|
|
)
|
|
assert "tools" in response["result"]
|
|
assert len(response["result"]["tools"]) >= 10
|
|
|
|
|
|
def test_tool_call_returns_content_array() -> None:
|
|
"""tools/call must return content array per MCP spec."""
|
|
server = make_fallback_server()
|
|
register_all(server)
|
|
response = asyncio.run(
|
|
server.handle_request(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "tools/call",
|
|
"params": {"name": "pry_x402_pricing", "arguments": {}},
|
|
}
|
|
)
|
|
)
|
|
assert "content" in response["result"]
|
|
assert isinstance(response["result"]["content"], list)
|
|
assert "isError" in response["result"]
|
|
|
|
|
|
def test_resources_list() -> None:
|
|
server = make_fallback_server()
|
|
register_all(server)
|
|
response = asyncio.run(
|
|
server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "resources/list"})
|
|
)
|
|
assert "resources" in response["result"]
|
|
assert len(response["result"]["resources"]) >= 3
|
|
|
|
|
|
def test_resource_read_returns_contents() -> None:
|
|
server = make_fallback_server()
|
|
register_all(server)
|
|
response = asyncio.run(
|
|
server.handle_request(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "resources/read",
|
|
"params": {"uri": "pry://stats"},
|
|
}
|
|
)
|
|
)
|
|
assert "contents" in response["result"]
|
|
|
|
|
|
def test_prompts_list() -> None:
|
|
server = make_fallback_server()
|
|
register_all(server)
|
|
response = asyncio.run(
|
|
server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "prompts/list"})
|
|
)
|
|
assert "prompts" in response["result"]
|
|
assert len(response["result"]["prompts"]) >= 5
|
|
|
|
|
|
def test_prompt_get_returns_messages() -> None:
|
|
server = make_fallback_server()
|
|
register_all(server)
|
|
response = asyncio.run(
|
|
server.handle_request(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "prompts/get",
|
|
"params": {"name": "research_company", "arguments": {"company_name": "Anthropic"}},
|
|
}
|
|
)
|
|
)
|
|
assert "messages" in response["result"]
|
|
assert len(response["result"]["messages"]) > 0
|
|
|
|
|
|
def test_ping() -> None:
|
|
server = make_fallback_server()
|
|
response = asyncio.run(server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "ping"}))
|
|
assert response["result"] == {}
|
|
|
|
|
|
def test_unknown_method_error() -> None:
|
|
server = make_fallback_server()
|
|
response = asyncio.run(
|
|
server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "unknown_method"})
|
|
)
|
|
assert "error" in response
|
|
assert response["error"]["code"] == -32601
|
|
|
|
|
|
# ── x402 spec compliance ──
|
|
|
|
|
|
def test_x402_atomic_units() -> None:
|
|
"""Prices must be in atomic units (6 decimals for USDC)."""
|
|
for op, info in X402_PRICING.items():
|
|
amount_atomic = int(info["price_usd"] * 1_000_000)
|
|
assert amount_atomic > 0, f"Operation {op} has zero price"
|
|
|
|
|
|
def test_x402_accepts_array_format() -> None:
|
|
"""PaymentRequired must have accepts array per x402 spec."""
|
|
body = build_payment_required("scrape", "https://pry.dev/api/scrape")
|
|
assert body["x402Version"] == 1
|
|
assert "accepts" in body
|
|
assert isinstance(body["accepts"], list)
|
|
assert len(body["accepts"]) >= 1
|
|
|
|
|
|
def test_x402_acceptance_has_required_fields() -> None:
|
|
"""Each acceptance option must have required fields per x402 spec."""
|
|
body = build_payment_required("scrape")
|
|
for acceptance in body["accepts"]:
|
|
assert "scheme" in acceptance
|
|
assert "network" in acceptance
|
|
assert "maxAmountRequired" in acceptance
|
|
assert "resource" in acceptance
|
|
assert "payToAddress" in acceptance
|
|
assert "asset" in acceptance
|
|
|
|
|
|
def test_x402_base64_encoding() -> None:
|
|
"""PAYMENT-REQUIRED header must be Base64-encoded JSON."""
|
|
body = build_payment_required("scrape")
|
|
encoded = _encode_payment_required_body(body)
|
|
decoded = base64.b64decode(encoded).decode()
|
|
parsed = json.loads(decoded)
|
|
assert parsed == body
|
|
|
|
|
|
def test_x402_signature_decoding() -> None:
|
|
"""PAYMENT-SIGNATURE header must be Base64-encoded JSON."""
|
|
test_payload = {"scheme": "exact", "network": "base", "payload": "0x1234"}
|
|
encoded = base64.b64encode(json.dumps(test_payload).encode()).decode()
|
|
decoded = _decode_payment_signature(encoded)
|
|
assert decoded == test_payload
|
|
|
|
|
|
def test_x402_handler_create_payment_required() -> None:
|
|
handler = X402Handler()
|
|
body, headers = handler.create_payment_required("scrape", "https://pry.dev/api/scrape")
|
|
assert body["x402Version"] == 1
|
|
assert "PAYMENT-REQUIRED" in headers
|
|
decoded = json.loads(base64.b64decode(headers["PAYMENT-REQUIRED"]).decode())
|
|
assert decoded == body
|
|
|
|
|
|
def test_x402_handler_require_payment() -> None:
|
|
handler = X402Handler()
|
|
response = handler.require_payment("crawl")
|
|
assert response["status_code"] == 402
|
|
assert "PAYMENT-REQUIRED" in response["headers"]
|
|
assert "accepts" in response["body"]
|
|
|
|
|
|
def test_x402_handler_verify_and_settle() -> None:
|
|
set_facilitator_router(_AlwaysVerifyRouter())
|
|
try:
|
|
handler = X402Handler()
|
|
payment_id = "test-pay-123"
|
|
tx_hash = "0xabc123"
|
|
verify_result = asyncio.run(handler.verify_payment(payment_id, tx_hash))
|
|
assert verify_result["verified"] is True
|
|
assert handler.is_paid(payment_id) is True
|
|
settlement = asyncio.run(handler.settle_payment(payment_id, tx_hash))
|
|
assert settlement["success"] is True
|
|
assert settlement["transaction"] == tx_hash
|
|
finally:
|
|
set_facilitator_router(FacilitatorRouter())
|
|
|
|
|
|
def test_x402_multi_chain_support() -> None:
|
|
"""x402 should support multiple chains."""
|
|
body = build_payment_required("scrape")
|
|
networks = {a["network"] for a in body["accepts"]}
|
|
assert "base" in networks
|
|
assert "solana" in networks
|
|
|
|
|
|
def test_x402_scheme_enum() -> None:
|
|
assert X402Scheme.EXACT.value == "exact"
|
|
assert X402Scheme.UPTO.value == "upto"
|
|
|
|
|
|
def test_x402_network_enum() -> None:
|
|
assert X402Network.BASE.value == "base"
|
|
assert X402Network.SOLANA.value == "solana"
|
|
assert X402Network.ETHEREUM.value == "ethereum"
|
|
|
|
|
|
def test_x402_asset_enum() -> None:
|
|
assert X402Asset.USDC.value == "USDC"
|
|
assert X402Asset.USDT.value == "USDT"
|
|
assert X402Asset.ETH.value == "ETH"
|
|
|
|
|
|
def test_x402_stats_includes_standards() -> None:
|
|
handler = X402Handler()
|
|
stats = handler.get_stats()
|
|
assert "wallet" in stats
|
|
assert "facilitator" in stats
|
|
assert "pricing" in stats
|
|
assert "supported_networks" in stats
|
|
assert "supported_assets" in stats
|
|
assert "supported_schemes" in stats
|
|
assert "base" in stats["supported_networks"]
|
|
assert "solana" in stats["supported_networks"]
|