diff --git a/src/threaddesk/services/mcp.py b/src/threaddesk/services/mcp.py index 7efa870..cebbc09 100644 --- a/src/threaddesk/services/mcp.py +++ b/src/threaddesk/services/mcp.py @@ -242,6 +242,29 @@ def _management_schema() -> dict[str, Any]: ] +# Explicit, source-owned capability metadata. Unannotated future tools remain +# unexposed by dynamic read proxies until their actual effects are reviewed. +_READ_ONLY_TOOLS = frozenset({ + "get_reentry", + "get_issued_handoff", + "thread_operation_status", + "list_threads", + "get_thread", + "current_thread", + "list_snapshots", + "list_gnom_jobs", + "check_gate", +}) +for _tool in TOOLS: + if _tool["name"] in _READ_ONLY_TOOLS: + _tool["annotations"] = { + "readOnlyHint": True, + "destructiveHint": False, + "openWorldHint": False, + } + + + def wrap_untrusted(label: str, text: str) -> str: body = (text or "").replace("", "") return f"\n{body}\n" diff --git a/tests/test_mcp_readonly_annotations.py b/tests/test_mcp_readonly_annotations.py new file mode 100644 index 0000000..d9de458 --- /dev/null +++ b/tests/test_mcp_readonly_annotations.py @@ -0,0 +1,24 @@ +"""MCP read-effect annotations are explicit, not inferred from tool names.""" + +from threaddesk.services.mcp import McpBridge, TOOLS + + +def test_tools_advertise_only_reviewed_read_only_calls(): + # Listing must preserve the original schemas, names and function availability. + listed = McpBridge(None).list_tools() + assert listed is TOOLS + by_name = {tool["name"]: tool for tool in listed} + for name in ( + "list_threads", "get_thread", "current_thread", "get_reentry", + "list_snapshots", "thread_operation_status", "check_gate", + ): + assert by_name[name]["annotations"]["readOnlyHint"] is True + assert by_name[name]["annotations"]["destructiveHint"] is False + + # A function that writes, changes current context, saves HTML, or dispatches + # another operation must not accidentally become a proxy read. + for name in ( + "thread_manage", "knowledge_manage", "add_note", "switch_thread", + "save_snapshot", "dashboard", "issue_handoff", "generate_prompt", + ): + assert by_name[name].get("annotations", {}).get("readOnlyHint") is not True