diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index 531becd..cf3d95f 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -5453,9 +5453,36 @@ def rpc_error(request_id, code, message, details=None): return {"jsonrpc": "2.0", "id": request_id, "error": error} +def bounded_tool_text(value, is_error=False): + if is_error: + return json.dumps(value, indent=2) + if not isinstance(value, dict): + return json.dumps(value, ensure_ascii=False) + summary = { + key: value[key] + for key in ( + "ok", + "status", + "changed", + "created", + "path", + "full_path", + "view", + "source", + "truncated", + ) + if key in value + } + for key in ("items", "nodes", "edges", "warnings", "frontier"): + if isinstance(value.get(key), list): + summary[f"{key}_count"] = len(value[key]) + summary["structured_result"] = True + return json.dumps(summary, ensure_ascii=False, separators=(",", ":")) + + def tool_result(value, is_error=False): return { - "content": [{"type": "text", "text": json.dumps(value, indent=2)}], + "content": [{"type": "text", "text": bounded_tool_text(value, is_error)}], "structuredContent": value, "isError": is_error, } @@ -6016,6 +6043,15 @@ def release_index_file_lock(handle): handle.close() +def ensure_sqlite_column(conn, table, column, declaration): + existing = { + row["name"] + for row in conn.execute(f"PRAGMA table_info({table})").fetchall() + } + if column not in existing: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {declaration}") + + def index_connect(): AUTH_DIR.mkdir(exist_ok=True) chmod_best_effort(AUTH_DIR, 0o700) @@ -6038,6 +6074,36 @@ def index_connect(): ) """ ) + ensure_sqlite_column(conn, "local_index_nodes", "title", "TEXT") + ensure_sqlite_column(conn, "local_index_nodes", "status", "TEXT") + ensure_sqlite_column(conn, "local_index_nodes", "archived", "INTEGER NOT NULL DEFAULT 0") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS local_index_edges ( + project_id TEXT NOT NULL, + source_node_id TEXT NOT NULL, + target_ref TEXT NOT NULL, + target_node_id TEXT, + target_path TEXT, + relation TEXT NOT NULL, + updated_at TEXT NOT NULL, + indexed_at TEXT NOT NULL, + PRIMARY KEY (project_id, source_node_id, target_ref, relation) + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS local_index_edges_source " + "ON local_index_edges(project_id, source_node_id)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS local_index_edges_target_id " + "ON local_index_edges(project_id, target_node_id)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS local_index_edges_target_path " + "ON local_index_edges(project_id, target_path)" + ) conn.execute( """ CREATE VIRTUAL TABLE IF NOT EXISTS local_index_fts USING fts5( @@ -6098,13 +6164,21 @@ def index_node(node): standard_content_for_index = (not encrypted) or backend_at_rest_decrypted metadata = (node.get("metadata") or {}) if standard_content_for_index and not node.get("encrypted_metadata_envelope") else {} content_text = (node.get("content_text") or "") if standard_content_for_index else "" + title = ( + node.get("title") + or metadata.get("title") + or node.get("name") + or pathlib.PurePosixPath(str(node.get("full_path") or "")).name + ) + status = node_status(node) + project_id = node.get("project_id") or DEFAULT_PROJECT_ID or "" conn.execute( """ INSERT INTO local_index_nodes ( node_id, project_id, full_path, name, folder_path, node_type, - tags_json, updated_at, indexed_at + tags_json, updated_at, indexed_at, title, status, archived ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(node_id) DO UPDATE SET project_id = excluded.project_id, full_path = excluded.full_path, @@ -6113,11 +6187,14 @@ def index_node(node): node_type = excluded.node_type, tags_json = excluded.tags_json, updated_at = excluded.updated_at, - indexed_at = excluded.indexed_at + indexed_at = excluded.indexed_at, + title = excluded.title, + status = excluded.status, + archived = excluded.archived """, ( node["id"], - node.get("project_id") or DEFAULT_PROJECT_ID or "", + project_id, node.get("full_path") or "", node.get("name") or "", node.get("folder_path"), @@ -6125,8 +6202,43 @@ def index_node(node): compact_json_text(tags), node.get("updated_at") or "", iso_now(), + title, + status, + 1 if node_archived(node) else 0, ), ) + conn.execute( + "DELETE FROM local_index_edges WHERE project_id = ? AND source_node_id = ?", + (project_id, node["id"]), + ) + for link in links: + if not isinstance(link, dict): + continue + target_node_id = str(link.get("node_id") or "").strip() or None + target_path = str(link.get("path") or "").strip() or None + target_uri = str(link.get("uri") or "").strip() or None + target_ref = target_node_id or target_uri or target_path + if not target_ref: + continue + conn.execute( + """ + INSERT OR REPLACE INTO local_index_edges ( + project_id, source_node_id, target_ref, target_node_id, + target_path, relation, updated_at, indexed_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + project_id, + node["id"], + target_ref, + target_node_id, + target_path, + str(link.get("relation") or "references"), + node.get("updated_at") or "", + iso_now(), + ), + ) conn.execute("DELETE FROM local_index_fts WHERE node_id = ?", (node["id"],)) conn.execute( """ @@ -6159,6 +6271,11 @@ def remove_indexed_node(node_id): try: with index_connect() as conn: conn.execute("DELETE FROM local_index_fts WHERE node_id = ?", (node_id,)) + conn.execute( + "DELETE FROM local_index_edges " + "WHERE source_node_id = ? OR target_node_id = ?", + (node_id, node_id), + ) conn.execute("DELETE FROM local_index_nodes WHERE node_id = ?", (node_id,)) finally: release_index_file_lock(lock) @@ -8336,16 +8453,56 @@ def get_node_by_id(args): def node_get(args): - view = args.get("view", "edit") - if view not in {"render", "edit", "json"}: - raise RuntimeError("view must be render, edit, or json") + view = args.get("view", "content") + if view not in {"content", "outline", "edit"}: + raise RuntimeError("view must be content, outline, or edit") + backend_view = "edit" if view == "edit" else "render" if args.get("uri"): - return node_by_uri({"uri": args["uri"], "view": view})["node"] - if args.get("node_id") or args.get("id"): - return get_node_by_id({"node_id": args.get("node_id") or args.get("id"), "view": view}) - if args.get("path"): + node = node_by_uri({"uri": args["uri"], "view": backend_view})["node"] + elif args.get("node_id") or args.get("id"): + node = get_node_by_id({"node_id": args.get("node_id") or args.get("id"), "view": backend_view}) + elif args.get("path"): raise RuntimeError("path is an index/route only; resolve it with nap_node_by_path, then read with node_id or uri") - raise RuntimeError("one of node_id or uri is required") + else: + raise RuntimeError("one of node_id or uri is required") + if view == "edit": + return node + metadata = node.get("metadata") if isinstance(node.get("metadata"), dict) else {} + content = str(node.get("content_text") or "") + base = { + "id": node.get("id"), + "project_id": node.get("project_id"), + "canonical_uri": node_identity_payload(node).get("canonical_uri"), + "full_path": node.get("full_path"), + "name": node.get("name"), + "title": node.get("title") or metadata.get("title") or node.get("name"), + "type": node.get("type"), + "status": node_status(node), + "links": node.get("links") or [], + "created_at": node.get("created_at"), + "updated_at": node.get("updated_at"), + "view": view, + } + if view == "content": + base["content_text"] = content + return base + headings = [] + for line_number, line in enumerate(content.splitlines(), start=1): + match = re.match(r"^(#{1,6})\s+(.+?)\s*$", line) + if not match: + continue + title = match.group(2).strip() + anchor = re.sub(r"[^a-z0-9 -]", "", title.lower()).replace(" ", "-") + headings.append({ + "level": len(match.group(1)), + "title": title, + "anchor": anchor, + "line": line_number, + }) + base["abstract"] = preview_text(content, 320) + base["headings"] = headings + base["content_lines"] = len(content.splitlines()) + return base def node_by_id(args): @@ -10528,12 +10685,51 @@ def discover_memory(args): else: result = list_project_nodes({**args, "view": args.get("view") or "summary"}) result["tool_intent"] = "list" + sort = str(args.get("sort") or ("relevance" if q else "updated_desc")).strip().lower() + if sort not in {"relevance", "updated_desc", "updated_asc", "title_asc"}: + raise RuntimeError("sort must be relevance, updated_desc, updated_asc, or title_asc") + items = list(result.get("items") or []) + if sort == "updated_desc": + items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True) + elif sort == "updated_asc": + items.sort(key=lambda item: str(item.get("updated_at") or "")) + elif sort == "title_asc": + items.sort(key=lambda item: str(item.get("title") or item.get("name") or "").casefold()) + result["items"] = items + 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"}) + if invalid_facets: + raise RuntimeError("facets must contain only folder, tag, type, or status") + if requested_facets: + counts = {} + for facet in requested_facets: + values = {} + for item in items: + if facet == "folder": + item_values = [item.get("folder_path")] + elif facet == "tag": + item_values = item.get("tags") or [] + elif facet == "type": + item_values = [item.get("type")] + else: + item_values = [item.get("status") or node_status(item)] + for value in item_values: + if value not in (None, ""): + values[str(value)] = values.get(str(value), 0) + 1 + counts[facet] = [ + {"value": value, "count": count} + for value, count in sorted(values.items(), key=lambda pair: (-pair[1], pair[0])) + ] + result["facets"] = {"scope": "returned_items", "counts": counts} + result["sort"] = sort result["range"] = { "updated_after": args.get("updated_after"), "updated_before": args.get("updated_before"), } result["awareness"] = { - "default_sort": "search_relevance_then_updated_desc" if q else "updated_desc", + "default_sort": "relevance" if q else "updated_desc", "date_fields_in_rows": ["date", "created_at", "updated_at"], "next_step": "Use nap_node_by_path on important paths, then read by identity with nap_node_get when the preview is not enough.", } @@ -10550,8 +10746,8 @@ def classify_memory_query(args): primary_tool = "nap_context" reason = "query contains explicit node path(s)" elif recent_intent and not raw.strip('"'): - primary_tool = "nap_recent" - reason = "query asks for recent or latest memory" + primary_tool = "nap_discover" + reason = "query asks for recent memory; use sort=updated_desc" elif analysis.get("terms") or analysis.get("phrases"): primary_tool = "nap_discover" reason = "query has searchable keywords and may need filters or ranges" @@ -10574,68 +10770,227 @@ def classify_memory_query(args): "suggested_calls": [ {"tool": "nap_context", "when": "You already know one or more canonical paths and need surrounding links/backlinks."}, {"tool": "nap_discover", "when": "You need search plus folder/tag/type/status/date filters in one call."}, - {"tool": "nap_recent", "when": "You need latest project changes by time window."}, + {"tool": "nap_discover", "when": "You need latest project changes; omit q and use sort=updated_desc."}, {"tool": "nap_node_by_path", "when": "You selected a path and need to resolve it to node_id / nap:// URI before reading."}, {"tool": "nap_node_get", "when": "You have node_id or nap:// URI and need the full body."}, ], } -def get_memory_context(args): - raw_node_ids = args.get("node_ids") or ([args["node_id"]] if args.get("node_id") else []) - raw_uris = args.get("uris") or ([args["uri"]] if args.get("uri") else []) - raw_paths = args.get("paths") or ([args["path"]] if args.get("path") else []) - if not raw_node_ids and not raw_uris and not raw_paths: +def context_seed_ids(args): + node_ids = [str(value).strip() for value in (args.get("node_ids") or []) if str(value).strip()] + if args.get("node_id"): + node_ids.append(str(args["node_id"]).strip()) + uris = list(args.get("uris") or []) + if args.get("uri"): + uris.append(args["uri"]) + for uri in uris: + node_ids.append(parse_nap_node_uri(str(uri))["node_id"]) + paths = list(args.get("paths") or []) + if args.get("path"): + paths.append(args["path"]) + for path in paths: + resolved = node_by_path({"path": normalize_node_path(path)}) + node_id = (resolved.get("identity") or {}).get("node_id") + if node_id: + node_ids.append(node_id) + node_ids = unique_preserve_order(node_ids) + if not node_ids: raise RuntimeError("path, paths, node_id, node_ids, uri, or uris is required") - max_nodes = max(1, min(int(args.get("max_nodes", 20)), 100)) - depth = max(0, min(int(args.get("depth", 1)), 2)) - default_view = "summary" - view, _warnings = normalize_discovery_view(args, default=default_view) - queue = [] - for node_id in raw_node_ids: - queue.append(("node", get_node_by_id({"node_id": node_id}))) - for uri in raw_uris: - queue.append(("node", node_by_uri({"uri": uri})["node"])) - for path in raw_paths: - queue.append(("path", normalize_node_path(path))) - nodes = [] - edges = [] - seen_paths = set() - current_depth = 0 - while queue and len(nodes) < max_nodes and current_depth <= depth: - next_queue = [] - for kind, value in queue: - if len(nodes) >= max_nodes: - continue - try: - node = value if kind == "node" else read_node_by_path({"path": value}, allow_agent=is_agent_namespace_path(value)) - except Exception: - continue - path = node.get("full_path") - if not path or path in seen_paths: - continue - seen_paths.add(path) - nodes.append(summarize_node(node, view=view, preview_chars=normalize_int(args.get("preview_chars"), 240, 80, 1000))) - links = node.get("links") if isinstance(node.get("links"), list) else [] - for link in links: - target_path = link.get("path") if isinstance(link, dict) else None - if target_path: - edges.append({"source": node["full_path"], "target": target_path, "relation": link.get("relation", "references")}) - if current_depth < depth: - next_queue.append(("path", target_path)) - if current_depth < depth: - try: - backlinks = get_backlinks_by_path({"node_id": node["id"]}).get("items", []) - for backlink in backlinks: - source_path = backlink.get("full_path") - if source_path: - edges.append({"source": source_path, "target": node["full_path"], "relation": "backlink"}) - next_queue.append(("path", source_path)) - except Exception: - pass - queue = next_queue - current_depth += 1 - return {"items": unique_nodes(nodes), "edges": edges[: max_nodes * 4], "view": view} + return node_ids + + +def local_context_node(row): + return { + "id": row["node_id"], + "full_path": row["full_path"], + "name": row["name"], + "title": row["title"] or row["name"], + "type": row["node_type"], + "status": row["status"], + "updated_at": row["updated_at"], + } + + +def local_memory_context(args, seed_ids): + if not INDEX_PATH.exists(): + raise RuntimeError("local memory index is not available; run nap reindex or use mode='server'") + project_id = resolve_project_id(args) + depth = max(0, min(int(args.get("depth", 1)), 3)) + max_nodes = max(1, min(int(args.get("max_nodes", 40)), 100)) + max_edges = max(1, min(int(args.get("max_edges", 80)), 400)) + max_per_level = max(1, min(int(args.get("max_per_level", 20)), 50)) + direction = str(args.get("direction") or "both").strip().lower() + relations = {str(value) for value in (args.get("relations") or []) if str(value)} + if direction not in {"outgoing", "incoming", "both"}: + raise RuntimeError("direction must be outgoing, incoming, or both") + with index_connect() as conn: + def node_row(node_id=None, path=None): + if node_id: + return conn.execute( + "SELECT * FROM local_index_nodes WHERE project_id = ? AND node_id = ? AND archived = 0", + (project_id, node_id), + ).fetchone() + return conn.execute( + "SELECT * FROM local_index_nodes WHERE project_id = ? AND full_path = ? AND archived = 0", + (project_id, path), + ).fetchone() + + seed_rows = [node_row(node_id=node_id) for node_id in seed_ids] + seed_rows = [row for row in seed_rows if row is not None] + if not seed_rows: + raise RuntimeError("seed node is not present in the local memory index") + nodes = {} + edges = [] + edge_keys = set() + seen = set() + frontier = [] + truncation_reasons = [] + levels = [] + queue = [row["node_id"] for row in seed_rows] + indexed_at = "" + for current_depth in range(depth + 1): + level_ids = [] + next_ids = [] + for current_id in queue[:max_per_level]: + if current_id in seen or len(nodes) >= max_nodes: + continue + current = node_row(node_id=current_id) + if current is None: + continue + seen.add(current_id) + compact = local_context_node(current) + compact["depth"] = current_depth + nodes[current_id] = compact + level_ids.append(current_id) + indexed_at = max(indexed_at, str(current["indexed_at"] or "")) + candidates = [] + if direction in {"outgoing", "both"}: + candidates.extend(conn.execute( + "SELECT * FROM local_index_edges WHERE project_id = ? AND source_node_id = ? ORDER BY relation, target_ref", + (project_id, current_id), + ).fetchall()) + if direction in {"incoming", "both"}: + candidates.extend(conn.execute( + "SELECT * FROM local_index_edges WHERE project_id = ? AND (target_node_id = ? OR target_path = ?) ORDER BY relation, source_node_id", + (project_id, current_id, current["full_path"]), + ).fetchall()) + for edge in candidates: + if relations and edge["relation"] not in relations: + continue + target = node_row(node_id=edge["target_node_id"]) if edge["target_node_id"] else node_row(path=edge["target_path"]) + if target is None: + continue + source_id = edge["source_node_id"] + target_id = target["node_id"] + neighbor_id = target_id if source_id == current_id else source_id + known_neighbor = neighbor_id in seen or neighbor_id in next_ids + if current_depth >= depth and not known_neighbor: + continue + if not known_neighbor: + if len(nodes) + len(next_ids) >= max_nodes: + frontier.append(neighbor_id) + if "max_nodes" not in truncation_reasons: + truncation_reasons.append("max_nodes") + continue + if len(next_ids) >= max_per_level: + frontier.append(neighbor_id) + if "max_per_level" not in truncation_reasons: + truncation_reasons.append("max_per_level") + continue + edge_key = (source_id, target_id, edge["relation"]) + if edge_key not in edge_keys: + if len(edges) >= max_edges: + frontier.append(neighbor_id) + if "max_edges" not in truncation_reasons: + truncation_reasons.append("max_edges") + continue + edge_keys.add(edge_key) + edges.append({"source": source_id, "target": target_id, "relation": edge["relation"]}) + if not known_neighbor: + next_ids.append(neighbor_id) + levels.append({"depth": current_depth, "node_ids": level_ids}) + queue = unique_preserve_order(next_ids) + if not queue: + break + return { + "ok": True, + "seed_node_ids": [row["node_id"] for row in seed_rows], + "nodes": list(nodes.values()), + "items": list(nodes.values()), + "edges": edges, + "levels": levels, + "frontier": unique_preserve_order(frontier), + "truncated": bool(truncation_reasons), + "truncation_reasons": truncation_reasons, + "source": "local_index", + "freshness": {"indexed_at": indexed_at}, + "warnings": [], + "view": "titles", + } + + +def server_memory_context(args, seed_ids): + project_id = resolve_project_id(args) + results = [] + for node_id in seed_ids: + query = { + "depth": max(0, min(int(args.get("depth", 1)), 3)), + "direction": str(args.get("direction") or "both"), + "max_nodes": max(1, min(int(args.get("max_nodes", 40)), 100)), + "max_edges": max(1, min(int(args.get("max_edges", 80)), 400)), + "max_per_level": max(1, min(int(args.get("max_per_level", 20)), 50)), + } + if args.get("relations"): + query["relations"] = ",".join(str(value) for value in args["relations"]) + results.append(request_json( + "GET", + f"/v1/projects/{project_id}/nodes/{urllib.parse.quote(node_id, safe='')}/neighborhood?{rest_query_string(query)}", + )) + if len(results) == 1: + result = dict(results[0]) + result.setdefault("items", result.get("nodes") or []) + result.setdefault("source", "server") + result.setdefault("view", "titles") + return result + nodes = unique_nodes([node for result in results for node in result.get("nodes") or []]) + edges = unique_preserve_order( + compact_json_text(edge) for result in results for edge in result.get("edges") or [] + ) + return { + "ok": True, + "seed_node_ids": seed_ids, + "nodes": nodes, + "items": nodes, + "edges": [json.loads(edge) for edge in edges], + "levels": [result.get("levels") or [] for result in results], + "frontier": unique_preserve_order(value for result in results for value in result.get("frontier") or []), + "truncated": any(result.get("truncated") for result in results), + "truncation_reasons": unique_preserve_order(value for result in results for value in result.get("truncation_reasons") or []), + "source": "server", + "warnings": unique_preserve_order(value for result in results for value in result.get("warnings") or []), + "view": "titles", + } + + +def get_memory_context(args): + seed_ids = context_seed_ids(args) + mode = str(args.get("mode") or "hybrid").strip().lower() + if mode not in {"hybrid", "local", "server"}: + raise RuntimeError("mode must be hybrid, local, or server") + if mode in {"local", "hybrid"}: + try: + return local_memory_context(args, seed_ids) + except Exception as exc: + if mode == "local": + raise + result = server_memory_context(args, seed_ids) + result.setdefault("warnings", []).append( + "Local index was unavailable or incomplete; server neighborhood was used." + ) + result["local_fallback"] = safe_exception_payload(exc) + return result + return server_memory_context(args, seed_ids) def find_related_nodes(args): @@ -13581,14 +13936,9 @@ CANONICAL_TOOL_NAMES = { "nap_update_status", # Project memory. "nap_discover", - "nap_recent", "nap_context", - "nap_find_related", "nap_node_by_path", "nap_node_get", - "nap_backlinks", - "nap_ls_folders", - "nap_ls_tags", "nap_create_node", "nap_node_patch", "nap_bulk", @@ -13639,6 +13989,7 @@ CANONICAL_TOOL_NAMES = { COMPATIBILITY_TOOL_ALIASES = {} HIDDEN_COMPATIBILITY_TOOL_NAMES = { + "nap_backlinks", "nap_bulk_read", "nap_chat_secret_rotate", "nap_chat_secret_setup", @@ -13650,6 +14001,7 @@ HIDDEN_COMPATIBILITY_TOOL_NAMES = { "nap_feedback_ls", "nap_feedback_resolve", "nap_find", + "nap_find_related", "nap_finish_work", "nap_gateway_configure", "nap_gateway_kill", @@ -13673,6 +14025,8 @@ HIDDEN_COMPATIBILITY_TOOL_NAMES = { "nap_lock_release", "nap_lock_renew", "nap_ls", + "nap_ls_folders", + "nap_ls_tags", "nap_next_work", "nap_node_by_id", "nap_node_by_uri", @@ -13682,6 +14036,7 @@ HIDDEN_COMPATIBILITY_TOOL_NAMES = { "nap_plan_supersede", "nap_project_create", "nap_reindex_project", + "nap_recent", "nap_stat", "nap_tee", "nap_tree", @@ -13720,19 +14075,14 @@ SIMPLIFIED_TOOL_PROPERTIES = { "include_archived", "limit", "view", + "sort", + "facets", }, - "nap_recent": { - "folder_path", - "tag", - "cursor", - "updated_after", - "updated_before", - "status", - "limit", - "view", + "nap_context": { + "node_id", "node_ids", "uri", "uris", "path", "paths", "depth", + "max_nodes", "max_edges", "max_per_level", "direction", "relations", + "mode", "view", }, - "nap_context": {"node_id", "node_ids", "uri", "uris", "depth", "max_nodes", "view"}, - "nap_find_related": {"node_id", "uri", "limit", "view"}, "nap_kanban_list": { "q", "column", @@ -13807,14 +14157,9 @@ TOOL_CATEGORIES.update( "project": ["nap_whoami", "nap_auth_refresh"], "memory": [ "nap_discover", - "nap_recent", "nap_context", - "nap_find_related", "nap_node_by_path", "nap_node_get", - "nap_backlinks", - "nap_ls_folders", - "nap_ls_tags", "nap_create_node", "nap_node_patch", "nap_bulk", @@ -13870,6 +14215,87 @@ TOOL_CATEGORIES.update( ], } ) + +CORE_TOOL_NAMES = { + "nap_apropos", + "nap_man", + "nap_doctor", + "nap_whoami", + "nap_discover", + "nap_context", + "nap_node_by_path", + "nap_node_get", + "nap_create_node", + "nap_node_patch", + "nap_bulk", + "nap_ln", + "nap_mv", + "nap_rm", +} + +TOOL_PROFILES = { + "workflow": set(TOOL_CATEGORIES["planning"]) | set(TOOL_CATEGORIES["kanban"]), + "libraries": set(TOOL_CATEGORIES["libraries"]), + "agents": set(TOOL_CATEGORIES["agents"]), + "operations": set(TOOL_CATEGORIES["gateway"]) | set(TOOL_CATEGORIES["schedules"]), + "maintenance": { + "nap_contract", + "nap_update_status", + "nap_auth_refresh", + "nap_mv_folder", + "nap_node_restore", + *TOOL_CATEGORIES["maintenance"], + }, +} + +REMOVED_TOOL_REPLACEMENTS = { + "nap_recent": "Use nap_discover without q and sort='updated_desc'.", + "nap_find_related": "Use nap_context with direction='both' and depth=1.", + "nap_backlinks": "Use nap_context with direction='incoming' and depth=1.", + "nap_ls_folders": "Use nap_discover with facets=['folder'].", + "nap_ls_tags": "Use nap_discover with facets=['tag'].", +} + +# Tests and embedded clients may override this after import. None means read +# the environment on every request so a restarted MCP server picks up config. +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() + } + unknown = set(configured) - ({"all"} | set(TOOL_PROFILES)) + if unknown: + raise SafeToolError( + "invalid_tool_profile", + "Unknown NAPSEER_TOOL_PROFILES value. Supported profiles: " + + ", ".join(sorted(TOOL_PROFILES)), + ) + return set(configured) + + +def tool_profile(name): + if name in CORE_TOOL_NAMES: + return "core" + for profile, names in TOOL_PROFILES.items(): + if name in names: + return profile + return None + + +def enabled_tool_names(): + profiles = configured_tool_profiles() + if "all" in profiles: + return set(CANONICAL_TOOL_NAMES) + enabled = set(CORE_TOOL_NAMES) + for profile in profiles: + enabled.update(TOOL_PROFILES[profile]) + return enabled PUBLIC_TOOLS.add("nap_auth_refresh") READ_ONLY_TOOLS.add("nap_gateway_vault_ls") AUTO_LOCK_TOOLS.update({"nap_crontab_put", "nap_plan_transition", "nap_node_restore"}) @@ -14047,6 +14473,15 @@ def contract_profile(args=None): "names and explicit aliases only." ), } + profile["tool_profiles"] = { + "default": "core", + "active": sorted(configured_tool_profiles()), + "configuration": "NAPSEER_TOOL_PROFILES", + "available": { + name: sorted(tool_names) for name, tool_names in TOOL_PROFILES.items() + }, + "core": sorted(CORE_TOOL_NAMES), + } return profile @@ -14073,6 +14508,56 @@ def _simplify_schema(name, schema): return simplified +def _efficient_memory_schema(name, schema): + result = dict(schema) + properties = dict(result.get("properties") or {}) + if name == "nap_discover": + properties["sort"] = { + "type": "string", + "enum": ["relevance", "updated_desc", "updated_asc", "title_asc"], + "default": "updated_desc", + } + properties["facets"] = { + "type": "array", + "items": {"type": "string", "enum": ["folder", "tag", "type", "status"]}, + "uniqueItems": True, + "maxItems": 4, + } + elif name == "nap_context": + properties.update({ + "depth": {"type": "integer", "minimum": 0, "maximum": 3, "default": 1}, + "direction": { + "type": "string", + "enum": ["outgoing", "incoming", "both"], + "default": "both", + }, + "relations": {"type": "array", "items": {"type": "string"}, "uniqueItems": True}, + "max_edges": {"type": "integer", "minimum": 1, "maximum": 400, "default": 80}, + "max_per_level": {"type": "integer", "minimum": 1, "maximum": 50, "default": 20}, + "mode": { + "type": "string", + "enum": ["hybrid", "local", "server"], + "default": "hybrid", + }, + }) + properties.pop("include_content", None) + properties.pop("preview_chars", None) + if "view" in properties: + properties["view"] = { + "type": "string", + "enum": ["titles", "paths", "summary", "metadata"], + "default": "titles", + } + elif name == "nap_node_get": + properties["view"] = { + "type": "string", + "enum": ["content", "outline", "edit"], + "default": "content", + } + result["properties"] = properties + return result + + def _schedule_schema(): return { "type": "object", @@ -14389,7 +14874,7 @@ def tool_annotations(name): } -def tools(): +def all_tools(): items = [] raw_items = raw_tools() _validate_raw_tool_inventory(raw_items) @@ -14400,13 +14885,13 @@ def tools(): continue item = dict(tool) item["name"] = canonical_name - item["inputSchema"] = ( + item["inputSchema"] = _efficient_memory_schema(canonical_name, ( _schedule_schema() if canonical_name == "nap_crontab_put" else _simplify_schema( canonical_name, dict(item.get("inputSchema") or {}) ) - ) + )) item["inputSchema"] = _require_canonical_identity( canonical_name, item["inputSchema"] ) @@ -14420,6 +14905,24 @@ def tools(): "tokens automatically. Use `nap auth login` for account login and " "`nap project attach` to bind this folder to a project." ) + if canonical_name == "nap_context": + item["description"] = ( + "Read a bounded title-only neighborhood around one or more nodes. " + "Supports depth 0-3, incoming/outgoing/both traversal, relation filters, " + "explicit budgets, and local-index/server fallback without fetching bodies." + ) + if canonical_name == "nap_discover": + item["description"] = ( + "Discover compact project memory with keywords, structured filters, " + "deterministic sorting, and returned-item facets. Omit q with " + "sort='updated_desc' for recent memory." + ) + if canonical_name == "nap_node_get": + item["description"] = ( + "Read one node by canonical node_id or nap:// URI. Use content for a lean " + "reading view, outline for headings without the body, or edit to obtain " + "revision and read_fingerprint before nap_node_patch." + ) category = tool_category(canonical_name) if not item.get("description", "").startswith("["): item["description"] = f"[{category}] {item.get('description', '')}" @@ -14438,6 +14941,11 @@ def tools(): return items +def tools(): + enabled = enabled_tool_names() + return [tool for tool in all_tools() if tool["name"] in enabled] + + def explore_tools(args): category = str(args.get("category") or "").strip().lower() query = str(args.get("q") or "").strip().lower() @@ -14446,7 +14954,8 @@ def explore_tools(args): limit = max(1, min(int(args.get("limit", 50)), 200)) grouped = {key: [] for key in TOOL_CATEGORIES} grouped["other"] = [] - tool_by_name = {tool["name"]: tool for tool in tools()} + active_names = enabled_tool_names() + tool_by_name = {tool["name"]: tool for tool in all_tools()} ordered_names = [] for names in TOOL_CATEGORIES.values(): ordered_names.extend(names) @@ -14462,6 +14971,8 @@ def explore_tools(args): if query_terms and not all(term in haystack for term in query_terms): continue card = tool_card(tool, include_schemas) + card["enabled"] = name in active_names + 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 { @@ -14469,6 +14980,8 @@ def explore_tools(args): "categories": list(TOOL_CATEGORIES.keys()), "tools_are_flat_in_mcp": True, "include_schemas": include_schemas, + "active_profiles": sorted(configured_tool_profiles()), + "default_profile": "core", "metadata_fields": contract_profile({"include_tools": False})["metadata_fields"], "groups": grouped, } @@ -14476,9 +14989,18 @@ def explore_tools(args): def man_tool(args): name = str(args.get("tool") or args.get("name") or "").strip() - for tool in tools(): + for tool in all_tools(): if tool["name"] == name: - return tool_card(tool, include_schema=True) + card = tool_card(tool, include_schema=True) + card["enabled"] = name in enabled_tool_names() + card["profile"] = tool_profile(name) + return card + if name in REMOVED_TOOL_REPLACEMENTS: + return { + "name": name, + "removed": True, + "replacement": REMOVED_TOOL_REPLACEMENTS[name], + } raise RuntimeError(f"unknown tool: {name}") @@ -14537,11 +15059,25 @@ def doctor(args): def call_tool_impl(name, args): args = args or {} + if name in REMOVED_TOOL_REPLACEMENTS: + raise SafeToolError( + "tool_removed", + f"{name} was removed from the efficient surface. " + f"{REMOVED_TOOL_REPLACEMENTS[name]}", + ) if name not in CANONICAL_TOOL_NAMES and name not in COMPATIBILITY_TOOL_ALIASES: raise SafeToolError( "unknown_tool", "Unknown or unavailable MCP tool. Use `nap_apropos` to discover the canonical surface.", ) + canonical_name = COMPATIBILITY_TOOL_ALIASES.get(name, name) + if canonical_name not in enabled_tool_names(): + profile = tool_profile(canonical_name) + 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.", + ) if name not in PUBLIC_TOOLS and name in PROTECTED_TOOLS: require_unlocked(name) if args.get("view") == "full": diff --git a/tests/test_mcp_memory_ergonomics_helpers.py b/tests/test_mcp_memory_ergonomics_helpers.py index 98642fa..cbf483e 100644 --- a/tests/test_mcp_memory_ergonomics_helpers.py +++ b/tests/test_mcp_memory_ergonomics_helpers.py @@ -23,6 +23,9 @@ def load_module(): module.AUTH_PATH = pathlib.Path(module._test_state.name) / "auth.json" module.AUTH = {"account_id": "acct-1", "token": "token", "token_expires_at": "later"} module.DEFAULT_PROJECT_ID = "project-1" + # Keep broad workflow coverage in this legacy suite. Dedicated profile + # tests exercise the production core-only default. + module.CONFIGURED_TOOL_PROFILES = {"all"} module.project_memory_encryption_active = lambda project_id: False return module @@ -214,7 +217,7 @@ def test_node_get_accepts_node_id_without_path(mod): return found mod.request_json = fake_request - result = mod.node_get({"node_id": "node-123", "view": "render"}) + result = mod.node_get({"node_id": "node-123", "view": "content"}) assert result["id"] == "node-123" assert result["full_path"] == "/documentation/product/prd" @@ -243,7 +246,7 @@ def test_node_get_edit_view_uses_id_route_not_by_path(mod): def test_node_get_rejects_path_reads(mod): try: - mod.node_get({"path": "/documentation/product/prd", "view": "render"}) + mod.node_get({"path": "/documentation/product/prd", "view": "content"}) except RuntimeError as exc: assert "path is an index/route only" in str(exc) else: @@ -252,7 +255,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) == 56 + assert len(tool_by_name) == 51 assert len(tool_by_name) == len(mod.tools()) get_tool = next(item for item in mod.tools() if item["name"] == "nap_node_get") @@ -274,10 +277,15 @@ def test_tools_include_node_by_path_descriptor(mod): "nap_patch", "nap_find", "nap_index_sync", + "nap_recent", + "nap_find_related", + "nap_backlinks", + "nap_ls_folders", + "nap_ls_tags", ]: assert hidden_name not in tool_by_name - for name in ["nap_discover", "nap_context", "nap_find_related", "nap_recent"]: + for name in ["nap_discover", "nap_context"]: schema = next(item for item in mod.tools() if item["name"] == name)["inputSchema"] view = schema["properties"].get("view") if view: @@ -541,6 +549,21 @@ def test_context_and_related_use_identity_inputs(mod): def fake_request(method, path, payload=None, **kwargs): assert method == "GET" + if "/nodes/root-123/neighborhood?" in path: + return { + "nodes": [ + {"id": "root-123", "full_path": "/notes/root", "title": "root", "depth": 0}, + {"id": "linked-123", "full_path": "/notes/linked", "title": "linked", "depth": 1}, + {"id": "backlink-123", "full_path": "/notes/backlink", "title": "backlink", "depth": 1}, + ], + "edges": [ + {"source": "root-123", "target": "linked-123", "relation": "references"}, + {"source": "backlink-123", "target": "root-123", "relation": "references"}, + ], + "levels": [{"depth": 0, "node_ids": ["root-123"]}, {"depth": 1, "node_ids": ["linked-123", "backlink-123"]}], + "frontier": [], + "truncated": False, + } if path == "/v1/projects/project-1/nodes/root-123/backlinks": return {"items": [backlink]} if path == "/v1/projects/project-1/nodes/linked-123/backlinks": @@ -550,19 +573,20 @@ def test_context_and_related_use_identity_inputs(mod): mod.get_node_by_id = fake_get_node_by_id mod.read_node_by_path = fake_read_by_path mod.request_json = fake_request + mod.node_by_path = lambda args: {"identity": {"node_id": "root-123"}} mod.list_project_nodes = lambda args: {"items": [root, linked]} - context = mod.get_memory_context({"node_id": "root-123", "depth": 1, "view": "paths"}) + context = mod.get_memory_context({"node_id": "root-123", "depth": 1, "mode": "server"}) assert [item["full_path"] for item in context["items"]] == ["/notes/root", "/notes/linked", "/notes/backlink"] - assert {"source": "/notes/root", "target": "/notes/linked", "relation": "references"} in context["edges"] - assert {"source": "/notes/backlink", "target": "/notes/root", "relation": "backlink"} in context["edges"] + assert {"source": "root-123", "target": "linked-123", "relation": "references"} in context["edges"] + assert {"source": "backlink-123", "target": "root-123", "relation": "references"} in context["edges"] related = mod.find_related_nodes({"node_id": "root-123", "view": "paths"}) assert related["target"]["full_path"] == "/notes/root" assert {item["full_path"] for item in related["items"]} >= {"/notes/backlink", "/notes/linked"} - path_context = mod.get_memory_context({"path": "/notes/root", "depth": 0, "view": "paths"}) - assert [item["full_path"] for item in path_context["items"]] == ["/notes/root"] + path_context = mod.get_memory_context({"path": "/notes/root", "depth": 0, "mode": "server"}) + assert path_context["items"][0]["full_path"] == "/notes/root" with pytest.raises(RuntimeError, match="path is an index/route only"): mod.find_related_nodes({"path": "/notes/root"}) diff --git a/tests/test_mcp_memory_surface_efficiency.py b/tests/test_mcp_memory_surface_efficiency.py new file mode 100644 index 0000000..dcf03f9 --- /dev/null +++ b/tests/test_mcp_memory_surface_efficiency.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Contract tests for the reduced, bounded agent-memory surface.""" + +import importlib.util +import pathlib +import sys +import tempfile + +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_server_surface_test", script_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + module.DEFAULT_PROJECT_ID = "project-1" + return module + + +@pytest.fixture +def mod(monkeypatch): + monkeypatch.delenv("NAPSEER_TOOL_PROFILES", raising=False) + module = load_module() + module.CONFIGURED_TOOL_PROFILES = None + return module + + +def test_default_surface_is_exactly_the_core_fourteen(mod): + assert [tool["name"] for tool in mod.tools()] == [ + "nap_apropos", "nap_man", "nap_doctor", "nap_whoami", + "nap_discover", "nap_context", "nap_node_by_path", "nap_node_get", + "nap_create_node", "nap_node_patch", "nap_bulk", "nap_ln", + "nap_mv", "nap_rm", + ] + + +def test_profiles_expand_surface_without_restoring_pruned_tools(mod, monkeypatch): + monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "workflow,maintenance") + names = {tool["name"] for tool in mod.tools()} + assert {"nap_plan_list_active", "nap_kanban_complete", "nap_index_status"} <= names + assert not ({"nap_recent", "nap_find_related", "nap_backlinks", "nap_ls_folders", "nap_ls_tags"} & names) + + +def test_apropos_can_describe_inactive_profile_tools(mod): + result = mod.explore_tools({"q": "kanban complete"}) + card = result["groups"]["kanban"][0] + assert card["name"] == "nap_kanban_complete" + assert card["enabled"] is False + assert card["profile"] == "workflow" + + +def test_pruned_and_profile_gated_calls_are_actionable(mod): + with pytest.raises(mod.SafeToolError) as removed: + mod.call_tool_impl("nap_recent", {}) + assert removed.value.code == "tool_removed" + assert "nap_discover" in removed.value.safe_message + + with pytest.raises(mod.SafeToolError) as gated: + mod.call_tool_impl("nap_kanban_list", {}) + assert gated.value.code == "tool_profile_required" + assert "workflow" in gated.value.safe_message + + +def test_context_and_node_read_schemas_are_bounded(mod): + by_name = {tool["name"]: tool for tool in mod.all_tools()} + context = by_name["nap_context"]["inputSchema"]["properties"] + assert context["depth"]["maximum"] == 3 + assert context["direction"]["enum"] == ["outgoing", "incoming", "both"] + assert context["mode"]["enum"] == ["hybrid", "local", "server"] + assert "relations" in context + assert "max_edges" in context + assert "max_per_level" in context + + node_get = by_name["nap_node_get"]["inputSchema"]["properties"] + assert node_get["view"]["enum"] == ["content", "outline", "edit"] + assert node_get["view"]["default"] == "content" + + +def test_tool_text_does_not_duplicate_large_structured_payload(mod): + payload = {"ok": True, "items": [{"content_text": "x" * 20_000}]} + result = mod.tool_result(payload) + assert result["structuredContent"] == payload + assert len(result["content"][0]["text"]) < 200 + assert "20000" not in result["content"][0]["text"] + + +def test_local_context_returns_title_radius_without_node_bodies(mod): + state = tempfile.TemporaryDirectory() + mod._surface_state = state + mod.AUTH_DIR = pathlib.Path(state.name) + mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite" + mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock" + root = { + "id": "root", "project_id": "project-1", "full_path": "/docs/root", + "folder_path": "/docs", "name": "root", "type": "note", + "metadata": {"title": "Root", "status": "active"}, "tags": [], + "links": [{"path": "/docs/child", "relation": "explains"}], + "content_text": "secret body", "updated_at": "2026-08-16T12:00:00Z", + } + child = { + "id": "child", "project_id": "project-1", "full_path": "/docs/child", + "folder_path": "/docs", "name": "child", "type": "note", + "metadata": {"title": "Child", "status": "active"}, "tags": [], + "links": [], "content_text": "another body", "updated_at": "2026-08-16T12:01:00Z", + } + mod.index_node(root) + mod.index_node(child) + + result = mod.get_memory_context({ + "node_id": "root", "mode": "local", "depth": 2, + "direction": "both", "relations": ["explains"], + }) + assert [(item["title"], item["depth"]) for item in result["nodes"]] == [("Root", 0), ("Child", 1)] + assert result["edges"] == [{"source": "root", "target": "child", "relation": "explains"}] + assert result["levels"] == [ + {"depth": 0, "node_ids": ["root"]}, + {"depth": 1, "node_ids": ["child"]}, + ] + assert "content_text" not in result["nodes"][0] + assert result["source"] == "local_index" + + +def test_discovery_sort_and_returned_item_facets(mod): + mod.list_project_nodes = lambda args: { + "items": [ + {"name": "Beta", "folder_path": "/b", "tags": ["x"], "type": "note", "status": "active", "updated_at": "2026-01-01T00:00:00Z"}, + {"name": "Alpha", "folder_path": "/a", "tags": ["x", "y"], "type": "plan", "status": "planned", "updated_at": "2026-02-01T00:00:00Z"}, + ] + } + result = mod.discover_memory({"sort": "title_asc", "facets": ["folder", "tag", "status"]}) + assert [item["name"] for item in result["items"]] == ["Alpha", "Beta"] + assert result["facets"]["scope"] == "returned_items" + assert result["facets"]["counts"]["tag"][0] == {"value": "x", "count": 2} + + +def test_node_get_content_and_outline_hide_mutation_envelopes(mod): + node = { + "id": "node-1", "project_id": "project-1", "full_path": "/docs/one", + "name": "one", "type": "note", "metadata": {"title": "One", "status": "active"}, + "links": [], "content_text": "# First\nBody\n## Second\nMore", + "encrypted_content_envelope": {"ciphertext": "not-for-read-view"}, + "updated_at": "2026-08-16T12:00:00Z", + } + mod.get_node_by_id = lambda args: node + content = mod.node_get({"node_id": "node-1"}) + outline = mod.node_get({"node_id": "node-1", "view": "outline"}) + assert content["content_text"].startswith("# First") + assert "encrypted_content_envelope" not in content + assert [heading["title"] for heading in outline["headings"]] == ["First", "Second"] + assert "content_text" not in outline