diff --git a/app/main.py b/app/main.py index 034b80f0..758826bf 100644 --- a/app/main.py +++ b/app/main.py @@ -1687,6 +1687,27 @@ def optional_bool_arg(field: str, default: bool = False) -> bool: if optional_bool_arg("include_awards"): bounty_data["awards"] = bounty_awards_to_dict(session, bounty.id) return json.dumps(bounty_data) + if name == "list_bounty_attempts": + bounty_id = positive_int_arg("bounty_id") + bounty = session.get(Bounty, bounty_id) + if bounty is None: + return "bounty not found" + now = _utc_now() + attempt_query = select(BountyAttempt).where(BountyAttempt.bounty_id == bounty_id) + if not optional_bool_arg("include_expired"): + attempt_query = attempt_query.where(*_active_attempt_conditions(bounty_id, now)) + attempts = session.scalars( + attempt_query.order_by( + BountyAttempt.created_at.desc(), BountyAttempt.id.desc() + ).limit(list_limit_arg()) + ).all() + return { + "bounty_id": bounty_id, + "issue_number": bounty.issue_number, + "status": bounty.status, + "warnings": bounty_attempt_warnings(session, bounty, now), + "attempts": [bounty_attempt_to_dict(attempt, now) for attempt in attempts], + } if name == "get_balance": account = _normalized_account(str_arg("account")) return f"{account}: {format_mrwk(get_balance(session, account))} MRWK" diff --git a/app/mcp.py b/app/mcp.py index d9ca972b..92567a87 100644 --- a/app/mcp.py +++ b/app/mcp.py @@ -20,6 +20,10 @@ "name": "get_bounty", "description": "Get a bounty by id, optionally with accepted awards", }, + { + "name": "list_bounty_attempts", + "description": "List advisory active-attempt reservations for a bounty", + }, {"name": "get_balance", "description": "Get an account balance"}, { "name": "register_wallet", diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 4ce16574..9c3f3bc9 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -154,18 +154,28 @@ curl -s -X POST "$MCP_HOST/mcp" \ -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_bounties","arguments":{}}}' ``` +Inspect active attempt reservations for a bounty before opening overlapping +work: + +```bash +curl -s -X POST "$MCP_HOST/mcp" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"list_bounty_attempts","arguments":{"bounty_id":11}}}' +``` + Look up a public proof by hash: ```bash curl -s -X POST "$MCP_HOST/mcp" \ -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"get_proof","arguments":{"hash":""}}}' + -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"get_proof","arguments":{"hash":""}}}' ``` Tools: - `list_bounties` - `get_bounty` +- `list_bounty_attempts` - `get_balance` - `register_wallet` - `get_wallet` diff --git a/docs/api-examples.md b/docs/api-examples.md index 34f175ee..4f9e4db2 100644 --- a/docs/api-examples.md +++ b/docs/api-examples.md @@ -447,13 +447,22 @@ curl -s -X POST "$MCP_HOST/mcp" \ -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"get_bounty","arguments":{"id":11}}}' ``` +Call `list_bounty_attempts` with the same internal bounty `id` before opening a +PR. Omit `include_expired` to see only active attempts: + +```bash +curl -s -X POST "$MCP_HOST/mcp" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"list_bounty_attempts","arguments":{"bounty_id":11,"include_expired":false}}}' +``` + Call `get_proof` with the proof hash returned by `/api/v1/ledger`, `/api/v1/activity`, or `get_ledger_entry`: ```bash curl -s -X POST "$MCP_HOST/mcp" \ -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"get_proof","arguments":{"hash":""}}}' + -d '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"get_proof","arguments":{"hash":""}}}' ``` Call `submit_wallet_transfer` with the same signed transfer fields used by the @@ -463,7 +472,7 @@ send private keys to MergeWork: ```bash curl -s -X POST "$MCP_HOST/mcp" \ -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"submit_wallet_transfer","arguments":{"from_address":"","to_address":"","amount_mrwk":"1.5","nonce":3,"memo":"agent payout consolidation","signature_hex":"<128 lowercase hex chars>"}}}' + -d '{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"submit_wallet_transfer","arguments":{"from_address":"","to_address":"","amount_mrwk":"1.5","nonce":3,"memo":"agent payout consolidation","signature_hex":"<128 lowercase hex chars>"}}}' ``` Successful MCP transfer responses wrap a JSON-string transfer object in the @@ -473,7 +482,7 @@ ledger sequence, addresses, amount, nonce, memo, and timestamp: ```json { "jsonrpc": "2.0", - "id": 6, + "id": 7, "result": { "content": [ { @@ -491,7 +500,7 @@ block is a JSON string with proof metadata plus the stored public proof payload: ```json { "jsonrpc": "2.0", - "id": 5, + "id": 6, "result": { "content": [ { diff --git a/tests/test_api_mcp.py b/tests/test_api_mcp.py index 20ac0411..d616c5f3 100644 --- a/tests/test_api_mcp.py +++ b/tests/test_api_mcp.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from datetime import UTC, datetime, timedelta import pytest from fastapi.testclient import TestClient @@ -8,7 +9,7 @@ from app.db import create_schema, session_scope from app.ledger.service import close_bounty, create_bounty, ensure_genesis, pay_bounty from app.main import create_app -from app.models import Proof +from app.models import BountyAttempt, Proof def test_health_status_and_bounty_api(sqlite_url: str) -> None: @@ -431,6 +432,10 @@ def test_mcp_tools_list_and_call(sqlite_url: str) -> None: assert "bounty_id or issue_number" in submit_tool["description"] bounty_tool = next(tool for tool in tools["result"]["tools"] if tool["name"] == "get_bounty") assert "accepted awards" in bounty_tool["description"] + attempt_tool = next( + tool for tool in tools["result"]["tools"] if tool["name"] == "list_bounty_attempts" + ) + assert "active-attempt reservations" in attempt_tool["description"] balance = client.post( "/mcp", @@ -445,6 +450,136 @@ def test_mcp_tools_list_and_call(sqlite_url: str) -> None: assert "100000000" in balance["result"]["content"][0]["text"] +def test_mcp_list_bounty_attempts_reports_active_and_expired(sqlite_url: str) -> None: + create_schema(sqlite_url) + now = datetime.now(UTC) + with session_scope(sqlite_url) as session: + ensure_genesis(session) + bounty = create_bounty( + session, + repo="ramimbo/mergework", + issue_number=321, + issue_url="https://github.com/ramimbo/mergework/issues/321", + title="Attempt reservations", + reward_mrwk="250", + max_awards=2, + acceptance="Agents should inspect active attempts before opening work.", + ) + session.add_all( + [ + BountyAttempt( + bounty_id=bounty.id, + submitter_account="github:alice", + source_url="https://github.com/ramimbo/mergework/pull/501", + status="active", + expires_at=now + timedelta(hours=1), + created_at=now - timedelta(minutes=2), + updated_at=now - timedelta(minutes=2), + ), + BountyAttempt( + bounty_id=bounty.id, + submitter_account="github:bob", + source_url="https://github.com/ramimbo/mergework/pull/502", + status="active", + expires_at=now + timedelta(hours=2), + created_at=now - timedelta(minutes=1), + updated_at=now - timedelta(minutes=1), + ), + BountyAttempt( + bounty_id=bounty.id, + submitter_account="github:carol", + source_url="https://github.com/ramimbo/mergework/pull/503", + status="active", + expires_at=now - timedelta(minutes=1), + created_at=now - timedelta(minutes=3), + updated_at=now - timedelta(minutes=3), + ), + ] + ) + + client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret")) + + active_result = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 21, + "method": "tools/call", + "params": { + "name": "list_bounty_attempts", + "arguments": {"bounty_id": bounty.id}, + }, + }, + ).json()["result"] + + active_payload = active_result["structuredContent"] + assert active_payload["bounty_id"] == bounty.id + assert active_payload["issue_number"] == 321 + assert active_payload["warnings"] == ["bounty has 2 active attempts"] + assert [attempt["submitter_account"] for attempt in active_payload["attempts"]] == [ + "github:bob", + "github:alice", + ] + + all_result = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 22, + "method": "tools/call", + "params": { + "name": "list_bounty_attempts", + "arguments": {"bounty_id": bounty.id, "include_expired": True, "limit": 3}, + }, + }, + ).json()["result"] + + all_payload = all_result["structuredContent"] + assert [attempt["status"] for attempt in all_payload["attempts"]] == [ + "active", + "active", + "expired", + ] + + +def test_mcp_list_bounty_attempts_rejects_invalid_arguments(sqlite_url: str) -> None: + create_schema(sqlite_url) + with session_scope(sqlite_url) as session: + ensure_genesis(session) + bounty = create_bounty( + session, + repo="ramimbo/mergework", + issue_number=321, + issue_url="https://github.com/ramimbo/mergework/issues/321", + title="Attempt reservations", + reward_mrwk="250", + acceptance="Agents should inspect attempts before opening work.", + ) + bounty_id = bounty.id + + client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret")) + + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 23, + "method": "tools/call", + "params": { + "name": "list_bounty_attempts", + "arguments": {"bounty_id": bounty_id, "include_expired": "yes"}, + }, + }, + ) + + assert response.status_code == 200 + assert response.json() == { + "jsonrpc": "2.0", + "id": 23, + "error": {"code": -32602, "message": "invalid tool arguments"}, + } + + def test_mcp_list_bounties_filters_status_query_and_limit(sqlite_url: str) -> None: create_schema(sqlite_url) with session_scope(sqlite_url) as session: