From 49033480f40c091c7cec2ae5a2fab88d0da708a9 Mon Sep 17 00:00:00 2001 From: xiefuzheng713-alt <266900198+xiefuzheng713-alt@users.noreply.github.com> Date: Fri, 5 Jun 2026 02:59:51 +0800 Subject: [PATCH] Accept API field aliases in MCP lookups --- app/mcp.py | 10 ++++- app/mcp_tools.py | 22 ++++++++++- docs/api-examples.md | 6 ++- tests/test_api_mcp.py | 91 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 6 deletions(-) diff --git a/app/mcp.py b/app/mcp.py index 8541327f..2f48cf4f 100644 --- a/app/mcp.py +++ b/app/mcp.py @@ -42,8 +42,14 @@ "name": "submit_wallet_transfer", "description": "Submit a signed MRWK wallet transfer", }, - {"name": "get_ledger_entry", "description": "Get a ledger entry"}, - {"name": "get_proof", "description": "Get a public proof by hash"}, + { + "name": "get_ledger_entry", + "description": "Get a ledger entry by sequence or ledger_sequence", + }, + { + "name": "get_proof", + "description": "Get a public proof by hash or proof_hash", + }, { "name": "submit_work_proof", "description": ( diff --git a/app/mcp_tools.py b/app/mcp_tools.py index 0c3e4521..50aadb83 100644 --- a/app/mcp_tools.py +++ b/app/mcp_tools.py @@ -126,6 +126,24 @@ def optional_bool_arg(field: str, default: bool = False) -> bool: raise ValueError(f"{field} must be a boolean") return value + def proof_hash_arg() -> str: + has_hash = "hash" in args and args.get("hash") is not None + has_proof_hash = "proof_hash" in args and args.get("proof_hash") is not None + if has_hash and has_proof_hash: + raise ValueError("use hash or proof_hash, not both") + if has_proof_hash: + return proof_hash_from_path(str_arg("proof_hash")) + return proof_hash_from_path(str_arg("hash")) + + def ledger_sequence_arg() -> int: + has_sequence = "sequence" in args and args.get("sequence") is not None + has_ledger_sequence = "ledger_sequence" in args and args.get("ledger_sequence") is not None + if has_sequence and has_ledger_sequence: + raise ValueError("use sequence or ledger_sequence, not both") + if has_ledger_sequence: + return positive_int_arg("ledger_sequence") + return positive_int_arg("sequence") + def bounty_by_issue_number(repo_selector: str | None) -> Bounty | None: issue_query = select(Bounty).where(Bounty.issue_number == positive_int_arg("issue_number")) if repo_selector is not None: @@ -244,12 +262,12 @@ def selected_bounty(internal_id_field: str) -> Bounty | None: ) return json.dumps(wallet_transfer_to_dict(transfer)) if name == "get_ledger_entry": - entry = ledger_entry_to_dict(session, positive_int_arg("sequence")) + entry = ledger_entry_to_dict(session, ledger_sequence_arg()) if entry is None: return "ledger entry not found" return json.dumps(entry) if name == "get_proof": - proof = session.get(Proof, proof_hash_from_path(str_arg("hash"))) + proof = session.get(Proof, proof_hash_arg()) if proof is None: return "proof not found" try: diff --git a/docs/api-examples.md b/docs/api-examples.md index f19fdc54..6229d60e 100644 --- a/docs/api-examples.md +++ b/docs/api-examples.md @@ -719,7 +719,8 @@ curl -s -X POST "$MCP_HOST/mcp" \ ``` Call `get_proof` with the proof hash returned by `/api/v1/ledger`, -`/api/v1/activity`, or `get_ledger_entry`: +`/api/v1/activity`, or `get_ledger_entry`. When an API response names the field +`proof_hash`, agents may pass either `hash` or `proof_hash`: ```bash curl -s -X POST "$MCP_HOST/mcp" \ @@ -785,7 +786,8 @@ created from a GitHub bounty claim. Call `get_ledger_entry` with the immutable ledger sequence returned by `/api/v1/ledger`, `/api/v1/activity`, `get_bounty` award rows, or `get_proof`. -The MCP response wraps the same ledger-entry shape as +When an API response names the field `ledger_sequence`, agents may pass either +`sequence` or `ledger_sequence`. The MCP response wraps the same ledger-entry shape as `/api/v1/ledger/` in JSON text and `structuredContent`: ```bash diff --git a/tests/test_api_mcp.py b/tests/test_api_mcp.py index 8a3326f5..1dce5358 100644 --- a/tests/test_api_mcp.py +++ b/tests/test_api_mcp.py @@ -268,6 +268,12 @@ def test_mcp_tools_list_and_call(sqlite_url: str) -> None: tool for tool in tools["result"]["tools"] if tool["name"] == "list_bounty_attempts" ) assert "active-attempt reservations" in attempt_tool["description"] + ledger_tool = next( + tool for tool in tools["result"]["tools"] if tool["name"] == "get_ledger_entry" + ) + assert "ledger_sequence" in ledger_tool["description"] + proof_tool = next(tool for tool in tools["result"]["tools"] if tool["name"] == "get_proof") + assert "proof_hash" in proof_tool["description"] balance = client.post( "/mcp", @@ -1375,6 +1381,22 @@ def test_mcp_get_ledger_entry_includes_payment_proof_hash(sqlite_url: str) -> No assert payload["sequence"] == ledger_sequence assert payload["proof_hash"] == proof_hash + alias_result = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "get_ledger_entry", + "arguments": {"ledger_sequence": ledger_sequence}, + }, + }, + ).json() + alias_payload = json.loads(alias_result["result"]["content"][0]["text"]) + assert alias_result["result"]["structuredContent"] == alias_payload + assert alias_payload == payload + def test_mcp_get_ledger_entry_rejects_non_positive_sequence(sqlite_url: str) -> None: create_schema(sqlite_url) @@ -1401,6 +1423,34 @@ def test_mcp_get_ledger_entry_rejects_non_positive_sequence(sqlite_url: str) -> } +def test_mcp_get_ledger_entry_rejects_mixed_sequence_selectors(sqlite_url: str) -> None: + create_schema(sqlite_url) + with session_scope(sqlite_url) as session: + ensure_genesis(session) + + client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret")) + + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "get_ledger_entry", + "arguments": {"sequence": 1, "ledger_sequence": 1}, + }, + }, + ) + + assert response.status_code == 200 + assert response.json() == { + "jsonrpc": "2.0", + "id": 3, + "error": {"code": -32602, "message": "invalid tool arguments"}, + } + + def test_mcp_get_proof_returns_public_proof_details(sqlite_url: str) -> None: create_schema(sqlite_url) with session_scope(sqlite_url) as session: @@ -1449,6 +1499,19 @@ def test_mcp_get_proof_returns_public_proof_details(sqlite_url: str) -> None: assert payload["proof"]["submission_url"] == "https://github.com/ramimbo/mergework/pull/37" assert payload["proof"]["accepted_by"] == "maintainer" + alias_result = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": {"name": "get_proof", "arguments": {"proof_hash": proof.hash}}, + }, + ).json() + alias_payload = json.loads(alias_result["result"]["content"][0]["text"]) + assert alias_result["result"]["structuredContent"] == alias_payload + assert alias_payload == payload + def test_mcp_get_proof_reports_unknown_hash(sqlite_url: str) -> None: create_schema(sqlite_url) @@ -1568,6 +1631,34 @@ def test_mcp_get_proof_rejects_malformed_hash(sqlite_url: str) -> None: } +def test_mcp_get_proof_rejects_mixed_hash_selectors(sqlite_url: str) -> None: + create_schema(sqlite_url) + with session_scope(sqlite_url) as session: + ensure_genesis(session) + + client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret")) + + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "get_proof", + "arguments": {"hash": "0" * 64, "proof_hash": "1" * 64}, + }, + }, + ) + + assert response.status_code == 200 + assert response.json() == { + "jsonrpc": "2.0", + "id": 4, + "error": {"code": -32602, "message": "invalid tool arguments"}, + } + + def test_mcp_submit_work_proof_returns_bounty_specific_guidance(sqlite_url: str) -> None: create_schema(sqlite_url) with session_scope(sqlite_url) as session: