diff --git a/src/powercontext/server/mcp.py b/src/powercontext/server/mcp.py index e76b1f740..9b053db83 100644 --- a/src/powercontext/server/mcp.py +++ b/src/powercontext/server/mcp.py @@ -107,6 +107,11 @@ LIST_ARTIFACT_CANDIDATES.operation_id, GET_ARTIFACT_CANDIDATE.operation_id, }) +_MCP_REVIEW_WRITE_OPERATION_IDS = frozenset({ + APPROVE_ARTIFACT_CANDIDATE.operation_id, + REJECT_ARTIFACT_CANDIDATE.operation_id, + REVISE_ARTIFACT_CANDIDATE.operation_id, +}) def _select_mcp_type(route: HTTPRoute, _: MCPType) -> MCPType: @@ -143,6 +148,18 @@ def _annotate_mcp_component( idempotentHint=True, openWorldHint=False, ) + elif route.operation_id in _MCP_REVIEW_WRITE_OPERATION_IDS: + # Approval and rejection are terminal; a revision replaces the proposal a reviewer last + # inspected. MCP visibility is not an authorization boundary (RFC 0050), so these hints + # only let a host apply its own confirmation policy. An exact replay is rejected by the + # pending-head CAS before anything is written, so repeated identical calls have no + # additional effect and the tools are idempotent in the MCP sense. + component.annotations = ToolAnnotations( + readOnlyHint=False, + destructiveHint=True, + idempotentHint=True, + openWorldHint=False, + ) def create_mcp_server( diff --git a/tests/test_mcp.py b/tests/test_mcp.py index e58a40e40..af9d95fcd 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -345,6 +345,28 @@ async def inspect_annotations() -> dict[str, Any]: assert resolve.openWorldHint is False +def test_mcp_describes_review_write_side_effects_for_host_approval() -> None: + review_writes = { + "approve_artifact_candidate", + "reject_artifact_candidate", + "revise_artifact_candidate", + } + + async def inspect_annotations() -> dict[str, Any]: + async with Client(create_mcp_server(create_app())) as client: + return {tool.name: tool.annotations for tool in await client.list_tools() if tool.name in review_writes} + + annotations = run_async(inspect_annotations) + + assert set(annotations) == review_writes + for name, decision in annotations.items(): + assert decision is not None, f"{name} carries no annotations for an MCP host to prompt on" + assert decision.readOnlyHint is False + assert decision.destructiveHint is True + assert decision.idempotentHint is True + assert decision.openWorldHint is False + + def test_mcp_exact_entry_tools_use_nested_citations() -> None: async def exact_entry_tool_schemas() -> dict[str, dict[str, Any]]: server = create_mcp_server(create_app())