From 7eaaffb63f4291be95af385eb62555b70fd5f003 Mon Sep 17 00:00:00 2001 From: HumbleTurtle Date: Mon, 28 Sep 2026 21:34:35 -0300 Subject: [PATCH] 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) --- resources/scripts/napseer_mcp_server.py | 900 ++++++++++++++++++-- tests/test_mcp_memory_ergonomics_helpers.py | 2 +- tests/test_mcp_memory_surface_efficiency.py | 2 +- tests/test_mcp_reliability_contracts.py | 593 +++++++++++++ 4 files changed, 1427 insertions(+), 70 deletions(-) create mode 100644 tests/test_mcp_reliability_contracts.py diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index 7c51f0e..53ceaf9 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -325,6 +325,9 @@ AUTH_PATH = pathlib.Path(os.environ.get("NAPSEER_AUTH_FILE", PROJECT_DIR / "auth PROJECT_LOCATOR_SCHEMA = "napseer.project.v1" PROJECT_LOCATOR_FILENAME = "project.json" PROJECT_LOCATOR_FIELDS = {"schema", "base_url", "project_id", "project_slug"} +# Optional, non-identity locator preferences. Values are validated where +# they are consumed so an unknown preference never blocks project identity. +PROJECT_LOCATOR_OPTIONAL_FIELDS = {"tool_profiles"} GATEWAY_AUTH_PATH = AUTH_DIR / "gateway-auth.json" VAULT_PATH = AUTH_DIR / "vault.json" LOCAL_VAULT_KEY_PATH = AUTH_DIR / "vault.local-key" @@ -870,7 +873,7 @@ def validate_project_locator(data): "project_locator_invalid", ".napseer/project.json must contain one JSON object.", ) - if set(data) != PROJECT_LOCATOR_FIELDS: + if not PROJECT_LOCATOR_FIELDS <= set(data) or set(data) - PROJECT_LOCATOR_FIELDS - PROJECT_LOCATOR_OPTIONAL_FIELDS: raise SafeToolError( "project_locator_invalid", ".napseer/project.json has unsupported or missing fields. Regenerate it with a current nap CLI.", @@ -918,12 +921,15 @@ def validate_project_locator(data): ".napseer/project.json project_slug is invalid.", ) - return { + locator = { "schema": PROJECT_LOCATOR_SCHEMA, "base_url": raw_base_url, "project_id": project_id, "project_slug": project_slug, } + if "tool_profiles" in data: + locator["tool_profiles"] = data["tool_profiles"] + return locator def load_project_locator(): @@ -1041,6 +1047,9 @@ def write_project_locator(project, *, base_url=None): "project_locator_mismatch", "Local project credentials do not match the committed .napseer/project.json locator. Attach the declared project or intentionally update the locator before continuing.", ) + if existing and "tool_profiles" in existing: + # Preserve the repository's committed preference on rewrite. + desired["tool_profiles"] = existing["tool_profiles"] ensure_project_locator_ignore_policy() path = project_locator_path() @@ -1414,6 +1423,7 @@ def gateway_status(): locator_mismatch = bool( locator and DEFAULT_PROJECT_ID and str(DEFAULT_PROJECT_ID) != locator["project_id"] ) + auth_problem = current_auth_problem() if DEFAULT_PROJECT_ID and TOKEN else None if locator_mismatch: status = "project_locator_mismatch" message = "Local project credentials do not match the committed project locator." @@ -1423,6 +1433,10 @@ def gateway_status(): elif not DEFAULT_PROJECT_ID or not TOKEN: status = "not_configured" message = "No project/token is configured for this directory." + elif auth_problem: + # Memory access is unusable; report it ahead of gateway/relay state. + status = "auth_required" + message = auth_problem["message"] elif not vault_exists(): status = "vault_not_configured" message = "Local gateway storage is not configured; run nap gateway configure." @@ -1461,6 +1475,7 @@ def gateway_status(): "token_expires_at": TOKEN_EXPIRES_AT, "has_token": bool(TOKEN), "has_refresh_token": bool(REFRESH_TOKEN), + "auth_problem": auth_problem, "legacy_ssh_key_present": bool(AUTH.get("ssh_private_key")) or pathlib.Path(AUTH["ssh_key_path"]).expanduser().exists(), "tmux_target": VAULT_SECRETS.get("gateway_tmux_target") if unlocked else None, "default_command": VAULT_SECRETS.get("gateway_default_command") if unlocked else None, @@ -6196,13 +6211,19 @@ def summarize_node(node, view="summary", preview_chars=240): "created_at": node.get("created_at"), "metadata_summary": metadata_summary(node), "updated_at": node.get("updated_at"), + # Unknown when neither an indexed count nor a link list is present + # (for example compact server rows); omit rather than report 0. "links_count": ( node.get("links_count") if node.get("links_count") is not None - else len(node.get("links") or []) if isinstance(node.get("links"), list) else 0 + else len(node["links"]) if isinstance(node.get("links"), list) else None ), + "backlinks_count": node.get("backlinks_count"), "archived": node_archived(node), } + for key in ("links_count", "backlinks_count"): + if item[key] is None: + item.pop(key) content = node.get("snippet") or node.get("preview") or node.get("content_text") if content: item["preview"] = preview_text(content, preview_chars) @@ -6565,14 +6586,105 @@ def cleanup_project_lock(project_id, lock): pass -def request_project_write(method, path, payload, project_id, purpose, scope_type="project", scope_key="*"): +# Tools that resolve their own Idempotency-Key from the idempotency_key argument. +EXPLICIT_IDEMPOTENCY_KEY_TOOLS = {"nap_create_node", "nap_node_patch", "nap_bulk"} +# Tools whose node writes (POST /nodes, PATCH /nodes/{id}) take keys from a +# per-call scope: the first write sends the base key, later writes ':'. +SCOPED_IDEMPOTENCY_KEY_TOOLS = { + "nap_ln", + "nap_mv", + "nap_mv_folder", + "nap_plan_to_kanban", + "nap_plan_transition", + "nap_kanban_create", + "nap_kanban_start", + "nap_kanban_send_review", + "nap_kanban_complete", + "nap_kanban_block", + "nap_kanban_unblock", + "nap_kanban_update", + "nap_agent_tee", + "nap_agent_patch", + "nap_agent_ln", +} +IDEMPOTENCY_KEY_TOOLS = EXPLICIT_IDEMPOTENCY_KEY_TOOLS | SCOPED_IDEMPOTENCY_KEY_TOOLS +NODE_WRITE_ROUTE_PATTERN = re.compile(r"^/v1/projects/[^/]+/nodes(?:/[^/?]+(?:/patch)?)?$") +_IDEMPOTENCY_SCOPE = threading.local() + + +class NodeWriteIdempotencyScope: + """One logical tool call: a stable base key and the derived per-write keys.""" + + def __init__(self, base_key): + self.base_key = base_key + self.keys = [] + + def next_key(self): + index = len(self.keys) + key = self.base_key if index == 0 else f"{self.base_key}:{index}" + self.keys.append(key) + return key + + def __enter__(self): + self.previous = getattr(_IDEMPOTENCY_SCOPE, "current", None) + _IDEMPOTENCY_SCOPE.current = self + return self + + def __exit__(self, *_exc): + _IDEMPOTENCY_SCOPE.current = self.previous + return False + + +def node_write_idempotency_headers(method, path): + """Idempotency-Key for a node create/update route, else no header. + + Called once per logical write, before any lock or auth retry, so every + internal retry of that write reuses the same key. + """ + if method not in {"POST", "PATCH"} or not NODE_WRITE_ROUTE_PATTERN.match(str(path).split("?", 1)[0]): + return {} + scope = getattr(_IDEMPOTENCY_SCOPE, "current", None) + return idempotency_headers(scope.next_key() if scope else str(uuid.uuid4())) + + +def request_project_write(method, path, payload, project_id, purpose, scope_type="project", scope_key="*", *, extra_headers=None): + extra_headers = dict(extra_headers or {}) + if "Idempotency-Key" not in extra_headers: + extra_headers.update(node_write_idempotency_headers(method, path)) return with_project_lock( {"project_id": project_id, "scope_type": scope_type, "scope_key": scope_key, "purpose": purpose}, - lambda headers: request_json(method, path, payload, extra_headers=headers), + lambda headers: request_json(method, path, payload, extra_headers={**headers, **extra_headers}), project_id=project_id, ) +IDEMPOTENCY_KEY_MAX_LENGTH = 200 + + +def resolve_idempotency_key(value=None): + """Return the caller's key, or one fresh key for this logical write. + + The key is generated once per tool call and reused for every internal + retry of that request (including the auth-renewal replay in + request_json), so the server can deduplicate an ambiguous outcome. + """ + if value is None: + return str(uuid.uuid4()) + if not isinstance(value, str) or not value.strip(): + raise SafeToolError("invalid_arguments", "idempotency_key must be a non-empty string.") + value = value.strip() + if len(value) > IDEMPOTENCY_KEY_MAX_LENGTH or any(ord(ch) < 0x21 or ord(ch) > 0x7E for ch in value): + raise SafeToolError( + "invalid_arguments", + f"idempotency_key must be 1-{IDEMPOTENCY_KEY_MAX_LENGTH} visible ASCII characters without spaces.", + ) + return value + + +def idempotency_headers(key): + return {"Idempotency-Key": key} + + INDEX_SCHEMA_INIT_LOCK = threading.Lock() INDEX_SCHEMA_READY = {} @@ -7463,6 +7575,96 @@ def local_index_diagnostics(project_id, conn=None): return read(connection) +def server_fallback_code(exc): + """Classify a server read failure that a local-index answer may cover.""" + if isinstance(exc, SafeToolError): + if exc.code in AUTH_FAILURE_CODES or exc.status == 401: + return "auth_required" + if exc.code in {"request_timeout", "auth_refresh_unavailable"}: + return "server_unavailable" + if isinstance(exc.status, int) and exc.status >= 500: + return "server_unavailable" + return None + if isinstance(exc, (urllib.error.URLError, http.client.HTTPException, ConnectionError, TimeoutError, socket.timeout)): + return "server_unavailable" + return None + + +def local_answer_staleness(project_id, *, server_error=None, diagnostics=None): + """Return (warning, freshness) when a local-index answer may be stale. + + A server failure, or a known auth problem that makes the server + unreachable for this login, means the local index cannot be refreshed. + Returns None when neither applies. + """ + if server_error is not None: + code = server_fallback_code(server_error) + if code is None: + return None + cause = safe_exception_payload(server_error) + else: + problem = current_auth_problem() + if not problem: + return None + code = problem["code"] + cause = {key: problem[key] for key in ("reason", "message", "recorded_at") if problem.get(key)} + if diagnostics is None: + try: + diagnostics = local_index_diagnostics(project_id) + except Exception: + diagnostics = {} + indexed_at = diagnostics.get("latest_indexed_at") + last_sync_at = diagnostics.get("graph_completed_at") + newest = max( + (value for value in (parse_iso_datetime(indexed_at), parse_iso_datetime(last_sync_at)) if value is not None), + default=None, + ) + age_seconds = max(0, int((datetime.now(timezone.utc) - newest).total_seconds())) if newest else None + if code == "auth_required": + message = "Answered from the local index because Napseer login is not usable; results may be stale. Run `nap auth login` (or `nap auth repair`)." + else: + message = "Answered from the local index because the Napseer server was unavailable; results may be stale. Retry when the service recovers." + warning = { + "code": code, + "message": message, + "indexed_at": indexed_at, + "last_sync_at": last_sync_at, + "age_seconds": age_seconds, + } + freshness = { + "source": "local_index", + "stale": True, + "reason": code, + "indexed_at": indexed_at, + "last_sync_at": last_sync_at, + "age_seconds": age_seconds, + "cause": cause, + } + return warning, freshness + + +def apply_local_staleness(response, staleness): + if not staleness: + return response + warning, freshness = staleness + response["warnings"] = [*(response.get("warnings") or []), warning] + existing = response.get("freshness") if isinstance(response.get("freshness"), dict) else {} + # Keep the reader's own freshness facts; add the staleness verdict. + response["freshness"] = {**freshness, **existing, "stale": True, "reason": freshness["reason"]} + return response + + +# Outgoing and incoming first-class edge counts for a local_index_nodes row +# aliased as n. Incoming edges may be recorded by target id or by route. +LOCAL_INDEX_EDGE_COUNT_COLUMNS = """ + (SELECT COUNT(*) FROM local_index_edges e + WHERE e.project_id = n.project_id AND e.source_node_id = n.node_id) AS links_count, + (SELECT COUNT(*) FROM local_index_edges e + WHERE e.project_id = n.project_id + AND (e.target_node_id = n.node_id + OR (e.target_node_id IS NULL AND e.target_path = n.full_path))) AS backlinks_count""" + + def search_local_index(args): args = normalize_discovery_ranges(args) project_id = resolve_project_id(args) @@ -7514,7 +7716,7 @@ def search_local_index(args): f""" SELECT n.node_id, n.project_id, n.full_path, n.name, n.folder_path, n.node_type, n.tags_json, n.updated_at, n.indexed_at, - n.title, n.status, n.archived, + n.title, n.status, n.archived,{LOCAL_INDEX_EDGE_COUNT_COLUMNS}, local_index_fts.aliases AS aliases_text, bm25(local_index_fts, 0.0, 0.0, 8.0, 12.0, 4.0, 5.0, 8.0, 6.0, 1.0, 2.0, 1.0) AS rank @@ -7558,6 +7760,8 @@ def search_local_index(args): "archived": bool(row["archived"]), "updated_at": row["updated_at"], "indexed_at": row["indexed_at"], + "links_count": row["links_count"], + "backlinks_count": row["backlinks_count"], "rank": row["rank"], "matched_strategy": variant["strategy"], } @@ -8108,6 +8312,91 @@ def _renew_auth_locked(): return renew_anonymous_auth_with_ssh() +AUTH_FAILURE_SCHEMA = "napseer.auth_failure.v1" +AUTH_FAILURE_CODES = {"auth_required", "credential_rejected", "auth_refresh_failed"} + + +def auth_failure_path(): + # Private per-worktree state dir; never inside the checkout. + return AUTH_DIR / "auth-failure.json" + + +def auth_credential_marker(): + """Non-reversible marker binding a recorded failure to the current credentials. + + The marker is a truncated SHA-256 of the access/refresh pair, so a later + login or renewal (in this or another process) invalidates the record + without the state file ever containing credential material. + """ + material = f"{TOKEN or ''}\0{REFRESH_TOKEN or ''}".encode("utf-8") + return hashlib.sha256(material).hexdigest()[:16] + + +def record_auth_failure(exc): + try: + AUTH_DIR.mkdir(parents=True, exist_ok=True) + chmod_best_effort(AUTH_DIR, 0o700) + record = { + "schema": AUTH_FAILURE_SCHEMA, + "code": "auth_required", + "reason": exc.code if isinstance(exc, SafeToolError) else "auth_failed", + "message": safe_exception_text(exc), + "recorded_at": iso_now(), + "credential_marker": auth_credential_marker(), + } + temporary = auth_failure_path().with_name(f".{auth_failure_path().name}.{os.getpid()}.tmp") + temporary.write_text(json.dumps(record, sort_keys=True) + "\n", encoding="utf-8") + chmod_best_effort(temporary, 0o600) + os.replace(temporary, auth_failure_path()) + except Exception: + # Diagnostics state must never change the original auth outcome. + pass + + +def clear_auth_failure(): + try: + auth_failure_path().unlink() + except FileNotFoundError: + pass + except Exception: + pass + + +def recorded_auth_failure(): + try: + record = json.loads(auth_failure_path().read_text(encoding="utf-8")) + except Exception: + return None + if not isinstance(record, dict) or record.get("schema") != AUTH_FAILURE_SCHEMA: + return None + if record.get("credential_marker") != auth_credential_marker(): + return None + return record + + +def current_auth_problem(): + """Return a known, non-secret auth problem without contacting the server.""" + if not TOKEN and not REFRESH_TOKEN: + return {"code": "auth_required", "reason": "no_login", + "message": "No login is configured. Run `nap auth login` or configure a project API key."} + record = recorded_auth_failure() + if record: + return {"code": "auth_required", "reason": record.get("reason") or "auth_failed", + "message": record.get("message") or "The login grant is expired or revoked. Run `nap auth login`.", + "recorded_at": record.get("recorded_at")} + now = datetime.now(timezone.utc) + access_expires = parse_iso_datetime(TOKEN_EXPIRES_AT) + if access_expires is not None and access_expires <= now: + refresh_expires = parse_iso_datetime(REFRESH_EXPIRES_AT) + if not REFRESH_TOKEN: + return {"code": "auth_required", "reason": "access_token_expired_without_refresh", + "message": "The access token expired and no renewable login is configured. Run `nap auth login`."} + if refresh_expires is not None and refresh_expires <= now: + return {"code": "auth_required", "reason": "refresh_token_expired", + "message": "The login grant is expired. Run `nap auth login`."} + return None + + def renew_auth(stale_token=None): # OAuth providers may rotate refresh tokens. Relay lanes run concurrently, # so serialize renewal and reload the latest token written by another lane @@ -8126,7 +8415,14 @@ def renew_auth(stale_token=None): "refresh_expires_at": REFRESH_EXPIRES_AT, "project_id": DEFAULT_PROJECT_ID, } - return _renew_auth_locked() + try: + result = _renew_auth_locked() + except SafeToolError as exc: + if exc.code in AUTH_FAILURE_CODES: + record_auth_failure(exc) + raise + clear_auth_failure() + return result def jwt_claims(token): @@ -10215,11 +10511,13 @@ def guarded_node_patch(args): "alias_ops", "content_op", "dry_run", - "idempotency_key", "allow_dangling_links", ]: if key in args: payload[key] = args[key] + # The key travels only as the Idempotency-Key header; the body field was + # removed from the server contract. + idempotency_key = resolve_idempotency_key(args.get("idempotency_key")) result = request_project_write( "PATCH", f"/v1/projects/{project_id}/nodes/{urllib.parse.quote(node_id, safe='')}/patch", @@ -10228,12 +10526,13 @@ def guarded_node_patch(args): f"guarded patch node {existing.get('full_path') or node_id}", "node", node_id, + extra_headers=idempotency_headers(idempotency_key), ) node = ((result.get("data") or {}).get("node") or {}) if isinstance(result, dict) else {} if node: index_node(node) if not isinstance(result, dict): - return {"ok": True, "changed": True, "id": node_id, "path": existing.get("full_path")} + return {"ok": True, "changed": True, "id": node_id, "path": existing.get("full_path"), "idempotency_key": idempotency_key} data = result.get("data") if isinstance(result.get("data"), dict) else {} compact = { "ok": bool(result.get("ok", True)), @@ -10242,6 +10541,7 @@ def guarded_node_patch(args): "dry_run": bool(data.get("dry_run")), "id": node.get("id") or node_id, "path": node.get("full_path") or existing.get("full_path"), + "idempotency_key": idempotency_key, } if data.get("revision"): compact["revision"] = data["revision"] @@ -10360,6 +10660,36 @@ def upsert_node(args): return {"ok": True, "changed": True, "created": True, **node_write_reference(node)} +def create_node(args): + project_id = resolve_project_id(args) + folder_path = args.get("folder_path", "/agent/memory") + reject_agent_namespace_path(f"{folder_path}/{args['name']}") + idempotency_key = resolve_idempotency_key(args.get("idempotency_key")) + payload = { + "folder_path": folder_path, + "name": args["name"], + "type": args.get("type", "note"), + "tags": args.get("tags", []), + "aliases": args.get("aliases", []), + "links": args.get("links", []), + "metadata": args.get("metadata", {}), + "content_text": args["content_text"], + } + node = request_project_write( + "POST", + f"/v1/projects/{project_id}/nodes", + encrypt_node_payload_for_write(project_id, payload), + project_id, + f"create node {folder_path}/{args['name']}", + extra_headers=idempotency_headers(idempotency_key), + ) + index_node(node) + result = decrypt_node_for_return(node, project_id=project_id) + if isinstance(result, dict): + result = {**result, "idempotency_key": idempotency_key} + return result + + def update_node_by_path(args): project_id = resolve_project_id(args) existing = resolve_existing_node_ref(args) @@ -10634,9 +10964,7 @@ def browse_local_index(args, *, view, sort): f""" SELECT n.node_id, n.project_id, n.full_path, n.name, n.folder_path, n.node_type, n.tags_json, n.updated_at, n.indexed_at, - n.title, n.status, n.archived, {title_key} AS title_sort_key, - (SELECT COUNT(*) FROM local_index_edges e - WHERE e.project_id = n.project_id AND e.source_node_id = n.node_id) AS links_count + n.title, n.status, n.archived, {title_key} AS title_sort_key,{LOCAL_INDEX_EDGE_COUNT_COLUMNS} FROM local_index_nodes n WHERE {' AND '.join(where)} ORDER BY {order_by} @@ -10664,6 +10992,7 @@ def browse_local_index(args, *, view, sort): "status": status, "metadata_summary": {"status": status} if status else {}, "links_count": row["links_count"], + "backlinks_count": row["backlinks_count"], "archived": bool(row["archived"]), "updated_at": row["updated_at"], "indexed_at": row["indexed_at"], @@ -10736,10 +11065,14 @@ def browse_project_nodes(args, *, default_view="summary"): ) return list_project_nodes(args) - diagnostics = local_index_diagnostics(resolve_project_id(args)) + project_id = resolve_project_id(args) + diagnostics = local_index_diagnostics(project_id) local_supported = bool(diagnostics.get("graph_complete")) if local_supported: - return browse_local_index(args, view=view, sort=sort) + return apply_local_staleness( + browse_local_index(args, view=view, sort=sort), + local_answer_staleness(project_id, diagnostics=diagnostics), + ) if local_cursor: raise RuntimeError("local discovery cursor requires the same complete local snapshot routing") if mode == "local": @@ -11326,21 +11659,136 @@ def get_blocked_kanban_cards(args): return list_kanban_cards(query) -def pick_next_kanban_card(args): +def ordered_kanban_candidates(args): + """Pending cards in pick-next order (column, priority, rank, updated_at, path).""" args = dict(args or {}) result = get_pending_kanban_cards({**args, "limit": max(normalize_int(args.get("limit"), 25, 1, DISCOVERY_COMPACT_MAX_LIMIT), 25)}) - items = result.get("items") or [] + return result, list(result.get("items") or []) + + +def kanban_selection_contract(args): + return { + "columns": ["todo", "backlog"] if as_bool(args.get("include_backlog"), True) else ["todo"], + "blocked_excluded": not as_bool(args.get("include_blocked"), False), + "sort": ["column", "priority", "rank", "updated_at", "path"], + } + + +def pick_next_kanban_card(args): + args = dict(args or {}) + result, items = ordered_kanban_candidates(args) item = items[0] if items else None return { "ok": True, "project_id": result.get("project_id"), "selected": item, "items_considered": len(items), - "selection": { - "columns": ["todo", "backlog"] if as_bool(args.get("include_backlog"), True) else ["todo"], - "blocked_excluded": not as_bool(args.get("include_blocked"), False), - "sort": ["column", "priority", "rank", "updated_at", "path"], - }, + "selection": kanban_selection_contract(args), + } + + +KANBAN_CLAIM_NEXT_DEFAULT_CANDIDATES = 10 +KANBAN_CLAIM_NEXT_MAX_CANDIDATES = 25 +LEGACY_OPERATION_HELD_MESSAGE = "Another execution holds this work" + + +def operation_held_error(exc): + """True only for a definite 'another running operation holds this node' refusal.""" + if not isinstance(exc, SafeToolError) or exc.status != 409: + return False + if exc.service_code == "operation_held": + return True + return exc.service_code == "conflict" and str(getattr(exc, "service_message", "") or "").startswith( + LEGACY_OPERATION_HELD_MESSAGE + ) + + +def claim_next_kanban_card(args): + """Claim the first pending card whose execution lease is free. + + Each candidate gets a fresh operation id. A definite operation_held refusal + moves on to the next candidate; any other failure stops without retrying + so the caller can reconcile the attempted id with nap_operation get. + """ + args = dict(args or {}) + holder = str(args.get("holder") or "").strip() + if not holder: + raise SafeToolError("invalid_arguments", "holder is required for nap_kanban_claim_next.") + max_candidates = normalize_int( + args.get("max_candidates"), KANBAN_CLAIM_NEXT_DEFAULT_CANDIDATES, 1, KANBAN_CLAIM_NEXT_MAX_CANDIDATES + ) + ttl_seconds = normalize_int(args.get("ttl_seconds"), 300, 30, 900) + project_id = resolve_project_id({}) + selection_args = { + key: args[key] + for key in ("q", "include_blocked", "include_backlog", "dependency_view") + if key in args + } + result, items = ordered_kanban_candidates(selection_args) + candidates = items[:max_candidates] + skipped = [] + for card in candidates: + node_id = str(card.get("node_id") or card.get("id") or "").strip() + if not node_id: + skipped.append({"full_path": card.get("full_path"), "reason": "missing_node_id"}) + continue + operation_id = str(uuid.uuid4()) + try: + operation = request_json( + "POST", + f"/v1/projects/{project_id}/operations", + {"id": operation_id, "node_id": node_id, "holder": holder, "ttl_seconds": ttl_seconds}, + ) + except Exception as exc: + if operation_held_error(exc): + held = {"node_id": node_id, "full_path": card.get("full_path"), "reason": "operation_held"} + active_id = (getattr(exc, "service_details", None) or {}).get("active_operation_id") + if active_id: + held["active_operation_id"] = str(active_id) + skipped.append(held) + continue + cause = safe_exception_payload(exc) + error = SafeToolError( + "kanban_claim_unresolved", + f"Claiming {card.get('full_path') or node_id} with operation id {operation_id} did not " + f"complete ({cause['code']}). The claim may or may not exist: call nap_operation " + f"action='get' id='{operation_id}' to reconcile before claiming again.", + status=getattr(exc, "status", None), + service_code=getattr(exc, "service_code", None), + ) + error.details = { + "attempted_operation_id": operation_id, + "attempted_node_id": node_id, + "attempted_path": card.get("full_path"), + "cause": cause, + "skipped": skipped, + } + raise error from exc + return { + "ok": True, + "project_id": result.get("project_id") or project_id, + "claimed": card, + "operation": operation, + "operation_id": operation_id, + "candidates_considered": len(candidates), + "skipped": skipped, + "selection": kanban_selection_contract(selection_args), + "next_action": "Heartbeat with nap_operation action='heartbeat' before the lease expires; finish with succeed/fail/cancel.", + } + if not candidates: + reason = "no_pending_cards" + elif len(items) > len(candidates): + reason = "max_candidates_held" + else: + reason = "all_candidates_held" + return { + "ok": True, + "project_id": result.get("project_id") or project_id, + "claimed": None, + "reason": reason, + "candidates_considered": len(candidates), + "skipped": skipped, + "selection": kanban_selection_contract(selection_args), } @@ -12771,6 +13219,7 @@ def search_memory(args): collected = [] sources = [] server_search = {} + staleness = None diagnostics = { "query": query_analysis, "local_index": local_index_diagnostics(project_id), @@ -12819,7 +13268,15 @@ def search_memory(args): # list_project_nodes) multiplied one discovery into many serial HTTP # calls, especially on a new/sparse local index. remaining = max(1, limit - len(local_filtered)) - server_result = list_project_nodes({**args, "q": q, "limit": remaining, "view": view}) + try: + server_result = list_project_nodes({**args, "q": q, "limit": remaining, "view": view}) + except Exception as exc: + local_used = bool((diagnostics.get("local_index") or {}).get("used")) + if mode != "hybrid" or not (local_used or local_complete) or server_fallback_code(exc) is None: + raise + staleness = local_answer_staleness(project_id, server_error=exc, diagnostics=diagnostics.get("local_index")) + server_result = {"items": []} + sources.append("server_error") server_search = { key: server_result.get(key) for key in ("search_strategy", "search_coverage", "search_projection") @@ -12865,7 +13322,9 @@ def search_memory(args): response["search_projection"] = "sqlite_fts5_current" elif server_needed: response.update(server_search) - return response + if staleness is None and local_used and not server_needed: + staleness = local_answer_staleness(project_id, diagnostics=diagnostics.get("local_index")) + return apply_local_staleness(response, staleness) def recent_memory(args): @@ -13417,7 +13876,10 @@ def get_memory_context(args): "until the managed local projection refresh completes.", ) result = local_memory_context(args, seed_ids) - return finalize_memory_context(args, result, seed_ids) + return apply_local_staleness( + finalize_memory_context(args, result, seed_ids), + local_answer_staleness(resolve_project_id(args)), + ) except Exception as exc: if mode == "local": raise @@ -13577,7 +14039,9 @@ def move_folder(args): current_folder = node.get("folder_path") or "/" next_folder = to_folder if current_folder == from_folder else current_folder.replace(f"{from_folder}/", f"{to_folder}/", 1) payload = {"folder_path": None if next_folder == "/" else next_folder} - saved = request_json("PATCH", f"/v1/projects/{project_id}/nodes/{node['id']}", payload, extra_headers=headers) + node_path = f"/v1/projects/{project_id}/nodes/{node['id']}" + saved = request_json("PATCH", node_path, payload, + extra_headers={**headers, **node_write_idempotency_headers("PATCH", node_path)}) index_node(saved) updated.append(node_write_reference(saved)) finally: @@ -13594,11 +14058,22 @@ def bulk_upsert_nodes(args): if args.get("dry_run"): paths = [normalize_node_path(item.get("path")) for item in nodes if item.get("path")] return {"ok": True, "changed": False, "dry_run": True, "count": len(paths), "paths": paths, "items": []} + # Resolve every per-item key before acquiring the lock so an invalid key + # cannot leave a partial bulk write behind. + base_key = args.get("idempotency_key") + if base_key is not None: + base_key = resolve_idempotency_key(base_key) + item_keys = [ + resolve_idempotency_key(item.get("idempotency_key")) if isinstance(item, dict) and item.get("idempotency_key") is not None + else (f"{base_key}:{index}" if base_key else resolve_idempotency_key()) + for index, item in enumerate(nodes) + ] lock = acquire_project_lock({"scope_type": "project", "scope_key": "*", "purpose": args.get("purpose", "bulk upsert nodes")}, project_id=project_id) results = [] try: headers = lock_headers(lock) - for item in nodes: + for item, idempotency_key in zip(nodes, item_keys): + write_headers = {**headers, **idempotency_headers(idempotency_key)} path = item.get("path") existing = None if item.get("node_id") or item.get("id") or item.get("uri"): @@ -13611,14 +14086,14 @@ def bulk_upsert_nodes(args): payload = node_payload_from_args(item, existing=existing) payload = encrypt_node_payload_for_write(project_id, payload, existing=existing) if existing: - saved = request_json("PATCH", f"/v1/projects/{project_id}/nodes/{existing['id']}", payload, extra_headers=headers) + saved = request_json("PATCH", f"/v1/projects/{project_id}/nodes/{existing['id']}", payload, extra_headers=write_headers) created = False else: - saved = request_json("POST", f"/v1/projects/{project_id}/nodes", payload, extra_headers=headers) + saved = request_json("POST", f"/v1/projects/{project_id}/nodes", payload, extra_headers=write_headers) created = True index_node(saved) node = decrypt_node_for_return(saved, project_id=project_id) - results.append({"created": created, **node_write_reference(node)}) + results.append({"created": created, **node_write_reference(node), "idempotency_key": idempotency_key}) finally: cleanup_project_lock(project_id, lock) return {"ok": True, "changed": bool(results), "dry_run": False, "count": len(results), "paths": [item.get("path") for item in results if item.get("path")], "items": results} @@ -14695,6 +15170,24 @@ def kanban_workflow_tool_schemas(): {"name": "nap_kanban_get_done", "description": "Get node-backed Kanban cards in /kanban/done.", "inputSchema": {**column_read_schema, "properties": {**column_read_schema["properties"], "active_only": {"type": "boolean", "default": False}}}}, {"name": "nap_kanban_get_blocked", "description": "Get blocked node-backed Kanban cards across columns.", "inputSchema": column_read_schema}, {"name": "nap_kanban_pick_next", "description": "Pick the next unblocked Kanban card from pending work by column, priority, and rank.", "inputSchema": compact_read_schema}, + { + "name": "nap_kanban_claim_next", + "description": "Atomically claim the next unblocked Kanban card (pick-next order) with a fresh nap_operation lease. Cards held by another running operation are skipped; any other failure stops and returns the attempted operation id to reconcile with nap_operation get. Never retries a claim.", + "inputSchema": { + "type": "object", + "required": ["holder"], + "properties": { + "holder": {"type": "string", "description": "Stable label for the executing agent/session."}, + "ttl_seconds": {"type": "integer", "minimum": 30, "maximum": 900, "default": 300}, + "max_candidates": {"type": "integer", "minimum": 1, "maximum": KANBAN_CLAIM_NEXT_MAX_CANDIDATES, "default": KANBAN_CLAIM_NEXT_DEFAULT_CANDIDATES}, + "q": {"type": "string"}, + "include_blocked": {"type": "boolean", "default": False}, + "include_backlog": {"type": "boolean", "default": True}, + "dependency_view": {"type": "string", "enum": KANBAN_DEPENDENCY_VIEWS}, + }, + "additionalProperties": False, + }, + }, { "name": "nap_kanban_create", "description": "Create a normal node-backed Kanban card under /kanban//.", @@ -14736,6 +15229,7 @@ def coordination_file_tools(): def spec(name, description, properties, required=()): return {"name": name, "description": description, "inputSchema": {"type": "object", "properties": properties, "required": list(required), "additionalProperties": False}} return [ + spec("nap_changes_since", "Read project node changes (created/updated/archived/restored/deleted) since this worktree's stored cursor. The first call starts at head without replaying history. The cursor advances only after success; failures never fall back to the local index.", {"after": {"type": "string", "description": "Opaque cursor from a previous result, or 'head'. Overrides the stored cursor."}, "limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 100}, "advance": {"type": "boolean", "default": True}, "reset": {"type": "boolean", "default": False}}), spec("nap_operation_list", "Reconcile durable execution claims and outcomes after reconnect. Expired leases are reclaimable; pagination is explicit.", {"cursor": {"type":"integer"}, "limit": {"type":"integer","maximum":100}, "active_only":{"type":"boolean"}}), spec("nap_operation", "Claim work with a unique UUID id, then heartbeat every minute. Read the id after an uncertain result. Expired claims cannot finish; claim anew. Heartbeat can link progress and blockers with evidence_node_id. Success requires evidence_node_id. This execution lease does not replace guarded node writes or authorize external actions.", {"action":{"enum":["claim","get","heartbeat","succeed","fail","cancel"]},"id":string,"node_id":string,"holder":string,"ttl_seconds":{"type":"integer","minimum":30,"maximum":900},"evidence_node_id":string}, ["action","id"]), spec("nap_file_list", "List bounded file metadata and attachment identities; file bytes are never returned as tool text.", {"node_id":string,"cursor":string,"limit":{"type":"integer","maximum":100}}), @@ -14759,6 +15253,148 @@ def operation_tool(args): return request_json("POST", f"{root}/{identity}", {"action": action, "ttl_seconds": args.get("ttl_seconds",300), "evidence_node_id":args.get("evidence_node_id")}) +CHANGES_CURSOR_SCHEMA = "napseer.changes_cursor.v1" +CHANGES_DEFAULT_LIMIT = 100 +CHANGES_MAX_LIMIT = 200 +CHANGES_CURSOR_MAX_LENGTH = 512 + + +def changes_cursor_path(): + # Private per-worktree state; never inside the checkout. + return AUTH_DIR / "changes-cursor.json" + + +def read_changes_cursor(project_id): + try: + record = json.loads(changes_cursor_path().read_text(encoding="utf-8")) + except Exception: + return None + if ( + not isinstance(record, dict) + or record.get("schema") != CHANGES_CURSOR_SCHEMA + or record.get("project_id") != project_id + or not isinstance(record.get("cursor"), str) + or not record["cursor"] + ): + return None + return record["cursor"] + + +def write_changes_cursor(project_id, cursor): + AUTH_DIR.mkdir(parents=True, exist_ok=True) + chmod_best_effort(AUTH_DIR, 0o700) + path = changes_cursor_path() + temporary = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp") + try: + temporary.write_text(json.dumps({ + "schema": CHANGES_CURSOR_SCHEMA, + "project_id": project_id, + "cursor": cursor, + "updated_at": iso_now(), + }, sort_keys=True) + "\n", encoding="utf-8") + chmod_best_effort(temporary, 0o600) + os.replace(temporary, path) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def clear_changes_cursor(): + try: + changes_cursor_path().unlink() + except FileNotFoundError: + pass + + +def changes_since(args): + """Read the project change feed from a per-worktree cursor. + + The cursor is opaque. It advances only after a successful response, and + a failure never falls back to the local index. + """ + args = dict(args or {}) + project_id = resolve_project_id(args) + limit = normalize_int(args.get("limit"), CHANGES_DEFAULT_LIMIT, 1, CHANGES_MAX_LIMIT) + advance = as_bool(args.get("advance"), True) + reset = as_bool(args.get("reset"), False) + explicit = args.get("after") + if explicit is not None and ( + not isinstance(explicit, str) or not explicit.strip() or len(explicit) > CHANGES_CURSOR_MAX_LENGTH + ): + raise SafeToolError("invalid_arguments", "after must be a non-empty cursor string or 'head'.") + if reset: + clear_changes_cursor() + after, cursor_source = "head", "reset" + elif explicit is not None: + after, cursor_source = explicit.strip(), "argument" + else: + stored = read_changes_cursor(project_id) + after, cursor_source = (stored, "stored") if stored else ("head", "initial_head") + + auth_problem = current_auth_problem() + if auth_problem: + error = SafeToolError("auth_required", auth_problem["message"], status=401) + error.details = {"auth_problem": auth_problem, "after": after, "cursor_advanced": False} + raise error + query = rest_query_string({"after": after, "limit": limit}) + try: + response = request_json("GET", f"/v1/projects/{project_id}/changes?{query}") + except SafeToolError as exc: + details = {"after": after, "cursor_source": cursor_source, "cursor_advanced": False} + if exc.status == 404: + error = SafeToolError( + "unsupported_server", + "This Napseer server does not provide the change feed (HTTP 404 on /changes). " + "Update the server; the stored cursor was not changed.", + status=404, + service_code=exc.service_code, + ) + error.details = details + raise error from exc + if exc.status == 400: + error = SafeToolError( + "invalid_changes_cursor", + "The server rejected the change cursor. Pass a cursor returned by nap_changes_since, " + "or use reset=true to restart at head (skipping unread history).", + status=400, + service_code=exc.service_code, + ) + error.details = details + raise error from exc + if server_fallback_code(exc) == "auth_required": + details["auth_problem"] = current_auth_problem() + exc.details = {**(getattr(exc, "details", None) or {}), **details} + raise + if not isinstance(response, dict) or not isinstance(response.get("items", []), list): + raise SafeToolError("malformed_response", "The change feed returned an unexpected shape; the cursor was not changed.") + next_cursor = response.get("next_cursor") + advanced = False + if advance and isinstance(next_cursor, str) and next_cursor: + write_changes_cursor(project_id, next_cursor) + advanced = True + result = { + "ok": True, + "project_id": project_id, + "after": after, + "cursor_source": cursor_source, + "items": list(response.get("items") or []), + "next_cursor": next_cursor, + "has_more": bool(response.get("has_more")), + "cursor_advanced": advanced, + } + if cursor_source in {"initial_head", "reset"}: + result["started_at_head"] = True + result["message"] = ( + "Started at the current head: earlier history is not replayed. " + "Call again to receive changes made after this point." + ) + elif result["has_more"]: + result["next_action"] = "Call nap_changes_since again to read the next page." + return result + + def export_project(args): """A portable, checked active-project snapshot; never contains login data.""" import zipfile @@ -15557,6 +16193,7 @@ def raw_tools(): "properties": { "purpose": {"type": "string"}, "dry_run": {"type": "boolean", "default": False}, + "idempotency_key": {"type": "string", "description": "Optional base key; item N without its own key sends ':N'. Reuse the returned keys to retry after an ambiguous failure."}, "nodes": { "type": "array", "items": { @@ -15571,6 +16208,7 @@ def raw_tools(): "aliases": {"type": "array", "items": {"type": "string"}}, "links": {"type": "array", "items": {"type": "object"}}, "metadata": {"type": "object"}, + "idempotency_key": {"type": "string", "description": "Optional per-item Idempotency-Key; generated when omitted and returned per item."}, }, "additionalProperties": False, }, @@ -15618,6 +16256,7 @@ def raw_tools(): }, }, "metadata": {"type": "object"}, + "idempotency_key": {"type": "string", "description": "Optional Idempotency-Key. Generated when omitted and returned; resend the same key to retry an ambiguous create safely."}, }, "additionalProperties": True, }, @@ -15803,7 +16442,7 @@ def raw_tools(): "alias_ops": {"type": "object"}, "content_op": {"type": "object"}, "dry_run": {"type": "boolean", "default": False}, - "idempotency_key": {"type": "string"}, + "idempotency_key": {"type": "string", "description": "Sent as the Idempotency-Key header. Generated when omitted and returned; resend the same key to retry an ambiguous patch safely."}, "allow_dangling_links": {"type": "boolean", "default": False}, }, "additionalProperties": False, @@ -16230,6 +16869,7 @@ TOOL_CATEGORIES = { "nap_plan_cancel", "nap_plan_to_kanban", "nap_kanban_create", + "nap_kanban_claim_next", "nap_kanban_start", "nap_kanban_send_review", "nap_kanban_complete", @@ -16294,7 +16934,7 @@ def tool_category(name): READ_ONLY_TOOLS = { - "nap_operation_list", "nap_file_list", + "nap_operation_list", "nap_file_list", "nap_changes_since", "nap_whoami", "nap_gateway_status", "nap_chat_secret_status", @@ -16515,6 +17155,7 @@ def contract_profile(args=None): CANONICAL_TOOL_NAMES = { + "nap_changes_since", "nap_operation_list", "nap_operation", "nap_file_list", @@ -16551,6 +17192,7 @@ CANONICAL_TOOL_NAMES = { "nap_plan_transition", "nap_kanban_list", "nap_kanban_pick_next", + "nap_kanban_claim_next", "nap_kanban_create", "nap_kanban_start", "nap_kanban_send_review", @@ -16760,6 +17402,7 @@ TOOL_CATEGORIES.update( "project": ["nap_whoami", "nap_auth_refresh"], "memory": [ "nap_operation_list", "nap_operation", "nap_file_list", "nap_file_upload", "nap_file_download", "nap_file_archive", + "nap_changes_since", "nap_discover", "nap_context", "nap_node_by_path", @@ -16783,6 +17426,7 @@ TOOL_CATEGORIES.update( "kanban": [ "nap_kanban_list", "nap_kanban_pick_next", + "nap_kanban_claim_next", "nap_kanban_create", "nap_kanban_start", "nap_kanban_send_review", @@ -16822,6 +17466,7 @@ TOOL_CATEGORIES.update( ) CORE_TOOL_NAMES = { + "nap_changes_since", "nap_operation_list", "nap_operation", "nap_file_list", @@ -16884,14 +17529,72 @@ REMOVED_TOOL_REPLACEMENTS = { CONFIGURED_TOOL_PROFILES = None -def configured_tool_profiles(): - configured = CONFIGURED_TOOL_PROFILES - if configured is None: - configured = { - value.strip().lower() - for value in str(os.environ.get("NAPSEER_TOOL_PROFILES") or "").split(",") - if value.strip() - } +TOOL_PROFILE_LOCATOR_MAX_ITEMS = 16 + + +def locator_tool_profiles(): + """Return (profiles, declared, warnings) from the committed project locator. + + Unknown or malformed entries are ignored with a warning; a locator that + cannot be read yields no profiles here (identity errors surface elsewhere). + """ + try: + locator = load_project_locator() + except SafeToolError as exc: + return set(), False, [{"code": "project_locator_unreadable", "message": exc.safe_message}] + if not locator or "tool_profiles" not in locator: + return set(), False, [] + raw = locator["tool_profiles"] + if not isinstance(raw, list): + return set(), True, [{ + "code": "invalid_tool_profiles", + "message": ".napseer/project.json tool_profiles must be an array of profile names; it was ignored.", + }] + known = {"all"} | set(TOOL_PROFILES) + profiles = set() + unknown = [] + for value in raw[:TOOL_PROFILE_LOCATOR_MAX_ITEMS]: + name = value.strip().lower() if isinstance(value, str) else None + if name in known: + profiles.add(name) + else: + unknown.append(str(value)[:40]) + warnings = [] + if unknown: + warnings.append({ + "code": "unknown_tool_profile", + "message": ".napseer/project.json tool_profiles contains unknown profile names that were ignored. Supported profiles: " + + ", ".join(sorted(TOOL_PROFILES)) + ".", + "ignored": unknown, + }) + if len(raw) > TOOL_PROFILE_LOCATOR_MAX_ITEMS: + warnings.append({ + "code": "tool_profiles_truncated", + "message": f".napseer/project.json tool_profiles was truncated to {TOOL_PROFILE_LOCATOR_MAX_ITEMS} entries.", + }) + return profiles, True, warnings + + +def tool_profile_configuration(): + """Return (profiles, source, warnings). + + Precedence: in-process override, then a non-empty NAPSEER_TOOL_PROFILES, + then the committed locator's optional tool_profiles, then core only. + """ + if CONFIGURED_TOOL_PROFILES is not None: + configured = set(CONFIGURED_TOOL_PROFILES) + source = "override" + warnings = [] + else: + raw_env = str(os.environ.get("NAPSEER_TOOL_PROFILES") or "") + if raw_env.strip(): + configured = {value.strip().lower() for value in raw_env.split(",") if value.strip()} + source = "environment" + warnings = [] + else: + configured, declared, warnings = locator_tool_profiles() + source = "project_locator" if declared else "default" + return configured, source, warnings unknown = set(configured) - ({"all"} | set(TOOL_PROFILES)) if unknown: raise SafeToolError( @@ -16899,7 +17602,11 @@ def configured_tool_profiles(): "Unknown NAPSEER_TOOL_PROFILES value. Supported profiles: " + ", ".join(sorted(TOOL_PROFILES)), ) - return set(configured) + return configured, source, warnings + + +def configured_tool_profiles(): + return set(tool_profile_configuration()[0]) def tool_profile(name): @@ -16931,6 +17638,25 @@ _base_tool_contract_metadata = tool_contract_metadata def safe_http_error(exc, *, operation="request", body_text=""): + mapped = _safe_http_error(exc, operation=operation, body_text=body_text) + # Keep the service's own message/details on the exception for callers + # that must distinguish conflicts (for example operation_held). They are + # not added to safe payloads or tool text. + mapped.service_message = "" + mapped.service_details = {} + try: + payload = json.loads(body_text) if body_text else {} + error = payload.get("error") if isinstance(payload, dict) else {} + if isinstance(error, dict): + mapped.service_message = str(error.get("message") or "")[:300] + if isinstance(error.get("details"), dict): + mapped.service_details = error["details"] + except (TypeError, ValueError): + pass + return mapped + + +def _safe_http_error(exc, *, operation="request", body_text=""): status = int(getattr(exc, "code", 0) or 0) service_code = "" try: @@ -17012,6 +17738,8 @@ def safe_exception_payload(exc): payload["http_status"] = exc.status if isinstance(exc, SafeToolError) and exc.service_code: payload["service_code"] = exc.service_code + if isinstance(exc, SafeToolError) and isinstance(getattr(exc, "details", None), dict): + payload["details"] = exc.details return payload @@ -17090,10 +17818,14 @@ def tool_contract_metadata(name): metadata = _base_tool_contract_metadata(name) if name == "nap_operation": metadata.update(lock_policy="atomic-execution-lease", idempotency="reconcile-same-run-id") + if name == "nap_kanban_claim_next": + metadata.update(lock_policy="atomic-execution-lease", idempotency="reconcile-returned-operation-id") if name == "nap_file_upload": metadata.update(lock_policy="immutable-file-id", idempotency="same-id-and-bytes") if name == "nap_file_archive": metadata.update(idempotency="safe") + if name in IDEMPOTENCY_KEY_TOOLS: + metadata.update(idempotency="same-idempotency-key") if name in {"nap_batch", "nap_plan_transition"}: metadata["dry_run_supported"] = True if name == "nap_batch": @@ -17118,10 +17850,13 @@ def contract_profile(args=None): "names and explicit aliases only." ), } + active_profiles, profile_source, profile_warnings = tool_profile_configuration() profile["tool_profiles"] = { "default": "core", - "active": sorted(configured_tool_profiles()), - "configuration": "NAPSEER_TOOL_PROFILES", + "active": sorted(active_profiles), + "source": profile_source, + "warnings": profile_warnings, + "configuration": "NAPSEER_TOOL_PROFILES, else .napseer/project.json tool_profiles", "available": { name: sorted(tool_names) for name, tool_names in TOOL_PROFILES.items() }, @@ -17636,6 +18371,13 @@ def all_tools(): ) if isinstance(item["inputSchema"], dict): item["inputSchema"]["additionalProperties"] = False + if canonical_name in SCOPED_IDEMPOTENCY_KEY_TOOLS and isinstance(item["inputSchema"], dict): + properties = dict(item["inputSchema"].get("properties") or {}) + properties.setdefault("idempotency_key", { + "type": "string", + "description": "Optional Idempotency-Key for this call's node writes (first write sends it, later writes ':'). Generated when omitted and returned; resend it to retry an ambiguous failure safely.", + }) + item["inputSchema"] = {**item["inputSchema"], "properties": properties} if canonical_name == "nap_node_patch": item["inputSchema"] = _node_patch_schema(item["inputSchema"]) if canonical_name == "nap_auth_refresh": @@ -17719,16 +18461,21 @@ def explore_tools(args): card["profile"] = tool_profile(name) grouped.setdefault(tool_category_name, []).append(card) grouped = {key: value[:limit] for key, value in grouped.items() if value} - return { + active_profiles, profile_source, profile_warnings = tool_profile_configuration() + result = { "contract_version": CONTRACT_VERSION, "categories": list(TOOL_CATEGORIES.keys()), "tools_are_flat_in_mcp": True, "include_schemas": include_schemas, - "active_profiles": sorted(configured_tool_profiles()), + "active_profiles": sorted(active_profiles), + "profile_source": profile_source, "default_profile": "core", "metadata_fields": contract_profile({"include_tools": False})["metadata_fields"], "groups": grouped, } + if profile_warnings: + result["warnings"] = profile_warnings + return result def man_tool(args): @@ -17919,7 +18666,7 @@ def local_anchor_integrity(): def doctor(args): - return { + report = { "ok": True, "auth": gateway_status(), "index": index_status(args), @@ -17927,9 +18674,42 @@ def doctor(args): "contract": contract_profile({"include_tools": False}), "state_dir": state_dir_status_payload(), } + profile_warnings = report["contract"].get("tool_profiles", {}).get("warnings") or [] + if profile_warnings: + report["warnings"] = list(profile_warnings) + return report def call_tool_impl(name, args): + canonical_name = COMPATIBILITY_TOOL_ALIASES.get(name, name) + if canonical_name not in IDEMPOTENCY_KEY_TOOLS or not isinstance(args, dict): + return _call_tool_impl_dispatch(name, args) + args = dict(args) + raw_key = args.pop("idempotency_key", None) + base_key = resolve_idempotency_key(raw_key) + if canonical_name in EXPLICIT_IDEMPOTENCY_KEY_TOOLS: + # These tools send and return the key themselves; supplying it here + # keeps it known if the call fails ambiguously. + args["idempotency_key"] = base_key + scope = NodeWriteIdempotencyScope(base_key) + try: + with scope: + result = _call_tool_impl_dispatch(name, args) + except SafeToolError as exc: + if canonical_name in EXPLICIT_IDEMPOTENCY_KEY_TOOLS or scope.keys: + details = dict(getattr(exc, "details", None) or {}) + details.setdefault("idempotency_key", base_key) + details.setdefault("retry", "Resend the same arguments with this idempotency_key after checking current state.") + exc.details = details + raise + if canonical_name in SCOPED_IDEMPOTENCY_KEY_TOOLS and scope.keys and isinstance(result, dict): + result = {**result, "idempotency_key": base_key} + if len(scope.keys) > 1: + result["idempotency_keys"] = list(scope.keys) + return result + + +def _call_tool_impl_dispatch(name, args): args = args or {} if name in REMOVED_TOOL_REPLACEMENTS: raise SafeToolError( @@ -17950,7 +18730,8 @@ def call_tool_impl(name, args): raise SafeToolError( "tool_profile_required", f"{canonical_name} requires the '{profile}' tool profile. Set " - f"NAPSEER_TOOL_PROFILES={profile} (or include it in the comma-separated list) and restart the MCP server.", + f"NAPSEER_TOOL_PROFILES={profile} (or include it in the comma-separated list) and restart the MCP server, " + f"or add it to tool_profiles in .napseer/project.json.", ) if name not in PUBLIC_TOOLS and name in PROTECTED_TOOLS: require_unlocked(name) @@ -17959,6 +18740,8 @@ def call_tool_impl(name, args): "invalid_view", "Full bodies are available only through nap_node_get after resolving a node identity.", ) + if name == "nap_changes_since": + return changes_since(args) if name == "nap_operation_list": return request_json("GET", f"/v1/projects/{resolve_project_id({})}/operations?" + rest_query_string(args)) if name == "nap_operation": @@ -18049,6 +18832,8 @@ def call_tool_impl(name, args): return get_blocked_kanban_cards(args) if name == "nap_kanban_pick_next": return pick_next_kanban_card(args) + if name == "nap_kanban_claim_next": + return claim_next_kanban_card(args) if name == "nap_bulk_read": return bulk_read_nodes(args) if name == "nap_next_work": @@ -18086,28 +18871,7 @@ def call_tool_impl(name, args): if name == "nap_project_create": return create_project_from_args(args) if name == "nap_create_node": - project_id = resolve_project_id(args) - folder_path = args.get("folder_path", "/agent/memory") - reject_agent_namespace_path(f"{folder_path}/{args['name']}") - payload = { - "folder_path": folder_path, - "name": args["name"], - "type": args.get("type", "note"), - "tags": args.get("tags", []), - "aliases": args.get("aliases", []), - "links": args.get("links", []), - "metadata": args.get("metadata", {}), - "content_text": args["content_text"], - } - node = request_project_write( - "POST", - f"/v1/projects/{project_id}/nodes", - encrypt_node_payload_for_write(project_id, payload), - project_id, - f"create node {folder_path}/{args['name']}", - ) - index_node(node) - return decrypt_node_for_return(node, project_id=project_id) + return create_node(args) if name == "nap_tee": return upsert_node(args) if name == "nap_patch": diff --git a/tests/test_mcp_memory_ergonomics_helpers.py b/tests/test_mcp_memory_ergonomics_helpers.py index b49b1a7..713145e 100644 --- a/tests/test_mcp_memory_ergonomics_helpers.py +++ b/tests/test_mcp_memory_ergonomics_helpers.py @@ -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") diff --git a/tests/test_mcp_memory_surface_efficiency.py b/tests/test_mcp_memory_surface_efficiency.py index 05e1cc3..5323b25 100644 --- a/tests/test_mcp_memory_surface_efficiency.py +++ b/tests/test_mcp_memory_surface_efficiency.py @@ -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", ] diff --git a/tests/test_mcp_reliability_contracts.py b/tests/test_mcp_reliability_contracts.py new file mode 100644 index 0000000..ae9df49 --- /dev/null +++ b/tests/test_mcp_reliability_contracts.py @@ -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")