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
23 changes: 23 additions & 0 deletions src/threaddesk/services/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment on lines +251 to +256

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not mark revision-repairing JSON reads as read-only

For a JSON workspace containing an unanchored legacy thread, or one changed by an older writer, calls such as list_threads, get_thread, current_thread, get_reentry, list_snapshots, and check_gate with an ID reach JsonStore.get_thread(), which persists a revision anchor and can rewrite the thread while incrementing its revision (json_store.py:472-499). That contradicts the MCP definition that readOnlyHint: true means the tool does not modify its environment (official schema), allowing the new read proxy to cause persistent workspace changes; withhold the hint for these paths or perform the repair before exposing them as read-only.

Useful? React with 👍 / 👎.

})
for _tool in TOOLS:
if _tool["name"] in _READ_ONLY_TOOLS:
_tool["annotations"] = {
"readOnlyHint": True,
"destructiveHint": False,
"openWorldHint": False,
Comment on lines +260 to +263

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Negotiate a version that supports tool annotations

Every stdio initialization still advertises protocolVersion: "2024-11-05" in src/threaddesk/ui/mcp_stdio.py:33-40, but tool annotations were introduced only in MCP 2025-03-26, as confirmed by the MCP maintainers. Consequently, a version-aware client connected under the advertised contract may discard or reject these fields, so the dynamic proxy this change targets cannot reliably discover them; negotiate at least 2025-03-26 before emitting the annotations.

Useful? React with 👍 / 👎.

}



def wrap_untrusted(label: str, text: str) -> str:
body = (text or "").replace("</untrusted>", "</ untrusted>")
return f"<untrusted source=\"threaddesk.{label}\">\n{body}\n</untrusted>"
Expand Down
24 changes: 24 additions & 0 deletions tests/test_mcp_readonly_annotations.py
Original file line number Diff line number Diff line change
@@ -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
Loading