Skip to content
Merged
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
21 changes: 21 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions app/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 11 additions & 1 deletion docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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":"<proof_hash>"}}}'
-d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"get_proof","arguments":{"hash":"<proof_hash>"}}}'
```

Tools:

- `list_bounties`
- `get_bounty`
- `list_bounty_attempts`
- `get_balance`
- `register_wallet`
- `get_wallet`
Expand Down
17 changes: 13 additions & 4 deletions docs/api-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}}}'
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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":"<proof_hash>"}}}'
-d '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"get_proof","arguments":{"hash":"<proof_hash>"}}}'
```

Call `submit_wallet_transfer` with the same signed transfer fields used by the
Expand All @@ -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":"<sender_mrwk1_address>","to_address":"<receiver_mrwk1_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":"<sender_mrwk1_address>","to_address":"<receiver_mrwk1_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
Expand All @@ -473,7 +482,7 @@ ledger sequence, addresses, amount, nonce, memo, and timestamp:
```json
{
"jsonrpc": "2.0",
"id": 6,
"id": 7,
"result": {
"content": [
{
Expand All @@ -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": [
{
Expand Down
137 changes: 136 additions & 1 deletion tests/test_api_mcp.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
from __future__ import annotations

import json
from datetime import UTC, datetime, timedelta

import pytest
from fastapi.testclient import TestClient

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:
Expand Down Expand Up @@ -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",
Expand All @@ -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:
Expand Down
Loading