diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index cf8e6a4..2845268 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -66,7 +66,7 @@ DISCOVERY_COMPACT_MAX_LIMIT = 10_000 DISCOVERY_FULL_MAX_LIMIT = 10_000 DISCOVERY_SOURCE_PAGE_LIMIT = 10_000 DISCOVERY_CURSOR_GUARD_PAGES = 100 -ACTIVE_EXCLUDED_STATUSES = { +ACTIVE_EXCLUDED_LIFECYCLE_STATUSES = { "completed", "complete_verified_uncommitted", "complete_pending_human_e2e", @@ -75,6 +75,131 @@ ACTIVE_EXCLUDED_STATUSES = { "archived", "done", } + +# Node type/status vocabulary. This block mirrors napseer's API vocabulary +# module (src/vocab.rs; decision /decisions/2026-09-29-node-type-status-vocabulary); +# keep the two in step. Stored records are never rewritten: a create without a +# type gets the folder's type, and local filters/rows use the *effective* type. +CANONICAL_NODE_TYPES = frozenset({ + "plan", + "decision", + "implementation_note", + "deployment", + "documentation", + "product_spec", + "runbook", + "rule", + "index", + "analysis", + "kanban_card", + "anchor_route", + "note", +}) +# Folder prefixes whose generic notes take a specific type. A prefix matches +# the folder itself and anything below it (whole path segments, longest wins). +FOLDER_NODE_TYPES = ( + ("/plans", "plan"), + ("/decisions", "decision"), + ("/product/decisions", "decision"), + ("/implementation-notes", "implementation_note"), + ("/deployments", "deployment"), + ("/runbooks", "runbook"), + ("/rules", "rule"), + ("/indexes", "index"), + ("/analysis", "analysis"), + ("/product/analysis", "analysis"), + ("/documentation", "documentation"), + ("/kanban", "kanban_card"), +) +# Release-progress statuses are terminal too. They stay apart from the +# lifecycle set (the API keeps them out of its Kanban lifecycle list) and do +# not demote search ranking; active-only filters exclude both sets. +ACTIVE_EXCLUDED_RELEASE_STATUSES = frozenset({ + "deployed", + "released", + "released_verified_production", + "folded", +}) +ACTIVE_EXCLUDED_STATUSES = frozenset(ACTIVE_EXCLUDED_LIFECYCLE_STATUSES | ACTIVE_EXCLUDED_RELEASE_STATUSES) + + +def canonical_node_type(value): + """Spelling-only canonical form: trimmed, lowercase, '-' and ' ' become '_'.""" + return str(value or "").strip().lower().replace("-", "_").replace(" ", "_") + + +def normalize_type_folder(folder_path): + """A folder as the server stores it (lowercase slug segments), for type rules.""" + segments = [] + for segment in str(folder_path or "").split("/"): + segment = re.sub(r"[^a-z0-9._]+", "-", segment.strip().lower()).strip("-") + if segment: + segments.append(segment) + return "/" + "/".join(segments) if segments else None + + +def folder_node_type(folder_path): + """The type a folder implies for a node that does not name one, or None.""" + folder = str(folder_path or "") + matches = [ + (prefix, node_type) + for prefix, node_type in FOLDER_NODE_TYPES + if folder == prefix or folder.startswith(f"{prefix}/") + ] + return max(matches, key=lambda match: len(match[0]))[1] if matches else None + + +def effective_node_type(node_type, folder_path): + """Canonical stored type, except a generic note resolves from its folder.""" + canonical = canonical_node_type(node_type) + if canonical in ("", "note"): + return folder_node_type(folder_path) or "note" + return canonical + + +def explicit_node_type(value): + """A caller's type: known spelling variants canonicalize; custom types stay as given.""" + canonical = canonical_node_type(value) + return canonical if canonical in CANONICAL_NODE_TYPES else value + + +def create_node_type(value, folder_path): + """Type to send on create; None leaves the server default (no folder rule).""" + if value not in (None, ""): + return explicit_node_type(value) + return folder_node_type(normalize_type_folder(folder_path)) + + +def node_effective_type(node): + """Effective type of a node or row, or None when the row carries no type.""" + if node.get("effective_type") not in (None, ""): + return node["effective_type"] + node_type = node.get("type") if "type" in node else node.get("node_type") + if node_type is None: + return None + folder = node.get("folder_path") + if folder is None and node.get("full_path"): + folder = str(node["full_path"]).rsplit("/", 1)[0] or "/" + return effective_node_type(node_type, folder) + + +def requested_node_types(value): + """Canonical type filter values (a string, comma list, or list).""" + return unique_preserve_order( + canonical_node_type(item) for item in normalize_string_list(value) if canonical_node_type(item) + ) + + +def apply_type_filter(nodes, value): + """Keep rows whose effective type was requested. Older servers match the + stored type, so a `note` filter would also return notes whose folder + implies another type; rows without a type are left to the server.""" + types = set(requested_node_types(value)) + if not types: + return nodes + return [node for node in nodes if node_effective_type(node) in types | {None}] + + KNOWN_LIFECYCLE_STATUSES = { "active", "archived", @@ -6813,7 +6938,7 @@ def status_filter_from_args(args): active_only = as_bool(args.get("active_only"), False) warnings = [] for status in statuses + excluded: - if status not in KNOWN_LIFECYCLE_STATUSES: + if status not in KNOWN_LIFECYCLE_STATUSES and status not in ACTIVE_EXCLUDED_RELEASE_STATUSES: warnings.append(f"unknown metadata.status filter: {status}") return {"status": statuses, "exclude_status": excluded, "active_only": active_only, "warnings": warnings} @@ -6914,6 +7039,13 @@ def metadata_summary(node): return {key: metadata[key] for key in unique_preserve_order(keys) if key in metadata} +def reported_effective_type(node): + """effective_type for a compact row: only when it differs from the stored + type, so correctly typed rows keep their existing compact shape.""" + effective = node_effective_type(node) + return effective if effective != node.get("type") else None + + def summarize_node(node, view="summary", preview_chars=240): node_id = node.get("node_id") or node.get("id") if view == "full": @@ -6932,6 +7064,7 @@ def summarize_node(node, view="summary", preview_chars=240): "node_id": node_id, "full_path": node.get("full_path"), "type": node.get("type"), + "effective_type": reported_effective_type(node), "status": status, "date": node_date(node), "created_at": node.get("created_at"), @@ -6947,6 +7080,7 @@ def summarize_node(node, view="summary", preview_chars=240): "project_id": node.get("project_id"), "full_path": node.get("full_path"), "type": node.get("type"), + "effective_type": reported_effective_type(node), "name": node.get("name"), "folder_path": node.get("folder_path"), "tags": node.get("tags") or [], @@ -7467,15 +7601,37 @@ def ensure_sqlite_column(conn, table, column, declaration): conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {declaration}") +def register_index_functions(conn): + """SQL helpers so local type filters use the one Python vocabulary.""" + for name, arity, function in ( + ("nap_effective_type", 2, effective_node_type), + ): + try: + conn.create_function(name, arity, function, deterministic=True) + except sqlite3.NotSupportedError: + conn.create_function(name, arity, function) + + def open_index_connection(): AUTH_DIR.mkdir(parents=True, exist_ok=True) chmod_best_effort(AUTH_DIR, 0o700) conn = sqlite3.connect(str(INDEX_PATH), timeout=30) conn.row_factory = sqlite3.Row conn.execute("PRAGMA busy_timeout = 5000") + register_index_functions(conn) return conn +def local_index_type_clause(value, alias="n"): + """WHERE clause matching a type filter on effective types only: a generic + note whose folder implies another type does not match `note`.""" + types = requested_node_types(value) + if not types: + return None, [] + placeholders = ",".join("?" for _ in types) + return f"nap_effective_type({alias}.node_type, {alias}.folder_path) IN ({placeholders})", types + + def initialize_index_schema(conn): conn.execute("PRAGMA journal_mode = WAL") conn.execute( @@ -8390,7 +8546,9 @@ def node_matches_search_intent(node, intent): ) -SEARCH_DEMOTED_STATUSES = frozenset(ACTIVE_EXCLUDED_STATUSES | {"deprecated", "obsolete"}) +# Release statuses (deployed, released, ...) are terminal for active-only +# lists but often describe the current state, so they do not demote ranking. +SEARCH_DEMOTED_STATUSES = frozenset(ACTIVE_EXCLUDED_LIFECYCLE_STATUSES | {"deprecated", "obsolete"}) # Weighted relevance. Exact identity (the query is a node's name, title or # basename) is decisive; everything else is additive so that one strong # signal (an authoritative path) cannot hide a node that answers every term. @@ -8776,6 +8934,7 @@ def search_local_index(args): "title": row["title"], "folder_path": row["folder_path"], "type": row["node_type"], + "effective_type": effective_node_type(row["node_type"], row["folder_path"]), "tags": json.loads(row["tags_json"] or "[]"), "aliases_text": row["aliases_text"], "aliases": json.loads(row["aliases_json"] or "[]"), @@ -8880,9 +9039,10 @@ def local_index_filter_clauses(args, filters): if args.get("folder_path") not in (None, ""): where.append("n.folder_path = ?") parameters.append(str(args["folder_path"])) - if args.get("type") not in (None, ""): - where.append("n.node_type = ?") - parameters.append(str(args["type"])) + type_clause, type_parameters = local_index_type_clause(args.get("type")) + if type_clause: + where.append(type_clause) + parameters.extend(type_parameters) if args.get("tag") not in (None, ""): where.append( "EXISTS (SELECT 1 FROM local_index_node_tags t " @@ -10649,6 +10809,7 @@ def e2e_shard_search(project_id, conn, match_query, analysis, filter_where, filt "title": row["title"], "folder_path": row["folder_path"], "type": row["node_type"], + "effective_type": effective_node_type(row["node_type"], row["folder_path"]), "tags": json.loads(row["tags_json"] or "[]"), "aliases": json.loads(row["aliases_json"] or "[]"), "status": row["status"], @@ -13421,7 +13582,6 @@ def node_payload_from_args(args, existing=None): elif not existing: raise RuntimeError("path is required when creating a node") for source, target in [ - ("type", "type"), ("content_text", "content_text"), ("tags", "tags"), ("aliases", "aliases"), @@ -13430,8 +13590,14 @@ def node_payload_from_args(args, existing=None): ]: if source in args: payload[target] = args[source] - if "type" not in payload and not existing: - payload["type"] = "note" + if args.get("type") not in (None, ""): + payload["type"] = explicit_node_type(args["type"]) + elif not existing: + # A create without a type takes its folder's type; otherwise the + # server default applies. Updates never retype a stored node. + node_type = create_node_type(None, payload.get("folder_path")) + if node_type is not None: + payload["type"] = node_type if "content_text" not in payload and not existing: payload["content_text"] = "" if "tags" not in payload and not existing: @@ -13517,13 +13683,16 @@ def create_node(args): 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"], } + # Send the folder's type explicitly so older servers store typed nodes too. + node_type = create_node_type(args.get("type"), folder_path) + if node_type is not None: + payload["type"] = node_type node = request_project_write( "POST", f"/v1/projects/{project_id}/nodes", @@ -13763,9 +13932,10 @@ def browse_local_index(args, *, view, sort): if args.get("folder_path") not in (None, ""): where.append("n.folder_path = ?") parameters.append(str(args["folder_path"])) - if args.get("type") not in (None, ""): - where.append("n.node_type = ?") - parameters.append(str(args["type"])) + type_clause, type_parameters = local_index_type_clause(args.get("type")) + if type_clause: + where.append(type_clause) + parameters.extend(type_parameters) if args.get("tag") not in (None, ""): where.append( "EXISTS (SELECT 1 FROM local_index_node_tags t " @@ -13837,6 +14007,7 @@ def browse_local_index(args, *, view, sort): "_cursor_title_key": row["title_sort_key"], "folder_path": row["folder_path"], "type": row["node_type"], + "effective_type": effective_node_type(row["node_type"], row["folder_path"]), "tags": json.loads(row["tags_json"] or "[]"), "status": status, "metadata_summary": {"status": status} if status else {}, @@ -13952,6 +14123,7 @@ def list_project_nodes(args): result = request_json("GET", f"/v1/projects/{project_id}/nodes{suffix}") items = decrypt_nodes_for_return(filter_project_nodes(result.get("items", [])), project_id=project_id) items = apply_status_filters(items, filters) + items = apply_type_filter(items, base_query.get("type")) raw_q = str(base_query.get("q") or "").strip() query_analysis = result.get("query_analysis") diagnostics = result.get("diagnostics") @@ -16241,9 +16413,12 @@ def discover_memory(args): requested_facets = unique_preserve_order( str(value).strip().lower() for value in (args.get("facets") or []) if str(value).strip() ) - invalid_facets = sorted(set(requested_facets) - {"folder", "tag", "type", "status"}) + invalid_facets = sorted(set(requested_facets) - {"folder", "tag", "type", "effective_type", "status"}) if invalid_facets: - raise RuntimeError("facets must contain only folder, tag, type, or status") + raise RuntimeError("facets must contain only folder, tag, type, effective_type, or status") + if "type" in requested_facets and "effective_type" not in requested_facets: + # "type" keeps counting stored types; effective types ride alongside. + requested_facets.append("effective_type") if requested_facets: counts = {} for facet in requested_facets: @@ -16255,6 +16430,8 @@ def discover_memory(args): item_values = item.get("tags") or [] elif facet == "type": item_values = [item.get("type")] + elif facet == "effective_type": + item_values = [node_effective_type(item)] else: item_values = [item.get("status") or node_status(item)] for value in item_values: @@ -18734,7 +18911,7 @@ def raw_tools(): "intent": {"type": "string", "enum": ["any", "decision", "rule", "plan", "task", "implementation", "documentation"], "default": "any", "description": "Optional ranking intent. It boosts canonical records of that class without hard-filtering other matches."}, "folder_path": {"type": "string"}, "tag": {"type": "string"}, - "type": {"type": "string"}, + "type": {"type": "string", "description": "Matches effective types: the canonical stored type, except a generic note takes its folder's type (a note under /plans matches plan, not note). Rows keep the stored type and add effective_type when it differs."}, "updated_after": {"type": "string", "description": "RFC3339 timestamp or YYYY-MM-DD lower bound on updated_at."}, "updated_before": {"type": "string", "description": "RFC3339 timestamp or YYYY-MM-DD upper bound on updated_at."}, "since": {"type": "string", "description": "Alias for updated_after."}, @@ -19125,6 +19302,15 @@ def raw_tools(): "properties": { "folder_path": {"type": "string", "description": "Folder part of the canonical node path, for example /decisions or /incidents/2026-04-26."}, "name": {"type": "string", "description": "Human-readable node name. The server normalizes it into full_path."}, + "type": { + "type": "string", + "description": ( + "Optional. Canonical: " + ", ".join(sorted(CANONICAL_NODE_TYPES)) + + ". Spelling variants canonicalize; custom types are kept. When omitted, typed folders " + "decide (/plans -> plan, /decisions -> decision, /implementation-notes -> implementation_note, " + "/runbooks, /rules, /kanban, ...); other folders get note." + ), + }, "content_text": {"type": "string"}, "tags": {"type": "array", "items": {"type": "string"}}, "links": { @@ -20849,9 +21035,10 @@ def _efficient_memory_schema(name, schema): } properties["facets"] = { "type": "array", - "items": {"type": "string", "enum": ["folder", "tag", "type", "status"]}, + "items": {"type": "string", "enum": ["folder", "tag", "type", "effective_type", "status"]}, "uniqueItems": True, - "maxItems": 4, + "maxItems": 5, + "description": "Counts over returned rows. type counts stored types; effective_type (also added with type) counts folder-resolved types.", } elif name == "nap_context": for key in ("shortcuts", "paths", "node_ids", "uris"): diff --git a/tests/test_effective_node_types.py b/tests/test_effective_node_types.py new file mode 100644 index 0000000..defa28d --- /dev/null +++ b/tests/test_effective_node_types.py @@ -0,0 +1,397 @@ +"""Effective node types and release-terminal statuses in the CLI wrapper. + +Decision /decisions/2026-09-29-node-type-status-vocabulary: creates infer a +type from the folder when the caller omits one, type filters match effective +types (a generic `note` resolves from its folder), and active-only lists also +exclude release-style terminal statuses. Older APIs do none of this, so the +wrapper must behave the same on its own. Writes go through the real request +path to a fake transport; discovery runs against a disposable HTTP provider +and the real local index. +""" + +import copy +import importlib.util +import json +import pathlib +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlsplit + +import pytest + +SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py" + + +def load_module(name): + sys.path.insert(0, str(SCRIPT.parent)) + spec = importlib.util.spec_from_file_location(name, SCRIPT) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +# -- Writes ----------------------------------------------------------------- + + +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 + + +@pytest.fixture +def writer(monkeypatch, tmp_path): + monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "all") + mod = load_module("napseer_effective_types_write_test") + mod.DEFAULT_PROJECT_ID = "project-1" + mod.TOKEN = "synthetic-access" + mod.TOKEN_EXPIRES_AT = "2099-01-01T00:00:00Z" + mod.AUTH = {**mod.AUTH, "account_mode": "operator_account", "project_id": "project-1"} + mod.CONFIGURED_TOOL_PROFILES = {"all"} + mod.refresh_public_auth_state = lambda: mod.AUTH + mod.project_memory_encryption_active = lambda _project_id: False + mod.AUTH_DIR = tmp_path / "state" + mod.INDEX_PATH = tmp_path / "state" / "index.sqlite" + mod.INDEX_LOCK_PATH = tmp_path / "state" / "index.lock" + mod.resolve_project_id = lambda _args: "project-1" + mod.acquire_project_lock = lambda args=None, **kwargs: {"id": "lock-1", "lease_token": "lease-1"} + mod.cleanup_project_lock = lambda project_id, lock: None + sent = [] + + def fake_urlopen(request, timeout=None): + body = json.loads(request.data.decode("utf-8")) if request.data else None + sent.append((request.get_method(), urlsplit(request.full_url).path, body)) + folder = (body or {}).get("folder_path") or "/" + name = (body or {}).get("name") or "node" + return FakeResponse({ + "id": f"node-{len(sent)}", "project_id": "project-1", + "full_path": f"{folder.rstrip('/')}/{name}", "folder_path": folder, "name": name, + # The fake server applies the historical default; the payload is what is asserted. + "type": (body or {}).get("type") or "note", + "content_text": (body or {}).get("content_text", ""), "metadata": {}, + "updated_at": "2026-09-29T12:00:00Z", + }) + + mod.api_urlopen = fake_urlopen + return mod, sent + + +def created_payload(sent): + posts = [body for method, path, body in sent if method == "POST" and path.endswith("/nodes")] + assert len(posts) == 1, sent + return posts[0] + + +@pytest.mark.parametrize( + ("folder_path", "expected"), + [ + ("/plans", "plan"), + ("/plans/2026", "plan"), + ("/decisions", "decision"), + ("/product/decisions", "decision"), + ("/implementation-notes", "implementation_note"), + ("/deployments", "deployment"), + ("/runbooks", "runbook"), + ("/rules", "rule"), + ("/indexes", "index"), + ("/analysis", "analysis"), + ("/product/analysis", "analysis"), + ("/documentation/security", "documentation"), + ("/kanban/doing", "kanban_card"), + # The server normalizes folder segments before storing; inference agrees. + ("/Implementation Notes/", "implementation_note"), + ], +) +def test_create_without_type_sends_the_folder_type(writer, folder_path, expected): + mod, sent = writer + mod.call_tool_impl("nap_create_node", {"folder_path": folder_path, "name": "record", "content_text": "body"}) + assert created_payload(sent)["type"] == expected + + +@pytest.mark.parametrize("folder_path", ["/notes", "/plansx", "/product", "/services/plans"]) +def test_create_without_type_outside_typed_folders_leaves_the_server_default(writer, folder_path): + mod, sent = writer + mod.call_tool_impl("nap_create_node", {"folder_path": folder_path, "name": "record", "content_text": "body"}) + assert "type" not in created_payload(sent) + + +@pytest.mark.parametrize( + ("given", "expected"), + [ + ("implementation-note", "implementation_note"), + ("Kanban Card", "kanban_card"), + ("product-spec", "product_spec"), + ("decision", "decision"), + # An explicit generic note stays a note even inside a typed folder. + ("note", "note"), + # Unknown custom types are kept exactly as given. + ("security-documentation", "security-documentation"), + ("Custom Type", "Custom Type"), + ], +) +def test_create_with_explicit_type_only_canonicalizes_known_spellings(writer, given, expected): + mod, sent = writer + mod.call_tool_impl( + "nap_create_node", + {"folder_path": "/plans", "name": "record", "content_text": "body", "type": given}, + ) + assert created_payload(sent)["type"] == expected + + +def test_create_node_schema_advertises_optional_type(writer): + mod, _sent = writer + tool = next(tool for tool in mod.tools() if tool["name"] == "nap_create_node") + schema = tool["inputSchema"] + assert schema["properties"]["type"]["type"] == "string" + assert "type" not in schema.get("required", []) + + +def test_bulk_creates_infer_types_per_item(writer): + mod, sent = writer + mod.try_get_node_by_path = lambda path, allow_agent=False: None + mod.call_tool_impl("nap_bulk", {"nodes": [ + {"path": "/decisions/choose-store", "content_text": "a"}, + {"path": "/scratch/idea", "content_text": "b"}, + {"path": "/runbooks/restore", "content_text": "c", "type": "product-spec"}, + {"path": "/plans/custom", "content_text": "d", "type": "Custom Type"}, + ]}) + posts = [body for method, path, body in sent if method == "POST" and path.endswith("/nodes")] + assert [body.get("type") for body in posts] == ["decision", None, "product_spec", "Custom Type"] + assert "type" not in posts[1] + + +def test_bulk_update_without_type_does_not_retype_the_stored_node(writer): + mod, sent = writer + existing = {"id": "node-9", "project_id": "project-1", "full_path": "/plans/legacy", + "folder_path": "/plans", "name": "legacy", "type": "note", "content_text": "old"} + mod.get_node_by_id = lambda args: existing + mod.call_tool_impl("nap_bulk", {"nodes": [{"node_id": "node-9", "content_text": "new"}]}) + patches = [body for method, path, body in sent if method == "PATCH"] + assert patches and "type" not in patches[0] + + +# -- Local discovery --------------------------------------------------------- + + +def stored_node(folder, name, node_type, *, minute, status=None, content=None): + stamp = f"2026-09-01T12:{minute:02d}:00Z" + return { + "id": name, "project_id": "project-1", "full_path": f"{folder}/{name}", + "folder_path": folder, "name": name, "type": node_type, + "metadata": {"status": status} if status else {}, "tags": [], "aliases": [], "links": [], + "content_text": content or f"{name} vocabularyterm", "updated_at": stamp, "archived_at": None, + } + + +@pytest.fixture +def local(tmp_path, monkeypatch): + nodes = {} + events = [] + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def reply(self, status, payload): + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(payload).encode()) + + def do_GET(self): + url = urlsplit(self.path) + query = parse_qs(url.query) + if url.path.endswith("/changes"): + after = query.get("after", ["head"])[0] + start = len(events) if after == "head" else int(after[1:]) + return self.reply(200, {"items": [], "next_cursor": f"c{start}", "has_more": False}) + if url.path.endswith("/nodes"): + rows = sorted((copy.deepcopy(n) for n in nodes.values()), + key=lambda n: (n["updated_at"], n["id"]), reverse=True) + return self.reply(200, {"items": rows, "next_cursor": None}) + node_id = url.path.rsplit("/", 1)[-1] + if node_id not in nodes: + return self.reply(404, {"error": {"code": "not_found", "message": "missing"}}) + return self.reply(200, copy.deepcopy(nodes[node_id])) + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "all") + mod = load_module("napseer_effective_types_local_test") + mod.AUTH_DIR = tmp_path / "project-state" + mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite" + mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.lock" + mod.BASE_URL = f"http://127.0.0.1:{server.server_port}" + mod.TOKEN = "disposable-test-token" + mod.AUTH = {"account_id": "test-account"} + mod.DEFAULT_PROJECT_ID = "project-1" + mod.CONFIGURED_TOOL_PROFILES = {"all"} + mod.refresh_public_auth_state = lambda: None + mod.require_unlocked = lambda _name: None + mod.resolve_project_id = lambda args: args.get("project_id", "project-1") + mod.project_encryption_state = lambda _project_id: "standard" + + def seed(*items): + for item in items: + nodes[item["id"]] = copy.deepcopy(item) + events.append(item["id"]) + assert mod.reindex_project({})["graph_complete"] is True + + try: + yield mod, seed + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +TYPED_FIXTURE = [ + stored_node("/plans", "legacy-plan", "note", minute=1), + stored_node("/plans/2026", "nested-plan", "note", minute=2), + stored_node("/plans", "typed-plan", "plan", minute=3), + stored_node("/notes", "scratch", "note", minute=4), + stored_node("/implementation-notes", "dashed-note", "implementation-note", minute=5), + stored_node("/implementation-notes", "generic-note", "note", minute=6), + stored_node("/services", "misplaced-plan", "Plan", minute=7), + stored_node("/services", "custom", "security-documentation", minute=8), + # A differently spelled generic note still resolves from its folder. + stored_node("/decisions", "capital-note", "Note", minute=9), +] + + +def discover(mod, **args): + return mod.call_tool_impl("nap_discover", {"mode": "local", "limit": 50, **args}) + + +@pytest.mark.parametrize("q", [None, "vocabularyterm"]) +def test_local_type_filter_matches_effective_types(local, q): + mod, seed = local + seed(*TYPED_FIXTURE) + extra = {"q": q} if q else {} + + plans = discover(mod, type="plan", **extra) + by_id = {item["node_id"]: item for item in plans["items"]} + assert set(by_id) == {"legacy-plan", "nested-plan", "typed-plan", "misplaced-plan"} + # The stored type is reported unchanged; effective_type is added when it differs. + assert (by_id["legacy-plan"]["type"], by_id["legacy-plan"]["effective_type"]) == ("note", "plan") + assert (by_id["nested-plan"]["type"], by_id["nested-plan"]["effective_type"]) == ("note", "plan") + assert (by_id["misplaced-plan"]["type"], by_id["misplaced-plan"]["effective_type"]) == ("Plan", "plan") + assert by_id["typed-plan"]["type"] == "plan" + assert "effective_type" not in by_id["typed-plan"] + + notes = discover(mod, type="implementation_note", **extra) + by_id = {item["node_id"]: item for item in notes["items"]} + assert set(by_id) == {"dashed-note", "generic-note"} + assert by_id["dashed-note"]["type"] == "implementation-note" + assert {item["effective_type"] for item in notes["items"]} == {"implementation_note"} + + # A spelling variant in the filter canonicalizes the same way. + dashed = discover(mod, type="implementation-note", **extra) + assert {item["node_id"] for item in dashed["items"]} == {"dashed-note", "generic-note"} + + decisions = discover(mod, type="decision", **extra) + assert [item["node_id"] for item in decisions["items"]] == ["capital-note"] + assert (decisions["items"][0]["type"], decisions["items"][0]["effective_type"]) == ("Note", "decision") + + custom = discover(mod, type="security_documentation", **extra) + assert [item["node_id"] for item in custom["items"]] == ["custom"] + + +@pytest.mark.parametrize("q", [None, "vocabularyterm"]) +@pytest.mark.parametrize("requested", ["note", "Note", " NOTE "]) +def test_local_note_filter_is_effective_type_only(local, q, requested): + mod, seed = local + seed(*TYPED_FIXTURE) + extra = {"q": q} if q else {} + # Only the note under /notes is a note; notes under /plans, /decisions and + # /implementation-notes take their folder's type and are excluded. + notes = discover(mod, type=requested, **extra) + assert [item["node_id"] for item in notes["items"]] == ["scratch"] + assert "effective_type" not in notes["items"][0] + + +def test_unfiltered_local_rows_carry_effective_type_and_facets(local): + mod, seed = local + seed(*TYPED_FIXTURE) + result = discover(mod, view="paths", facets=["type", "effective_type"]) + by_id = {item["node_id"]: item for item in result["items"]} + assert by_id["legacy-plan"]["type"] == "note" + assert by_id["legacy-plan"]["effective_type"] == "plan" + assert "effective_type" not in by_id["scratch"] + assert by_id["custom"]["effective_type"] == "security_documentation" + counts = result["facets"]["counts"] + # The stored-type facet keeps its historical meaning. + assert {"value": "note", "count": 4} in counts["type"] + assert {"value": "plan", "count": 4} in counts["effective_type"] + assert {"value": "implementation_note", "count": 2} in counts["effective_type"] + + +RELEASE_TERMINAL = ["deployed", "released", "released_verified_production", "folded"] + + +@pytest.mark.parametrize("q", [None, "vocabularyterm"]) +def test_local_active_only_excludes_release_terminal_statuses(local, q): + mod, seed = local + seed( + stored_node("/deployments", "live", "note", minute=1, status="active"), + *[ + stored_node("/deployments", f"release-{status}", "note", minute=2 + index, status=status) + for index, status in enumerate(RELEASE_TERMINAL) + ], + stored_node("/plans", "finished", "note", minute=9, status="completed"), + ) + extra = {"q": q} if q else {} + active = discover(mod, active_only=True, **extra) + assert [item["node_id"] for item in active["items"]] == ["live"] + # Without active_only the release records remain discoverable. + everything = discover(mod, **extra) + assert {f"release-{status}" for status in RELEASE_TERMINAL} <= { + item["node_id"] for item in everything["items"] + } + + +def test_server_rows_are_post_filtered_on_effective_type(local): + mod, _seed = local + # An older server matches stored types only, so type=note returns every + # stored note; the wrapper keeps just the effective notes. + rows = [ + {"id": "under-plans", "full_path": "/plans/legacy", "folder_path": "/plans", "type": "note"}, + {"id": "under-decisions", "full_path": "/decisions/choice", "type": "Note"}, + {"id": "under-notes", "full_path": "/notes/scratch", "folder_path": "/notes", "type": "note"}, + ] + requests = [] + + def fake_request(method, path, payload=None, **kwargs): + requests.append(path) + return {"items": copy.deepcopy(rows), "next_cursor": None} + + mod.request_json = fake_request + notes = mod.list_project_nodes({"type": "note", "view": "paths"}) + assert "type=note" in requests[-1] + assert [item["node_id"] for item in notes["items"]] == ["under-notes"] + + rows[:] = [{"id": "typed", "full_path": "/plans/typed", "type": "plan"}, + {"id": "legacy", "full_path": "/plans/legacy", "type": "note"}] + plans = mod.list_project_nodes({"type": "plan", "view": "paths"}) + assert [item["node_id"] for item in plans["items"]] == ["typed", "legacy"] + + +def test_active_only_filter_on_server_rows_excludes_release_statuses(local): + mod, _seed = local + rows = [{"id": status, "metadata": {"status": status}} for status in [*RELEASE_TERMINAL, "active", "done"]] + filters = mod.status_filter_from_args({"active_only": True}) + # "done" keeps its existing CLI handling (already excluded). + assert [row["id"] for row in mod.apply_status_filters(rows, filters)] == ["active"] + explicit = mod.status_filter_from_args({"status": "deployed"}) + assert explicit["warnings"] == [] diff --git a/tests/test_mcp_e2e_index_shard.py b/tests/test_mcp_e2e_index_shard.py index d7279b6..affe1e4 100644 --- a/tests/test_mcp_e2e_index_shard.py +++ b/tests/test_mcp_e2e_index_shard.py @@ -52,18 +52,18 @@ def unlock(mod, secret=None): cache_secret(mod, secret or memory_secret()) -def plain_node(name, minute, content=None): +def plain_node(name, minute, content=None, folder="/notes"): stamp = f"2026-09-01T12:{minute:02d}:00Z" return { - "id": name, "project_id": PROJECT, "full_path": f"/notes/{name}", "folder_path": "/notes", + "id": name, "project_id": PROJECT, "full_path": f"{folder}/{name}", "folder_path": folder, "name": name, "type": "note", "metadata": {}, "tags": [], "aliases": [], "links": [], "content_text": content if content is not None else name, "updated_at": stamp, "archived_at": None, } -def e2e_node(mod, name, minute, content, metadata=None, secret=None, archived=False): +def e2e_node(mod, name, minute, content, metadata=None, secret=None, archived=False, folder="/notes"): secret = secret or memory_secret() - node = plain_node(name, minute) + node = plain_node(name, minute, folder=folder) context = {"full_path": node["full_path"]} node.update({ "content_text": "", "metadata": {}, "encryption_state": "encrypted", @@ -229,6 +229,22 @@ def test_e2e_body_term_is_findable_with_snippet_while_unlocked(env): assert not discovered.get("warnings") +def test_e2e_shard_hits_filter_on_effective_type(env): + mod, provider, _ = env + provider.put(e2e_node(mod, "legacyplan", 1, BODY, folder="/plans"), "node_created") + provider.put(e2e_node(mod, "scratchnote", 2, BODY), "node_created") + assert mod.reindex_project({})["e2e_index"]["status"] == "applied" + + # Bodies are only in the shard, so these hits come from the E2E path. + plans = mod.search_local_index({"q": BODY_TERM, "limit": 10, "type": "plan"}) + assert hit_ids(plans) == ["legacyplan"] + assert plans["items"][0]["search_source"] == "local_e2e" + assert (plans["items"][0]["type"], plans["items"][0]["effective_type"]) == ("note", "plan") + notes = mod.search_local_index({"q": BODY_TERM, "limit": 10, "type": "note"}) + assert hit_ids(notes) == ["scratchnote"] + assert notes["items"][0]["search_source"] == "local_e2e" + + def test_unprovisioned_secret_yields_no_e2e_hits_and_an_explicit_warning(env): mod, provider, _ = env seed(mod, provider) diff --git a/tests/test_mcp_memory_node_encryption.py b/tests/test_mcp_memory_node_encryption.py index e76a2a3..d93e66f 100644 --- a/tests/test_mcp_memory_node_encryption.py +++ b/tests/test_mcp_memory_node_encryption.py @@ -307,6 +307,8 @@ def run(): "node_id": "done", "full_path": "/plans/done", "type": "note", + # A generic note under /plans reports its effective type as well. + "effective_type": "plan", "status": "completed", }]