Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions app/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": (
Expand Down
22 changes: 20 additions & 2 deletions app/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions docs/api-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down Expand Up @@ -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/<sequence>` in JSON text and `structuredContent`:

```bash
Expand Down
91 changes: 91 additions & 0 deletions tests/test_api_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
Loading