Make MCP writes retry-safe and coordination state visible to agents

- Send Idempotency-Key on node create/patch/update writes (including bulk,
  link, move, kanban and agent-node tools) and return the key used, so an
  ambiguous failure can be retried without duplicating the write.
- Record a non-secret marker when login renewal is rejected; nap_whoami
  reports auth_required ahead of gateway state, and local-index answers
  carry stale warnings instead of silently serving old memory.
- Add nap_kanban_claim_next: pick and claim in one call, skipping cards
  held by another operation, never replaying an ambiguous claim.
- Add nap_changes_since over /v1/projects/{id}/changes with a
  per-worktree cursor that only advances after a successful read.
- Report real links_count/backlinks_count from indexed edges.
- Accept optional tool_profiles in .napseer/project.json when
  NAPSEER_TOOL_PROFILES is unset.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
HumbleTurtle
2026-09-28 21:34:35 -03:00
co-authored by Claude Opus 5.5
parent c427e2432a
commit 7eaaffb63f
4 changed files with 1427 additions and 70 deletions
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -283,7 +283,7 @@ def test_node_get_rejects_path_reads(mod):
def test_tools_include_node_by_path_descriptor(mod):
tool_by_name = {item["name"]: item for item in mod.tools()}
assert len(tool_by_name) == 58
assert len(tool_by_name) == 60
assert len(tool_by_name) == len(mod.tools())
get_tool = next(item for item in mod.tools() if item["name"] == "nap_node_get")
+1 -1
View File
@@ -36,7 +36,7 @@ def test_default_surface_includes_knowledge_files_and_coordination(mod):
assert [tool["name"] for tool in mod.tools()] == [
"nap_apropos", "nap_man", "nap_doctor", "nap_whoami",
"nap_operation_list", "nap_operation", "nap_file_list", "nap_file_upload", "nap_file_download", "nap_file_archive",
"nap_discover", "nap_context", "nap_node_by_path", "nap_node_get",
"nap_changes_since", "nap_discover", "nap_context", "nap_node_by_path", "nap_node_get",
"nap_create_node", "nap_node_patch", "nap_bulk", "nap_batch", "nap_ln",
"nap_mv", "nap_rm",
]
+593
View File
@@ -0,0 +1,593 @@
"""Regressions for write idempotency, stale local reads, atomic claim-next,
indexed link counts, and locator-declared tool profiles."""
import importlib.util
import io
import json
import pathlib
import sys
import urllib.error
import uuid
import pytest
def load_module():
script_path = (
pathlib.Path(__file__).resolve().parents[1]
/ "resources"
/ "scripts"
/ "napseer_mcp_server.py"
)
sys.path.insert(0, str(script_path.parent))
spec = importlib.util.spec_from_file_location("napseer_mcp_reliability_test", script_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
@pytest.fixture
def mod(monkeypatch, tmp_path):
monkeypatch.delenv("NAPSEER_TOOL_PROFILES", raising=False)
module = load_module()
module.DEFAULT_PROJECT_ID = "project-1"
module.TOKEN = "synthetic-access"
module.REFRESH_TOKEN = "synthetic-refresh"
module.TOKEN_EXPIRES_AT = "2099-01-01T00:00:00Z"
module.REFRESH_EXPIRES_AT = None
module.AUTH = {**module.AUTH, "account_mode": "operator_account", "project_id": "project-1"}
module.refresh_public_auth_state = lambda: module.AUTH
module.project_memory_encryption_active = lambda _project_id: False
module.AUTH_DIR = tmp_path / "state"
module.INDEX_PATH = tmp_path / "state" / "index.sqlite"
module.INDEX_LOCK_PATH = tmp_path / "state" / "index.lock"
module.resolve_project_id = lambda _args: "project-1"
return module
class FakeResponse:
def __init__(self, payload):
self._body = json.dumps(payload).encode("utf-8")
def read(self, *_args):
return self._body
def __enter__(self):
return self
def __exit__(self, *_exc):
return False
def http_error(status, body):
return urllib.error.HTTPError(
"https://api.example.test/x", status, "error", {}, io.BytesIO(json.dumps(body).encode("utf-8"))
)
def stub_project_lock(mod):
mod.acquire_project_lock = lambda args=None, **kwargs: {"id": "lock-1", "lease_token": "lease-1"}
mod.cleanup_project_lock = lambda project_id, lock: None
# -- 1. Idempotency keys ----------------------------------------------------
def test_create_node_idempotency_key_survives_auth_retry_and_is_returned(mod):
stub_project_lock(mod)
seen = []
def fake_urlopen(request, timeout=None):
seen.append((request.get_method(), request.full_url, request.get_header("Idempotency-key"),
request.get_header("Authorization")))
if len(seen) == 1:
raise http_error(401, {"error": {"code": "unauthorized"}})
return FakeResponse({"id": "node-new", "project_id": "project-1", "full_path": "/notes/a",
"folder_path": "/notes", "name": "a", "type": "note", "content_text": "x"})
def fake_renew(stale_token=None):
mod.TOKEN = "renewed-access"
return {"status": "renewed"}
mod.api_urlopen = fake_urlopen
mod.renew_auth = fake_renew
result = mod.call_tool_impl("nap_create_node", {"folder_path": "/notes", "name": "a", "content_text": "x"})
assert len(seen) == 2
generated = seen[0][2]
assert generated and str(uuid.UUID(generated)) == generated
# One logical request: the internal auth replay reuses the same key.
assert seen[1][2] == generated
assert result["idempotency_key"] == generated
seen.clear()
mod.api_urlopen = lambda request, timeout=None: seen.append(request.get_header("Idempotency-key")) or FakeResponse(
{"id": "node-new", "project_id": "project-1", "full_path": "/notes/a"}
)
result = mod.call_tool_impl(
"nap_create_node", {"folder_path": "/notes", "name": "a", "content_text": "x", "idempotency_key": "caller-key-1"}
)
assert seen == ["caller-key-1"]
assert result["idempotency_key"] == "caller-key-1"
def test_node_patch_sends_header_not_body_and_bulk_items_get_keys(mod):
stub_project_lock(mod)
existing = {"id": "node-123", "project_id": "project-1", "full_path": "/notes/current",
"folder_path": "/notes", "name": "current", "type": "note", "content_text": "old"}
mod.get_node_by_id = lambda args: existing
mod.try_get_node_by_path = lambda path, allow_agent=False: None
mod.index_node = lambda node: None
writes = []
def fake_request(method, path, payload=None, **kwargs):
writes.append((method, path, payload, dict(kwargs.get("extra_headers") or {})))
if path.endswith("/patch"):
return {"ok": True, "data": {"node": existing, "revision": "r2", "read_fingerprint": "f2"}}
return {**existing, "id": "node-new" if method == "POST" else existing["id"]}
mod.request_json = fake_request
patched = mod.guarded_node_patch({
"node_id": "node-123",
"precondition": {"revision": "r1", "read_fingerprint": "f1"},
"merge_metadata": {"status": "active"},
"idempotency_key": "patch-key-1",
})
method, path, payload, headers = writes[-1]
assert (method, path) == ("PATCH", "/v1/projects/project-1/nodes/node-123/patch")
assert "idempotency_key" not in payload
assert headers["Idempotency-Key"] == "patch-key-1"
assert headers["X-Napseer-Lock-Id"] == "lock-1"
assert patched["idempotency_key"] == "patch-key-1"
generated = mod.guarded_node_patch({
"node_id": "node-123",
"precondition": {"revision": "r1", "read_fingerprint": "f1"},
"merge_metadata": {"status": "active"},
})
assert generated["idempotency_key"] == writes[-1][3]["Idempotency-Key"]
uuid.UUID(generated["idempotency_key"])
writes.clear()
bulk = mod.bulk_upsert_nodes({
"idempotency_key": "bulk-base",
"nodes": [
{"path": "/notes/new", "content_text": "new"},
{"node_id": "node-123", "content_text": "updated", "idempotency_key": "item-own"},
],
})
assert [(w[0], w[3]["Idempotency-Key"]) for w in writes] == [("POST", "bulk-base:0"), ("PATCH", "item-own")]
assert [item["idempotency_key"] for item in bulk["items"]] == ["bulk-base:0", "item-own"]
for name in ("nap_create_node", "nap_node_patch", "nap_bulk"):
assert mod.tool_contract_metadata(name)["idempotency"] == "same-idempotency-key"
tools = {tool["name"]: tool for tool in mod.raw_tools()}
assert "idempotency_key" in tools["nap_create_node"]["inputSchema"]["properties"]
assert "idempotency_key" in tools["nap_bulk"]["inputSchema"]["properties"]
assert "idempotency_key" in tools["nap_bulk"]["inputSchema"]["properties"]["nodes"]["items"]["properties"]
# -- 2. Stale local reads and whoami auth precedence -----------------------
def indexed(path, links=None):
folder, name = path.rsplit("/", 1)
return {
"id": f"id-{name}",
"project_id": "project-1",
"full_path": path,
"folder_path": folder,
"name": name,
"type": "note",
"tags": [],
"aliases": [],
"links": links or [],
"metadata": {"status": "active"},
"content_text": f"body of {name} gateway reliability",
"encryption_state": "legacy_plaintext",
"updated_at": "2026-08-22T00:00:00Z",
}
def build_complete_index(mod):
mod.index_node(indexed("/plans/alpha", links=[{"path": "/plans/beta", "relation": "references"}]))
mod.index_node(indexed("/plans/beta"))
mod.set_local_graph_index_complete("project-1", True)
def record_revoked_grant(mod):
def rejected(*_args, **_kwargs):
raise mod.SafeToolError("http_401", "rejected", status=401)
mod.request_form_json = rejected
with pytest.raises(mod.SafeToolError) as caught:
mod.renew_auth()
assert caught.value.code == "auth_required"
assert "expired or revoked" in caught.value.safe_message
def test_local_answers_warn_when_login_grant_is_revoked(mod):
build_complete_index(mod)
mod.request_json = lambda *a, **k: (_ for _ in ()).throw(AssertionError("no HTTP expected"))
healthy = mod.discover_memory({"folder_path": "/plans", "view": "paths"})
assert not healthy.get("warnings")
assert not (healthy.get("freshness") or {}).get("stale")
record_revoked_grant(mod)
state_text = (mod.AUTH_DIR / "auth-failure.json").read_text()
assert "synthetic-refresh" not in state_text and "synthetic-access" not in state_text
browsed = mod.discover_memory({"folder_path": "/plans", "view": "paths"})
assert [w["code"] for w in browsed["warnings"]] == ["auth_required"]
assert browsed["warnings"][0]["indexed_at"]
assert browsed["freshness"]["stale"] is True
assert browsed["freshness"]["graph_complete"] is True
context = mod.get_memory_context({"node_id": "id-alpha", "depth": 1})
codes = [w["code"] for w in context["warnings"] if isinstance(w, dict)]
assert codes == ["auth_required"]
assert context["freshness"]["stale"] is True
assert context["edge_count"] >= 1
status = mod.gateway_status()
assert status["status"] == "auth_required"
assert status["auth_problem"]["reason"] == "auth_required"
# A new login invalidates the recorded failure without clearing files.
mod.TOKEN = "fresh-access"
mod.REFRESH_TOKEN = "fresh-refresh"
assert mod.current_auth_problem() is None
assert mod.gateway_status()["status"] != "auth_required"
def test_hybrid_search_falls_back_to_local_with_server_unavailable_warning(mod):
build_complete_index(mod)
def server_down(*_args, **_kwargs):
raise mod.SafeToolError("http_503", "Napseer GET request failed with HTTP 503.", status=503)
mod.request_json = server_down
result = mod.discover_memory({"q": "gateway reliability", "limit": 25})
assert {item["full_path"] for item in result["items"]} == {"/plans/alpha", "/plans/beta"}
assert [w["code"] for w in result["warnings"]] == ["server_unavailable"]
assert result["freshness"]["stale"] is True
assert result["freshness"]["reason"] == "server_unavailable"
# A non-recoverable server error is not masked by the local answer.
mod.request_json = lambda *a, **k: (_ for _ in ()).throw(mod.SafeToolError("http_403", "forbidden", status=403))
with pytest.raises(mod.SafeToolError):
mod.discover_memory({"q": "gateway reliability", "limit": 25})
def test_whoami_reports_expired_access_without_refresh_ahead_of_gateway_state(mod):
mod.REFRESH_TOKEN = None
mod.TOKEN_EXPIRES_AT = "2020-01-01T00:00:00Z"
status = mod.gateway_status()
assert status["status"] == "auth_required"
assert status["auth_problem"]["reason"] == "access_token_expired_without_refresh"
# -- 3. Atomic claim-next ---------------------------------------------------
def kanban_card(name, priority, rank):
return {
"id": f"id-{name}",
"project_id": "project-1",
"full_path": f"/kanban/todo/{name}",
"folder_path": "/kanban/todo",
"name": name,
"type": "kanban_card",
"tags": ["kanban"],
"links": [],
"metadata": {"status": "todo", "column": "todo", "priority": priority, "rank": rank, "blocked": False},
"content_text": "card",
"updated_at": "2026-05-06T12:00:00Z",
"archived_at": None,
"encryption_state": "plaintext",
}
def kanban_backend(mod, claim_outcomes):
cards = [kanban_card("first", "urgent", "00001024"), kanban_card("second", "high", "00001024"),
kanban_card("third", "normal", "00001024")]
claims = []
def fake_request(method, path, payload=None, **kwargs):
if method == "GET":
return {"items": cards, "next_cursor": None}
assert (method, path) == ("POST", "/v1/projects/project-1/operations")
claims.append(payload)
outcome = claim_outcomes[len(claims) - 1]
if isinstance(outcome, Exception):
raise outcome
return {**outcome, "id": payload["id"], "node_id": payload["node_id"]}
mod.request_json = fake_request
return claims
def held_error(mod, legacy=False):
mod_error = mod.safe_http_error(
http_error(409, {}),
operation="POST request",
body_text=json.dumps(
{"error": {"code": "conflict", "message": "Another execution holds this work until it expires."}}
if legacy else
{"error": {"code": "operation_held", "message": "held", "details": {"active_operation_id": "op-active"}}}
),
)
return mod_error
def test_claim_next_skips_held_cards_and_claims_in_pick_order(mod):
claims = kanban_backend(mod, [held_error(mod), held_error(mod, legacy=True), {"state": "running"}])
mod.CONFIGURED_TOOL_PROFILES = {"workflow"}
result = mod.call_tool_impl("nap_kanban_claim_next", {"holder": "agent-a", "ttl_seconds": 120})
assert [c["node_id"] for c in claims] == ["id-first", "id-second", "id-third"]
assert len({c["id"] for c in claims}) == 3
assert all(c["holder"] == "agent-a" and c["ttl_seconds"] == 120 for c in claims)
assert result["claimed"]["full_path"] == "/kanban/todo/third"
assert result["operation"]["state"] == "running"
assert result["operation_id"] == claims[-1]["id"]
assert [s["reason"] for s in result["skipped"]] == ["operation_held", "operation_held"]
assert result["skipped"][0]["active_operation_id"] == "op-active"
def test_claim_next_returns_null_when_all_held_and_stops_on_ambiguous_error(mod):
kanban_backend(mod, [held_error(mod)] * 3)
none = mod.claim_next_kanban_card({"holder": "agent-a"})
assert none["ok"] is True and none["claimed"] is None
assert none["reason"] == "all_candidates_held"
claims = kanban_backend(mod, [held_error(mod), mod.SafeToolError("request_timeout", "timed out")])
with pytest.raises(mod.SafeToolError) as caught:
mod.claim_next_kanban_card({"holder": "agent-a"})
# Stops at the ambiguous claim; never retries or moves on.
assert len(claims) == 2
payload = mod.safe_exception_payload(caught.value)
assert payload["code"] == "kanban_claim_unresolved"
assert payload["details"]["attempted_operation_id"] == claims[1]["id"]
assert payload["details"]["cause"]["code"] == "request_timeout"
assert claims[1]["id"] in payload["message"]
claims = kanban_backend(mod, [held_error(mod), held_error(mod)])
capped = mod.claim_next_kanban_card({"holder": "agent-a", "max_candidates": 2})
assert len(claims) == 2 and capped["reason"] == "max_candidates_held"
with pytest.raises(mod.SafeToolError):
mod.claim_next_kanban_card({})
def test_claim_next_is_registered_as_workflow_mutation(mod):
assert "nap_kanban_claim_next" in mod.CANONICAL_TOOL_NAMES
assert mod.tool_profile("nap_kanban_claim_next") == "workflow"
assert mod.tool_category("nap_kanban_claim_next") == "kanban"
metadata = mod.tool_contract_metadata("nap_kanban_claim_next")
assert metadata["side_effect"] == "remote-write"
assert metadata["lock_policy"] == "atomic-execution-lease"
assert "nap_kanban_claim_next" not in mod.READ_ONLY_TOOLS
# -- 4. links_count from indexed edges -------------------------------------
def test_local_discovery_rows_report_indexed_link_counts(mod):
build_complete_index(mod)
mod.request_json = lambda *a, **k: (_ for _ in ()).throw(AssertionError("no HTTP expected"))
searched = mod.search_local_index({"q": "gateway reliability", "limit": 10})
by_path = {item["full_path"]: item for item in searched["items"]}
assert by_path["/plans/alpha"]["links_count"] == 1
assert by_path["/plans/beta"]["backlinks_count"] == 1
rows = mod.discover_memory({"q": "gateway reliability", "view": "summary", "mode": "local"})["items"]
rows = {item["full_path"]: item for item in rows}
assert rows["/plans/alpha"]["links_count"] == 1
assert rows["/plans/beta"]["links_count"] == 0
assert rows["/plans/beta"]["backlinks_count"] == 1
# Unknown counts are omitted rather than reported as zero.
summary = mod.summarize_node({"id": "x", "full_path": "/a/x", "name": "x"}, view="summary")
assert "links_count" not in summary
# -- 5. Tool profiles from the committed locator ---------------------------
def write_locator(mod, **extra):
path = mod.project_locator_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({
"schema": "napseer.project.v1",
"base_url": "https://api.example.test",
"project_id": "11111111-1111-4111-8111-111111111111",
"project_slug": "example",
**extra,
}))
def test_locator_tool_profiles_apply_when_env_unset(mod, monkeypatch):
mod.CONFIGURED_TOOL_PROFILES = None
assert mod.configured_tool_profiles() == set()
write_locator(mod, tool_profiles=["workflow", "operations", "bogus"])
assert mod.load_project_locator()["project_slug"] == "example"
assert mod.configured_tool_profiles() == {"workflow", "operations"}
assert "nap_kanban_claim_next" in mod.enabled_tool_names()
apropos = mod.explore_tools({})
assert apropos["profile_source"] == "project_locator"
assert apropos["warnings"][0]["code"] == "unknown_tool_profile"
assert apropos["warnings"][0]["ignored"] == ["bogus"]
mod.BASE_URL = "https://api.example.test"
mod.local_anchor_integrity = lambda: {}
mod.index_status = lambda args: {}
doctor = mod.doctor({})
assert doctor["warnings"][0]["code"] == "unknown_tool_profile"
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "maintenance")
assert mod.configured_tool_profiles() == {"maintenance"}
assert mod.explore_tools({})["profile_source"] == "environment"
monkeypatch.delenv("NAPSEER_TOOL_PROFILES")
write_locator(mod, tool_profiles="workflow")
assert mod.configured_tool_profiles() == set()
assert mod.explore_tools({})["warnings"][0]["code"] == "invalid_tool_profiles"
# -- 6. Change feed cursor --------------------------------------------------
def changes_backend(mod, responses):
calls = []
def fake_request(method, path, payload=None, **kwargs):
calls.append((method, path))
outcome = responses[len(calls) - 1]
if isinstance(outcome, Exception):
raise outcome
return outcome
mod.request_json = fake_request
return calls
def test_changes_since_starts_at_head_then_advances_only_on_success(mod, tmp_path):
change = {"type": "node_updated", "cursor": "c1.5.9", "event_id": "e1", "project_id": "project-1",
"node_id": "n1", "full_path": "/plans/a", "changed_fields": ["content_text"]}
calls = changes_backend(mod, [
{"items": [], "next_cursor": "c1.5.8", "has_more": False},
{"items": [change], "next_cursor": "c1.5.9", "has_more": True},
mod.SafeToolError("http_503", "unavailable", status=503),
{"items": [], "next_cursor": "c1.9.9", "has_more": False},
])
first = mod.call_tool_impl("nap_changes_since", {})
assert calls[0] == ("GET", "/v1/projects/project-1/changes?after=head&limit=100")
assert first["started_at_head"] is True and first["items"] == []
stored = json.loads((mod.AUTH_DIR / "changes-cursor.json").read_text())
assert stored["project_id"] == "project-1" and stored["cursor"] == "c1.5.8"
assert not (tmp_path / ".napseer" / "changes-cursor.json").exists()
second = mod.changes_since({"limit": 50})
assert calls[1] == ("GET", "/v1/projects/project-1/changes?after=c1.5.8&limit=50")
assert second["items"] == [change] and second["has_more"] is True and second["cursor_advanced"] is True
with pytest.raises(mod.SafeToolError) as caught:
mod.changes_since({})
assert caught.value.details["cursor_advanced"] is False
assert mod.read_changes_cursor("project-1") == "c1.5.9"
peek = mod.changes_since({"advance": False})
assert peek["cursor_advanced"] is False
assert mod.read_changes_cursor("project-1") == "c1.5.9"
def test_changes_since_errors_project_binding_and_reset(mod):
mod.write_changes_cursor("other-project", "c1.1.1")
calls = changes_backend(mod, [
mod.safe_http_error(http_error(404, {}), body_text="{}"),
mod.safe_http_error(http_error(400, {}), body_text=json.dumps({"error": {"code": "validation_error"}})),
{"items": [], "next_cursor": "c1.2.2", "has_more": False},
])
with pytest.raises(mod.SafeToolError) as unsupported:
mod.changes_since({})
# A cursor stored for another project is never reused.
assert "after=head" in calls[0][1]
assert unsupported.value.code == "unsupported_server"
with pytest.raises(mod.SafeToolError) as invalid:
mod.changes_since({"after": "bogus"})
assert invalid.value.code == "invalid_changes_cursor"
reset = mod.changes_since({"reset": True})
assert "after=head" in calls[2][1] and reset["started_at_head"] is True
assert mod.read_changes_cursor("project-1") == "c1.2.2"
mod.REFRESH_TOKEN = None
mod.TOKEN_EXPIRES_AT = "2020-01-01T00:00:00Z"
with pytest.raises(mod.SafeToolError) as auth:
mod.changes_since({})
assert auth.value.code == "auth_required"
assert auth.value.details["auth_problem"]["reason"] == "access_token_expired_without_refresh"
assert len(calls) == 3
assert mod.read_changes_cursor("project-1") == "c1.2.2"
def test_changes_since_is_a_core_read_tool(mod):
assert "nap_changes_since" in mod.CORE_TOOL_NAMES
assert mod.tool_category("nap_changes_since") == "memory"
assert mod.tool_contract_metadata("nap_changes_since")["side_effect"] == "none"
# -- 7. Idempotency keys on remaining node writes --------------------------
def node_write_backend(mod, nodes):
stub_project_lock(mod)
mod.CONFIGURED_TOOL_PROFILES = {"all"}
mod.get_node_by_id = lambda args: nodes[args.get("node_id") or args.get("id")]
mod.index_node = lambda node: None
mod.remove_indexed_node = lambda node_id: None
writes = []
def fake_request(method, path, payload=None, **kwargs):
if method == "GET":
return {"items": list(nodes.values()) if "/nodes?" in path else [], "next_cursor": None}
writes.append((method, path, dict(kwargs.get("extra_headers") or {})))
node_id = path.rsplit("/", 1)[-1]
return {**nodes.get(node_id, next(iter(nodes.values()))), **(payload or {})}
mod.request_json = fake_request
return writes
def test_link_and_kanban_writes_send_scoped_idempotency_keys(mod):
card = kanban_card("first", "high", "00001024")
card["id"] = "card-1"
target = {"id": "target-1", "project_id": "project-1", "full_path": "/plans/t", "links": []}
writes = node_write_backend(mod, {"card-1": card, "target-1": target})
linked = mod.call_tool_impl("nap_ln", {"source_node_id": "card-1", "target_node_id": "target-1"})
assert writes[-1][0:2] == ("PATCH", "/v1/projects/project-1/nodes/card-1")
assert linked["idempotency_key"] == writes[-1][2]["Idempotency-Key"]
uuid.UUID(linked["idempotency_key"])
started = mod.call_tool_impl("nap_kanban_start", {"node_id": "card-1", "idempotency_key": "start-1"})
assert writes[-1][2]["Idempotency-Key"] == "start-1"
assert started["idempotency_key"] == "start-1"
tools = {tool["name"]: tool for tool in mod.all_tools()}
for name in ("nap_ln", "nap_kanban_start", "nap_kanban_update", "nap_agent_tee", "nap_mv_folder"):
assert "idempotency_key" in tools[name]["inputSchema"]["properties"], name
assert mod.tool_contract_metadata(name)["idempotency"] == "same-idempotency-key"
def test_multi_write_tools_derive_keys_and_surface_them_on_failure(mod):
nodes = {
"a": {"id": "a", "project_id": "project-1", "full_path": "/old/a", "folder_path": "/old", "name": "a"},
"b": {"id": "b", "project_id": "project-1", "full_path": "/old/b", "folder_path": "/old", "name": "b"},
}
writes = node_write_backend(mod, nodes)
moved = mod.call_tool_impl("nap_mv_folder", {"from_folder": "/old", "to_folder": "/new", "idempotency_key": "mvf"})
assert [w[2]["Idempotency-Key"] for w in writes] == ["mvf", "mvf:1"]
assert moved["idempotency_key"] == "mvf" and moved["idempotency_keys"] == ["mvf", "mvf:1"]
def ambiguous(method, path, payload=None, **kwargs):
raise mod.SafeToolError("request_timeout", "timed out")
mod.request_json = ambiguous
with pytest.raises(mod.SafeToolError) as caught:
mod.call_tool_impl("nap_create_node", {"folder_path": "/notes", "name": "x", "content_text": "x"})
uuid.UUID(caught.value.details["idempotency_key"])
assert mod.node_write_idempotency_headers("POST", "/v1/projects/p/nodes/n/restore") == {}
assert mod.node_write_idempotency_headers("DELETE", "/v1/projects/p/nodes/n") == {}
assert "Idempotency-Key" in mod.node_write_idempotency_headers("PATCH", "/v1/projects/p/nodes/n/patch")