From 5b26f98a74bfccfe0f52b20e95112998fe4e53f2 Mon Sep 17 00:00:00 2001 From: russell001209-ai Date: Sat, 29 Aug 2026 23:59:26 +0800 Subject: [PATCH 1/2] fix(mcp): annotate Review write tools so hosts can prompt `_annotate_mcp_component` promises to "describe the side effects that an MCP host should use for approval decisions", but its branches only cover the read-only set, `handoff_current_work`, and `commit_handoff`. `approve_artifact_candidate`, `reject_artifact_candidate`, and `revise_artifact_candidate` fall through and are projected with `annotations: None`, so a host has nothing to key a confirmation prompt off for the three Review operations that change Candidate state. Project them with `destructiveHint=True` and add a regression test mirroring the existing handoff annotation test. The projected tool set is unchanged; annotations are advisory hints, not an authorization boundary (RFC 0050). Relates to #1391. --- src/powercontext/server/mcp.py | 15 +++++++++++++++ tests/test_mcp.py | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/powercontext/server/mcp.py b/src/powercontext/server/mcp.py index e76b1f740..2620ce01c 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,16 @@ 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. + component.annotations = ToolAnnotations( + readOnlyHint=False, + destructiveHint=True, + idempotentHint=False, + openWorldHint=False, + ) def create_mcp_server( diff --git a/tests/test_mcp.py b/tests/test_mcp.py index e58a40e40..7d849c762 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 False + 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()) From 6266c16e7e52e5741a51e93b1a8943ca460894de Mon Sep 17 00:00:00 2001 From: russell001209-ai Date: Sun, 30 Aug 2026 12:33:40 +0800 Subject: [PATCH 2/2] fix(mcp): mark Review write tools idempotent per MCP replay semantics MCP defines idempotentHint by additional environment effect, not by the retry returning the same successful response. An exact replay of approve/reject/revise is rejected by the pending-head CAS (ArtifactCandidateRepository.lock_pending) before any Candidate version, Artifact revision, index update, or status transition is written, so repeated identical calls have no additional effect. Requested in review. --- src/powercontext/server/mcp.py | 6 ++++-- tests/test_mcp.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/powercontext/server/mcp.py b/src/powercontext/server/mcp.py index 2620ce01c..9b053db83 100644 --- a/src/powercontext/server/mcp.py +++ b/src/powercontext/server/mcp.py @@ -151,11 +151,13 @@ def _annotate_mcp_component( 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. + # 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=False, + idempotentHint=True, openWorldHint=False, ) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 7d849c762..af9d95fcd 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -363,7 +363,7 @@ async def inspect_annotations() -> dict[str, Any]: 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 False + assert decision.idempotentHint is True assert decision.openWorldHint is False