Skip to content
Open
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
128 changes: 101 additions & 27 deletions app/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,101 @@
"additionalProperties": False,
}

MCP_GET_BALANCE_OUTPUT_SCHEMA: dict[str, Any] = {
"type": "object",
"description": "Account balance payload returned in structuredContent.",
"properties": {
"account": {"type": "string"},
"balance_mrwk": {"type": "string"},
"balance_microunits": {"type": "integer"},
},
"required": ["account", "balance_mrwk", "balance_microunits"],
"additionalProperties": False,
}

MCP_WALLET_OUTPUT_SCHEMA: dict[str, Any] = {
"type": "object",
"description": "Registered MRWK wallet payload returned in structuredContent.",
"properties": {
"address": {
"type": "string",
"pattern": "^[mM][rR][wW][kK]1[0-9a-fA-F]{40}$",
},
"public_key_hex": {"type": "string", "pattern": "^[0-9a-fA-F]{64}$"},
"label": {"type": ["string", "null"]},
"github_login": {"type": ["string", "null"]},
"balance_mrwk": {"type": "string"},
"nonce": {"type": "integer", "minimum": 0},
"next_nonce": {"type": "integer", "minimum": 1},
"created_at": {"type": "string"},
},
"required": [
"address",
"public_key_hex",
"label",
"github_login",
"balance_mrwk",
"nonce",
"next_nonce",
"created_at",
],
"additionalProperties": True,
Comment on lines +134 to +160

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Close the wallet output schema.

register_wallet/get_wallet advertise a fixed field set here, and tests/test_api_mcp.py:2941-2960 already asserts those schema keys exactly match the serialized wallet payload. Leaving additionalProperties open means undeclared wallet fields can slip into structuredContent without breaking the published contract.

Suggested fix
 MCP_WALLET_OUTPUT_SCHEMA: dict[str, Any] = {
@@
-    "additionalProperties": True,
+    "additionalProperties": False,
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
MCP_WALLET_OUTPUT_SCHEMA: dict[str, Any] = {
"type": "object",
"description": "Registered MRWK wallet payload returned in structuredContent.",
"properties": {
"address": {
"type": "string",
"pattern": "^[mM][rR][wW][kK]1[0-9a-fA-F]{40}$",
},
"public_key_hex": {"type": "string", "pattern": "^[0-9a-fA-F]{64}$"},
"label": {"type": ["string", "null"]},
"github_login": {"type": ["string", "null"]},
"balance_mrwk": {"type": "string"},
"nonce": {"type": "integer", "minimum": 0},
"next_nonce": {"type": "integer", "minimum": 1},
"created_at": {"type": "string"},
},
"required": [
"address",
"public_key_hex",
"label",
"github_login",
"balance_mrwk",
"nonce",
"next_nonce",
"created_at",
],
"additionalProperties": True,
MCP_WALLET_OUTPUT_SCHEMA: dict[str, Any] = {
"type": "object",
"description": "Registered MRWK wallet payload returned in structuredContent.",
"properties": {
"address": {
"type": "string",
"pattern": "^[mM][rR][wW][kK]1[0-9a-fA-F]{40}$",
},
"public_key_hex": {"type": "string", "pattern": "^[0-9a-fA-F]{64}$"},
"label": {"type": ["string", "null"]},
"github_login": {"type": ["string", "null"]},
"balance_mrwk": {"type": "string"},
"nonce": {"type": "integer", "minimum": 0},
"next_nonce": {"type": "integer", "minimum": 1},
"created_at": {"type": "string"},
},
"required": [
"address",
"public_key_hex",
"label",
"github_login",
"balance_mrwk",
"nonce",
"next_nonce",
"created_at",
],
"additionalProperties": False,
}

}

MCP_GET_LEDGER_ENTRY_OUTPUT_SCHEMA: dict[str, Any] = {
"type": "object",
"description": "Public ledger entry payload returned in structuredContent.",
"properties": {
"sequence": {"type": "integer", "minimum": 1},
"type": {"type": "string"},
"from": {"type": "string"},
"to": {"type": "string"},
"amount_mrwk": {"type": "string"},
"reference": {"type": ["string", "null"]},
"previous_hash": {"type": "string"},
"entry_hash": {"type": "string"},
"proof_hash": {"type": ["string", "null"]},
"created_at": {"type": "string"},
},
"required": [
"sequence",
"type",
"from",
"to",
"amount_mrwk",
"reference",
"previous_hash",
"entry_hash",
"proof_hash",
"created_at",
],
"additionalProperties": False,
}

MCP_GET_PROOF_OUTPUT_SCHEMA: dict[str, Any] = {
"type": "object",
"description": "Public proof payload returned in structuredContent.",
"properties": {
"hash": {"type": "string", "pattern": "^[0-9a-fA-F]{64}$"},
"kind": {"type": "string"},
"ledger_sequence": {"type": ["integer", "null"], "minimum": 1},
"bounty_id": {"type": ["integer", "null"], "minimum": 1},
"submission_id": {"type": ["integer", "null"], "minimum": 1},
"created_at": {"type": "string"},
"proof": {"type": "object", "additionalProperties": True},
},
"required": [
"hash",
"kind",
"ledger_sequence",
"bounty_id",
"submission_id",
"created_at",
"proof",
],
"additionalProperties": False,
}

MCP_TOOLS: list[dict[str, Any]] = [
{
"name": "list_bounties",
Expand Down Expand Up @@ -292,6 +387,7 @@
"required": ["account"],
"additionalProperties": False,
},
"outputSchema": MCP_GET_BALANCE_OUTPUT_SCHEMA,
},
{
"name": "register_wallet",
Expand All @@ -312,33 +408,7 @@
"required": ["public_key_hex"],
"additionalProperties": False,
},
"outputSchema": {
"type": "object",
"properties": {
"address": {
"type": "string",
"pattern": "^[mM][rR][wW][kK]1[0-9a-fA-F]{40}$",
},
"public_key_hex": {"type": "string", "pattern": "^[0-9a-fA-F]{64}$"},
"label": {"type": ["string", "null"]},
"github_login": {"type": ["string", "null"]},
"balance_mrwk": {"type": "string"},
"nonce": {"type": "integer", "minimum": 0},
"next_nonce": {"type": "integer", "minimum": 1},
"created_at": {"type": "string"},
},
"required": [
"address",
"public_key_hex",
"label",
"github_login",
"balance_mrwk",
"nonce",
"next_nonce",
"created_at",
],
"additionalProperties": True,
},
"outputSchema": MCP_WALLET_OUTPUT_SCHEMA,
},
{
"name": "get_wallet",
Expand All @@ -355,6 +425,7 @@
"required": ["address"],
"additionalProperties": False,
},
"outputSchema": MCP_WALLET_OUTPUT_SCHEMA,
},
{
"name": "submit_wallet_transfer",
Expand All @@ -375,6 +446,7 @@
"required": ["sequence"],
"additionalProperties": False,
},
"outputSchema": MCP_GET_LEDGER_ENTRY_OUTPUT_SCHEMA,
},
{
"name": "get_proof",
Expand All @@ -393,6 +465,7 @@
"required": ["hash"],
"additionalProperties": False,
},
"outputSchema": MCP_GET_PROOF_OUTPUT_SCHEMA,
},
{
"name": "submit_work_proof",
Expand Down Expand Up @@ -514,6 +587,7 @@ def _jsonrpc_error(response_id: Any, code: int, message: str) -> dict[str, Any]:
# field-prefixed (e.g. `unknown tool`).
_KNOWN_FIELDLESS_MESSAGES: dict[str, str] = {
"unknown tool": "unknown tool",
"unknown argument": "unknown argument",
"matches multiple bounties": "matches multiple bounties",
"repo can only be used with issue_number": ("repo can only be used with issue_number"),
}
Expand Down
19 changes: 16 additions & 3 deletions app/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ def optional_bounty_search_query_arg() -> str | None:
return query

def output_format_arg() -> str:
value = args.get("format", "text")
if value is None:
if "format" not in args:
return "text"
value = args["format"]
Comment on lines 105 to +108

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Reject explicit null consistently across optional typed arguments.

This fixes {"format": null}, but the shared optional helpers still accept explicit null as “argument omitted” for other fields. For example, repo, q, limit, include_awards, and include_expired still pass with null even though their schemas only allow string/integer/boolean, so tools/list and tools/call remain out of sync for those parameters.

Suggested direction
 def optional_clean_str_arg(field: str) -> str | None:
-    value = args.get(field)
-    if value is None:
+    if field not in args:
         return None
+    value = args[field]
+    if value is None:
+        raise ValueError(f"{field} must be a string")
     if not isinstance(value, str):
         raise ValueError(f"{field} must be a string")
@@
 def list_limit_arg(default: int = 25) -> int:
-    if "limit" not in args or args.get("limit") is None:
+    if "limit" not in args:
         return default
     value = positive_int_arg("limit")
@@
 def optional_bool_arg(field: str, default: bool = False) -> bool:
-    value = args.get(field, default)
-    if value is None:
+    if field not in args:
         return default
+    value = args[field]
     if not isinstance(value, bool):
         raise ValueError(f"{field} must be a boolean")

if not isinstance(value, str):
raise ValueError("format must be a string")
if contains_control_character(value):
Expand Down Expand Up @@ -142,7 +142,7 @@ def optional_bool_arg(field: str, default: bool = False) -> bool:
def require_known_fields(*allowed_fields: str) -> None:
unknown_fields = set(args) - set(allowed_fields)
if unknown_fields:
raise ValueError(f"unknown argument: {sorted(unknown_fields)[0]}")
raise ValueError("unknown argument")

def bounty_by_issue_number(repo_selector: str | None) -> Bounty | None:
issue_query = select(Bounty).where(Bounty.issue_number == positive_int_arg("issue_number"))
Expand Down Expand Up @@ -245,6 +245,7 @@ def reject_unexpected_args(tool_name: str, allowed: set[str]) -> None:
)
return json.dumps(sorted_bounties[:limit])
if name == "get_bounty":
require_known_fields("id", "bounty_id", "issue_number", "repo", "include_awards")
bounty = selected_bounty("id", internal_id_aliases=("bounty_id",))
if bounty is None:
return "bounty not found"
Expand All @@ -253,6 +254,14 @@ def reject_unexpected_args(tool_name: str, allowed: set[str]) -> None:
bounty_data["awards"] = bounty_awards_to_dict(session, bounty.id)
return json.dumps(bounty_data)
if name == "list_bounty_attempts":
require_known_fields(
"id",
"bounty_id",
"issue_number",
"repo",
"include_expired",
"limit",
)
bounty = selected_bounty("bounty_id", internal_id_aliases=("id",))
if bounty is None:
return "bounty not found"
Expand All @@ -270,6 +279,7 @@ def reject_unexpected_args(tool_name: str, allowed: set[str]) -> None:
"attempts": attempt_listing["attempts"],
}
if name == "get_balance":
require_known_fields("account")
account = normalized_account(str_arg("account"))
balance_microunits = get_balance(session, account)
balance_mrwk = format_mrwk(balance_microunits)
Expand All @@ -290,6 +300,7 @@ def reject_unexpected_args(tool_name: str, allowed: set[str]) -> None:
)
return json.dumps(wallet_to_dict(session, wallet))
if name == "get_wallet":
require_known_fields("address")
wallet_row = session.get(Wallet, normalized_wallet_address(str_arg("address")))
if wallet_row is None:
return "wallet not found"
Expand Down Expand Up @@ -317,11 +328,13 @@ def reject_unexpected_args(tool_name: str, allowed: set[str]) -> None:
)
return json.dumps(wallet_transfer_to_dict(transfer))
if name == "get_ledger_entry":
require_known_fields("sequence")
entry = ledger_entry_to_dict(session, positive_int_arg("sequence"))
if entry is None:
return "ledger entry not found"
return json.dumps(entry)
if name == "get_proof":
require_known_fields("hash")
proof = session.get(Proof, proof_hash_from_path(str_arg("hash")))
if proof is None:
return "proof not found"
Expand Down
20 changes: 15 additions & 5 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,13 +309,16 @@ Tools:
- `list_bounties`
- `get_bounty`
- `list_bounty_attempts`
- `get_balance`
- `get_balance` (`tools/list` advertises the account input schema and balance
output schema)
- `register_wallet` (`tools/list` advertises the public-key input schema and the
registered wallet output schema)
- `get_wallet`
- `get_wallet` (same wallet output schema as `register_wallet`)
- `submit_wallet_transfer`
- `get_ledger_entry`
- `get_proof`
- `get_ledger_entry` (`tools/list` advertises the sequence input schema and
ledger-entry output schema)
- `get_proof` (`tools/list` advertises the hash input schema and proof output
schema)
- `submit_work_proof` (`format: "json"` returns structuredContent; `tools/list`
advertises the selector and format schema)

Expand All @@ -330,7 +333,14 @@ back to text for human-readable not-found messages.

`get_balance` keeps the legacy balance text and also includes
`result.structuredContent.account`, `balance_mrwk`, and `balance_microunits` so
callers do not need to parse the text response.
callers do not need to parse the text response. `tools/list` advertises the
same fields in `get_balance.outputSchema.required`.

Reusable schema/runtime conformance coverage lives in
`tests/test_mcp_schema_conformance.py`. It compares advertised `tools/list`
output schemas with representative `tools/call` structuredContent payloads and
checks that tools with `additionalProperties: false` reject unknown arguments
with the safe `error.data.message: "unknown argument"` envelope.

### Argument-validation errors

Expand Down
7 changes: 7 additions & 0 deletions tests/test_api_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,18 +468,24 @@ def test_mcp_tools_list_and_call(sqlite_url: str) -> None:
assert balance_schema["additionalProperties"] is False
assert balance_schema["properties"]["account"]["minLength"] == 1
assert "github:<login>" in balance_schema["properties"]["account"]["description"]
balance_output_schema = balance_tool["outputSchema"]
assert balance_output_schema["required"] == ["account", "balance_mrwk", "balance_microunits"]
ledger_tool = next(
tool for tool in tools["result"]["tools"] if tool["name"] == "get_ledger_entry"
)
ledger_schema = ledger_tool["inputSchema"]
assert ledger_schema["required"] == ["sequence"]
assert ledger_schema["additionalProperties"] is False
assert ledger_schema["properties"]["sequence"]["minimum"] == 1
ledger_output_schema = ledger_tool["outputSchema"]
assert "sequence" in ledger_output_schema["required"]
proof_tool = next(tool for tool in tools["result"]["tools"] if tool["name"] == "get_proof")
proof_schema = proof_tool["inputSchema"]
assert proof_schema["required"] == ["hash"]
assert proof_schema["additionalProperties"] is False
assert proof_schema["properties"]["hash"]["pattern"] == "^[0-9a-fA-F]{64}$"
assert "proof" in proof_tool["outputSchema"]["required"]
assert "outputSchema" in wallet_tool

balance = client.post(
"/mcp",
Expand Down Expand Up @@ -2434,6 +2440,7 @@ def test_mcp_submit_work_proof_scopes_issue_number_by_repo(sqlite_url: str) -> N
({"bounty_id": 1, "issue_number": 1}, 25),
({"format": "xml"}, 26),
({"format": 1}, 27),
({"format": None}, 33),
({"format": "\x85json"}, 28),
({"repo": "ramimbo/mergework"}, 29),
({"bounty_id": 1, "repo": "ramimbo/mergework"}, 30),
Expand Down
Loading
Loading