The knowledge base was reachable only over HTTP, so an MCP client had to fall back to a second integration path to browse or maintain it. Register the seven agent-facing operations as native MCP tools, with the same bank scoping, tenant auth and operation-validator behaviour as the existing tools: get_knowledge_base_tree, search_knowledge_base, get_knowledge_page, create_knowledge_folder, create_knowledge_page, update_knowledge_node, delete_knowledge_node export_knowledge_base stays HTTP/CLI-only — it returns the whole bank as one markdown bundle, which does not belong in an agent's context window. Two places where the MCP surface cannot mirror the HTTP one, both commented at the call site: - MCP arguments cannot express an explicit null, so update_knowledge_node reads parent_id="root" as "move to the top level". Node ids are prefixed kf-/kp-, so the literal cannot collide with a real folder id. - The page refresh trigger is flattened to a single refresh_after_consolidation flag, matching how create/update_mental_model already expose it. It is sent as a patch, so an unstated flag leaves the knowledge-page defaults (delta mode, observation-only) intact — the regression #3506 fixed. get_knowledge_page returns the rendered markdown document once instead of the HTTP body+markdown pair, which would double the tokens for no new information. search_knowledge_base clamps limit instead of rejecting it: an agent that asked for 500 pages wants results, not a 422. Also adds a structural guard that the three hand-maintained tool allowlists (_ALL_TOOLS, register_mcp_tools()'s default set, and the single-bank set in create_mcp_server) agree with what is actually registered — a name added to one but not the others silently drops the tool from the endpoint.
This commit is contained in:
@@ -154,6 +154,13 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
|
||||
"update_bank",
|
||||
"delete_bank",
|
||||
"clear_memories",
|
||||
"get_knowledge_base_tree",
|
||||
"search_knowledge_base",
|
||||
"get_knowledge_page",
|
||||
"create_knowledge_folder",
|
||||
"create_knowledge_page",
|
||||
"update_knowledge_node",
|
||||
"delete_knowledge_node",
|
||||
}
|
||||
)
|
||||
base_tools: frozenset[str] | None = None if multi_bank else _SINGLE_BANK_TOOLS
|
||||
|
||||
@@ -16,6 +16,7 @@ from mcp.types import ToolAnnotations
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from hindsight_api import MemoryEngine
|
||||
from hindsight_api.api import page_markdown
|
||||
from hindsight_api.config import (
|
||||
DEFAULT_MCP_RECALL_DESCRIPTION,
|
||||
DEFAULT_MCP_RETAIN_DESCRIPTION,
|
||||
@@ -65,6 +66,13 @@ _ALL_TOOLS: frozenset[str] = frozenset(
|
||||
"update_bank",
|
||||
"delete_bank",
|
||||
"clear_memories",
|
||||
"get_knowledge_base_tree",
|
||||
"search_knowledge_base",
|
||||
"get_knowledge_page",
|
||||
"create_knowledge_folder",
|
||||
"create_knowledge_page",
|
||||
"update_knowledge_node",
|
||||
"delete_knowledge_node",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -228,6 +236,9 @@ _READ_ONLY_TOOLS = {
|
||||
"list_operations",
|
||||
"get_operation",
|
||||
"list_tags",
|
||||
"get_knowledge_base_tree",
|
||||
"search_knowledge_base",
|
||||
"get_knowledge_page",
|
||||
}
|
||||
_DESTRUCTIVE_TOOLS = {
|
||||
"delete_bank",
|
||||
@@ -237,6 +248,7 @@ _DESTRUCTIVE_TOOLS = {
|
||||
"delete_directive",
|
||||
"delete_document",
|
||||
"invalidate_memory",
|
||||
"delete_knowledge_node",
|
||||
}
|
||||
|
||||
|
||||
@@ -295,6 +307,13 @@ def register_mcp_tools(
|
||||
"update_bank",
|
||||
"delete_bank",
|
||||
"clear_memories",
|
||||
"get_knowledge_base_tree",
|
||||
"search_knowledge_base",
|
||||
"get_knowledge_page",
|
||||
"create_knowledge_folder",
|
||||
"create_knowledge_page",
|
||||
"update_knowledge_node",
|
||||
"delete_knowledge_node",
|
||||
}
|
||||
|
||||
if "retain" in tools_to_register:
|
||||
@@ -399,6 +418,28 @@ def register_mcp_tools(
|
||||
if "clear_memories" in tools_to_register:
|
||||
_register_clear_memories(mcp, memory, config)
|
||||
|
||||
# Knowledge base tools
|
||||
if "get_knowledge_base_tree" in tools_to_register:
|
||||
_register_get_knowledge_base_tree(mcp, memory, config)
|
||||
|
||||
if "search_knowledge_base" in tools_to_register:
|
||||
_register_search_knowledge_base(mcp, memory, config)
|
||||
|
||||
if "get_knowledge_page" in tools_to_register:
|
||||
_register_get_knowledge_page(mcp, memory, config)
|
||||
|
||||
if "create_knowledge_folder" in tools_to_register:
|
||||
_register_create_knowledge_folder(mcp, memory, config)
|
||||
|
||||
if "create_knowledge_page" in tools_to_register:
|
||||
_register_create_knowledge_page(mcp, memory, config)
|
||||
|
||||
if "update_knowledge_node" in tools_to_register:
|
||||
_register_update_knowledge_node(mcp, memory, config)
|
||||
|
||||
if "delete_knowledge_node" in tools_to_register:
|
||||
_register_delete_knowledge_node(mcp, memory, config)
|
||||
|
||||
_apply_bank_tool_filtering(mcp, memory, config)
|
||||
_apply_audit_logging(mcp, memory, config)
|
||||
|
||||
@@ -509,6 +550,10 @@ _AUDITABLE_MCP_TOOLS: frozenset[str] = frozenset(
|
||||
"delete_directive",
|
||||
"delete_document",
|
||||
"cancel_operation",
|
||||
"create_knowledge_folder",
|
||||
"create_knowledge_page",
|
||||
"update_knowledge_node",
|
||||
"delete_knowledge_node",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2045,6 +2090,823 @@ def _register_clear_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCP
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# KNOWLEDGE BASE TOOLS
|
||||
# =========================================================================
|
||||
# A tree of folders and pages over mental models (see the HTTP
|
||||
# /knowledge-base endpoints). Pages read as markdown documents; folders are
|
||||
# containers. ``export_knowledge_base`` is deliberately NOT exposed here — it
|
||||
# returns the whole bank as one markdown bundle, which belongs on the HTTP/CLI
|
||||
# path rather than in an agent's context window.
|
||||
|
||||
# MCP tool arguments cannot express an explicit null: an omitted argument and a
|
||||
# null one both arrive as ``None``, so update_knowledge_node reads this literal
|
||||
# as "move to the top level". Node ids are prefixed (``kf-``/``kp-``), so it
|
||||
# cannot collide with a real folder id.
|
||||
KNOWLEDGE_ROOT_PARENT = "root"
|
||||
|
||||
|
||||
def _knowledge_node_json(node: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Project an engine node dict into the compact JSON an MCP client sees.
|
||||
|
||||
Mirrors the HTTP ``KnowledgeNode`` projection, minus the fields an agent has
|
||||
no use for (bank_id, sort_order): page metadata comes from the backing
|
||||
mental model, folders carry structure only.
|
||||
"""
|
||||
is_page = node.get("kind") == "page"
|
||||
projected: dict[str, Any] = {
|
||||
"id": node["id"],
|
||||
"kind": node["kind"],
|
||||
"name": node["name"],
|
||||
"parent_id": node.get("parent_id"),
|
||||
"managed": bool(node.get("managed")),
|
||||
}
|
||||
if is_page:
|
||||
projected["mental_model_id"] = node.get("mental_model_id")
|
||||
projected["description"] = node.get("source_query")
|
||||
projected["tags"] = list(node.get("tags") or [])
|
||||
projected["timestamp"] = node.get("last_refreshed_at")
|
||||
if node.get("trigger") is not None:
|
||||
projected["trigger"] = node["trigger"]
|
||||
if node.get("is_stale") is not None:
|
||||
projected["is_stale"] = node["is_stale"]
|
||||
else:
|
||||
projected["timestamp"] = node.get("updated_at")
|
||||
projected["children"] = []
|
||||
return projected
|
||||
|
||||
|
||||
def _knowledge_tree_json(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Assemble the flat node list into nested roots (mirrors the HTTP tree)."""
|
||||
projected = {n["id"]: _knowledge_node_json(n) for n in nodes}
|
||||
roots: list[dict[str, Any]] = []
|
||||
for node in nodes:
|
||||
parent_id = node.get("parent_id")
|
||||
# Only folders can be parents (enforced on write), so the parent normally
|
||||
# carries a children list; setdefault keeps a malformed row from raising.
|
||||
if parent_id and parent_id in projected:
|
||||
projected[parent_id].setdefault("children", []).append(projected[node["id"]])
|
||||
else:
|
||||
roots.append(projected[node["id"]])
|
||||
return roots
|
||||
|
||||
|
||||
def _page_trigger_patch(refresh_after_consolidation: bool | None) -> dict[str, Any] | None:
|
||||
"""Build the trigger patch for the one refresh setting exposed over MCP.
|
||||
|
||||
The engine merges a patch over the page's defaults (create) or its current
|
||||
trigger (update), so sending only this field leaves the rest — delta mode,
|
||||
observation-only facts — as they were.
|
||||
"""
|
||||
if refresh_after_consolidation is None:
|
||||
return None
|
||||
return {"refresh_after_consolidation": refresh_after_consolidation}
|
||||
|
||||
|
||||
async def _do_get_knowledge_base_tree(
|
||||
memory: MemoryEngine, target_bank: str, request_context: RequestContext
|
||||
) -> dict[str, Any]:
|
||||
"""Shared implementation for the get_knowledge_base_tree MCP tool variants."""
|
||||
nodes = await memory.list_knowledge_nodes(bank_id=target_bank, with_staleness=True, request_context=request_context)
|
||||
return {"roots": _knowledge_tree_json(nodes)}
|
||||
|
||||
|
||||
async def _do_search_knowledge_base(
|
||||
memory: MemoryEngine, target_bank: str, request_context: RequestContext, *, query: str, limit: int
|
||||
) -> dict[str, Any]:
|
||||
"""Shared implementation for the search_knowledge_base MCP tool variants.
|
||||
|
||||
``limit`` is clamped rather than rejected: the HTTP route answers an
|
||||
out-of-range limit with a 422, but an agent that asked for 500 pages wants
|
||||
results, not a validation round trip.
|
||||
"""
|
||||
results = await memory.search_knowledge_pages(
|
||||
bank_id=target_bank, query=query, limit=max(1, min(limit, 50)), request_context=request_context
|
||||
)
|
||||
return {"results": results, "total": len(results)}
|
||||
|
||||
|
||||
async def _do_get_knowledge_page(
|
||||
memory: MemoryEngine, target_bank: str, request_context: RequestContext, *, page_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""Shared implementation for the get_knowledge_page MCP tool variants."""
|
||||
node = await memory.get_knowledge_page(bank_id=target_bank, page_id=page_id, request_context=request_context)
|
||||
if node is None:
|
||||
return {"error": f"Knowledge page '{page_id}' not found in bank '{target_bank}'"}
|
||||
page = page_markdown.page_type(node.get("tags"))
|
||||
# The rendered document already carries the body under a frontmatter block,
|
||||
# so it is returned once rather than alongside a duplicate `body` field.
|
||||
return {
|
||||
"id": node["id"],
|
||||
"name": node["name"],
|
||||
"type": page.type,
|
||||
"description": node.get("source_query"),
|
||||
"tags": page.display_tags,
|
||||
"timestamp": node.get("last_refreshed_at") or node.get("created_at"),
|
||||
"markdown": page_markdown.render_document(node),
|
||||
}
|
||||
|
||||
|
||||
async def _do_create_knowledge_folder(
|
||||
memory: MemoryEngine, target_bank: str, request_context: RequestContext, *, name: str, parent_id: str | None
|
||||
) -> dict[str, Any]:
|
||||
"""Shared implementation for the create_knowledge_folder MCP tool variants."""
|
||||
node = await memory.create_knowledge_folder(
|
||||
bank_id=target_bank, name=name, parent_id=parent_id, request_context=request_context
|
||||
)
|
||||
return _knowledge_node_json(node)
|
||||
|
||||
|
||||
async def _do_create_knowledge_page(
|
||||
memory: MemoryEngine,
|
||||
target_bank: str,
|
||||
request_context: RequestContext,
|
||||
*,
|
||||
name: str,
|
||||
source_query: str,
|
||||
parent_id: str | None,
|
||||
tags: list[str] | None,
|
||||
max_tokens: int | None,
|
||||
refresh_after_consolidation: bool | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Shared implementation for the create_knowledge_page MCP tool variants."""
|
||||
node = await memory.create_knowledge_page(
|
||||
bank_id=target_bank,
|
||||
name=name,
|
||||
source_query=source_query,
|
||||
content="Generating content...",
|
||||
parent_id=parent_id,
|
||||
tags=tags or None,
|
||||
max_tokens=max_tokens,
|
||||
trigger=_page_trigger_patch(refresh_after_consolidation),
|
||||
request_context=request_context,
|
||||
)
|
||||
if node is None:
|
||||
return {"error": f"A page named '{name}' already exists in this folder"}
|
||||
result = await memory.submit_async_refresh_mental_model(
|
||||
bank_id=target_bank, mental_model_id=node["mental_model_id"], request_context=request_context
|
||||
)
|
||||
return {
|
||||
"page_id": node["id"],
|
||||
"mental_model_id": node["mental_model_id"],
|
||||
"operation_id": result["operation_id"],
|
||||
"status": "created",
|
||||
"message": f"Page '{name}' created. Content is being generated asynchronously.",
|
||||
}
|
||||
|
||||
|
||||
async def _do_update_knowledge_node(
|
||||
memory: MemoryEngine,
|
||||
target_bank: str,
|
||||
request_context: RequestContext,
|
||||
*,
|
||||
node_id: str,
|
||||
name: str | None,
|
||||
parent_id: str | None,
|
||||
source_query: str | None,
|
||||
tags: list[str] | None,
|
||||
max_tokens: int | None,
|
||||
refresh_after_consolidation: bool | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Shared implementation for the update_knowledge_node MCP tool variants.
|
||||
|
||||
Each field is applied only when provided, so a rename never resets a page's
|
||||
query and moving a page never drops its tags.
|
||||
"""
|
||||
trigger = _page_trigger_patch(refresh_after_consolidation)
|
||||
page_update = source_query is not None or tags is not None or max_tokens is not None or trigger is not None
|
||||
if name is None and parent_id is None and not page_update:
|
||||
return {
|
||||
"error": "Provide name, parent_id, source_query, tags, max_tokens, "
|
||||
"and/or refresh_after_consolidation to update"
|
||||
}
|
||||
|
||||
updated: dict[str, Any] | None = None
|
||||
if name is not None:
|
||||
updated = await memory.rename_knowledge_node(
|
||||
bank_id=target_bank, node_id=node_id, name=name, request_context=request_context
|
||||
)
|
||||
if parent_id is not None:
|
||||
updated = await memory.move_knowledge_node(
|
||||
bank_id=target_bank,
|
||||
node_id=node_id,
|
||||
new_parent_id=None if parent_id == KNOWLEDGE_ROOT_PARENT else parent_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
if page_update:
|
||||
updated = await memory.update_knowledge_page(
|
||||
bank_id=target_bank,
|
||||
page_id=node_id,
|
||||
source_query=source_query,
|
||||
tags=tags,
|
||||
max_tokens=max_tokens,
|
||||
trigger=trigger,
|
||||
request_context=request_context,
|
||||
)
|
||||
# A new source query means the page's content no longer answers it — rebuild.
|
||||
if updated is not None and source_query is not None and updated.get("mental_model_id"):
|
||||
await memory.submit_async_refresh_mental_model(
|
||||
bank_id=target_bank, mental_model_id=updated["mental_model_id"], request_context=request_context
|
||||
)
|
||||
if updated is None:
|
||||
return {"error": f"Knowledge node '{node_id}' not found in bank '{target_bank}'"}
|
||||
return _knowledge_node_json(updated)
|
||||
|
||||
|
||||
async def _do_delete_knowledge_node(
|
||||
memory: MemoryEngine, target_bank: str, request_context: RequestContext, *, node_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""Shared implementation for the delete_knowledge_node MCP tool variants."""
|
||||
deleted = await memory.delete_knowledge_node(bank_id=target_bank, node_id=node_id, request_context=request_context)
|
||||
if not deleted:
|
||||
return {"error": f"Knowledge node '{node_id}' not found in bank '{target_bank}'"}
|
||||
return {"status": "deleted", "node_id": node_id}
|
||||
|
||||
|
||||
def _register_get_knowledge_base_tree(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the get_knowledge_base_tree tool."""
|
||||
|
||||
if config.include_bank_id_param:
|
||||
|
||||
@mcp.tool(annotations=_tool_annotations("get_knowledge_base_tree"))
|
||||
async def get_knowledge_base_tree(
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Browse the knowledge base as a nested tree of folders and pages.
|
||||
|
||||
Start here to discover what the bank documents: each page is a living
|
||||
markdown document synthesized from the bank's memories, and folders
|
||||
group them. Use get_knowledge_page to read a page's content, or
|
||||
search_knowledge_base when you know what you are looking for.
|
||||
|
||||
Pages report `is_stale`: false means the page is provably up to date;
|
||||
true means something was written since its last refresh, so it MAY be
|
||||
out of date.
|
||||
|
||||
Args:
|
||||
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return '{"error": "No bank_id configured"}'
|
||||
|
||||
tree = await _do_get_knowledge_base_tree(memory, target_bank, _get_request_context(config))
|
||||
return json.dumps(tree, indent=2, default=str)
|
||||
except OperationValidationError as e:
|
||||
logger.warning(f"Operation rejected: {e}")
|
||||
return json.dumps({"error": str(e)})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting knowledge base tree: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}"}}'
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool(annotations=_tool_annotations("get_knowledge_base_tree"))
|
||||
async def get_knowledge_base_tree() -> dict:
|
||||
"""
|
||||
Browse the knowledge base as a nested tree of folders and pages.
|
||||
|
||||
Start here to discover what the bank documents: each page is a living
|
||||
markdown document synthesized from the bank's memories, and folders
|
||||
group them. Use get_knowledge_page to read a page's content, or
|
||||
search_knowledge_base when you know what you are looking for.
|
||||
|
||||
Pages report `is_stale`: false means the page is provably up to date;
|
||||
true means something was written since its last refresh, so it MAY be
|
||||
out of date.
|
||||
"""
|
||||
try:
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"error": "No bank_id configured"}
|
||||
|
||||
return await _do_get_knowledge_base_tree(memory, target_bank, _get_request_context(config))
|
||||
except OperationValidationError as e:
|
||||
logger.warning(f"Operation rejected: {e}")
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting knowledge base tree: {e}", exc_info=True)
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def _register_search_knowledge_base(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the search_knowledge_base tool."""
|
||||
|
||||
if config.include_bank_id_param:
|
||||
|
||||
@mcp.tool(annotations=_tool_annotations("search_knowledge_base"))
|
||||
async def search_knowledge_base(
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Find knowledge pages by relevance (hybrid keyword + semantic search).
|
||||
|
||||
Searches page names and content, returning ranked pages with a short
|
||||
snippet each. Read a hit in full with get_knowledge_page. This searches
|
||||
the curated knowledge base only — use recall to search raw memories.
|
||||
|
||||
Args:
|
||||
query: What to search for
|
||||
limit: Maximum pages to return (1-50, default: 10)
|
||||
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return '{"error": "No bank_id configured"}'
|
||||
|
||||
results = await _do_search_knowledge_base(
|
||||
memory, target_bank, _get_request_context(config), query=query, limit=limit
|
||||
)
|
||||
return json.dumps(results, indent=2, default=str)
|
||||
except OperationValidationError as e:
|
||||
logger.warning(f"Operation rejected: {e}")
|
||||
return json.dumps({"error": str(e)})
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching knowledge base: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}"}}'
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool(annotations=_tool_annotations("search_knowledge_base"))
|
||||
async def search_knowledge_base(
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
) -> dict:
|
||||
"""
|
||||
Find knowledge pages by relevance (hybrid keyword + semantic search).
|
||||
|
||||
Searches page names and content, returning ranked pages with a short
|
||||
snippet each. Read a hit in full with get_knowledge_page. This searches
|
||||
the curated knowledge base only — use recall to search raw memories.
|
||||
|
||||
Args:
|
||||
query: What to search for
|
||||
limit: Maximum pages to return (1-50, default: 10)
|
||||
"""
|
||||
try:
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"error": "No bank_id configured"}
|
||||
|
||||
return await _do_search_knowledge_base(
|
||||
memory, target_bank, _get_request_context(config), query=query, limit=limit
|
||||
)
|
||||
except OperationValidationError as e:
|
||||
logger.warning(f"Operation rejected: {e}")
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching knowledge base: {e}", exc_info=True)
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def _register_get_knowledge_page(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the get_knowledge_page tool."""
|
||||
|
||||
if config.include_bank_id_param:
|
||||
|
||||
@mcp.tool(annotations=_tool_annotations("get_knowledge_page"))
|
||||
async def get_knowledge_page(
|
||||
page_id: str,
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Read a knowledge page as a markdown document.
|
||||
|
||||
Returns the page's YAML frontmatter (id, type, title, description,
|
||||
tags, timestamp) followed by its synthesized markdown body. Discover
|
||||
page ids with get_knowledge_base_tree or search_knowledge_base.
|
||||
|
||||
Args:
|
||||
page_id: The ID of the page to read (a `kp-...` node id)
|
||||
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return '{"error": "No bank_id configured"}'
|
||||
|
||||
page = await _do_get_knowledge_page(memory, target_bank, _get_request_context(config), page_id=page_id)
|
||||
return json.dumps(page, indent=2, default=str)
|
||||
except OperationValidationError as e:
|
||||
logger.warning(f"Operation rejected: {e}")
|
||||
return json.dumps({"error": str(e)})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting knowledge page: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}"}}'
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool(annotations=_tool_annotations("get_knowledge_page"))
|
||||
async def get_knowledge_page(
|
||||
page_id: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Read a knowledge page as a markdown document.
|
||||
|
||||
Returns the page's YAML frontmatter (id, type, title, description,
|
||||
tags, timestamp) followed by its synthesized markdown body. Discover
|
||||
page ids with get_knowledge_base_tree or search_knowledge_base.
|
||||
|
||||
Args:
|
||||
page_id: The ID of the page to read (a `kp-...` node id)
|
||||
"""
|
||||
try:
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"error": "No bank_id configured"}
|
||||
|
||||
return await _do_get_knowledge_page(memory, target_bank, _get_request_context(config), page_id=page_id)
|
||||
except OperationValidationError as e:
|
||||
logger.warning(f"Operation rejected: {e}")
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting knowledge page: {e}", exc_info=True)
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def _register_create_knowledge_folder(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the create_knowledge_folder tool."""
|
||||
|
||||
if config.include_bank_id_param:
|
||||
|
||||
@mcp.tool(annotations=_tool_annotations("create_knowledge_folder"))
|
||||
async def create_knowledge_folder(
|
||||
name: str,
|
||||
parent_id: str | None = None,
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create a folder in the knowledge base.
|
||||
|
||||
Folders group pages; they hold no content of their own.
|
||||
|
||||
Args:
|
||||
name: Folder name
|
||||
parent_id: Optional parent folder id (a `kf-...` node id). Omit to create at the top level.
|
||||
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return '{"error": "No bank_id configured"}'
|
||||
|
||||
node = await _do_create_knowledge_folder(
|
||||
memory, target_bank, _get_request_context(config), name=name, parent_id=parent_id
|
||||
)
|
||||
return json.dumps(node, indent=2, default=str)
|
||||
except OperationValidationError as e:
|
||||
logger.warning(f"Operation rejected: {e}")
|
||||
return json.dumps({"error": str(e)})
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating knowledge folder: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}"}}'
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool(annotations=_tool_annotations("create_knowledge_folder"))
|
||||
async def create_knowledge_folder(
|
||||
name: str,
|
||||
parent_id: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Create a folder in the knowledge base.
|
||||
|
||||
Folders group pages; they hold no content of their own.
|
||||
|
||||
Args:
|
||||
name: Folder name
|
||||
parent_id: Optional parent folder id (a `kf-...` node id). Omit to create at the top level.
|
||||
"""
|
||||
try:
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"error": "No bank_id configured"}
|
||||
|
||||
return await _do_create_knowledge_folder(
|
||||
memory, target_bank, _get_request_context(config), name=name, parent_id=parent_id
|
||||
)
|
||||
except OperationValidationError as e:
|
||||
logger.warning(f"Operation rejected: {e}")
|
||||
return {"error": str(e)}
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating knowledge folder: {e}", exc_info=True)
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def _register_create_knowledge_page(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the create_knowledge_page tool."""
|
||||
|
||||
if config.include_bank_id_param:
|
||||
|
||||
@mcp.tool(annotations=_tool_annotations("create_knowledge_page"))
|
||||
async def create_knowledge_page(
|
||||
name: str,
|
||||
source_query: str,
|
||||
parent_id: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
max_tokens: int | None = None,
|
||||
refresh_after_consolidation: bool | None = None,
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create a knowledge page — a living document answering a question.
|
||||
|
||||
The page's content is synthesized from the bank's memories by running
|
||||
source_query, asynchronously: use the returned operation_id to track
|
||||
completion, then read it with get_knowledge_page. By default the page
|
||||
keeps itself current, rebuilding after each consolidation.
|
||||
|
||||
EXAMPLES:
|
||||
- name="Deployment Runbook", source_query="How is this service deployed and rolled back?"
|
||||
- name="Team Preferences", source_query="What tools and conventions does the team prefer?"
|
||||
|
||||
Args:
|
||||
name: Page name (must be unique within its folder)
|
||||
source_query: The question this page answers and rebuilds itself from
|
||||
parent_id: Optional parent folder id (a `kf-...` node id). Omit to create at the top level.
|
||||
tags: Optional tags scoping which memories the page is built from
|
||||
max_tokens: Maximum tokens for the generated content (default: 4096)
|
||||
refresh_after_consolidation: Whether the page rebuilds itself after each memory
|
||||
consolidation. Omit to keep the knowledge-page default (True).
|
||||
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return '{"error": "No bank_id configured"}'
|
||||
|
||||
result = await _do_create_knowledge_page(
|
||||
memory,
|
||||
target_bank,
|
||||
_get_request_context(config),
|
||||
name=name,
|
||||
source_query=source_query,
|
||||
parent_id=parent_id,
|
||||
tags=tags,
|
||||
max_tokens=max_tokens,
|
||||
refresh_after_consolidation=refresh_after_consolidation,
|
||||
)
|
||||
return json.dumps(result, indent=2, default=str)
|
||||
except OperationValidationError as e:
|
||||
logger.warning(f"Operation rejected: {e}")
|
||||
return json.dumps({"error": str(e)})
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating knowledge page: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}"}}'
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool(annotations=_tool_annotations("create_knowledge_page"))
|
||||
async def create_knowledge_page(
|
||||
name: str,
|
||||
source_query: str,
|
||||
parent_id: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
max_tokens: int | None = None,
|
||||
refresh_after_consolidation: bool | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Create a knowledge page — a living document answering a question.
|
||||
|
||||
The page's content is synthesized from the bank's memories by running
|
||||
source_query, asynchronously: use the returned operation_id to track
|
||||
completion, then read it with get_knowledge_page. By default the page
|
||||
keeps itself current, rebuilding after each consolidation.
|
||||
|
||||
EXAMPLES:
|
||||
- name="Deployment Runbook", source_query="How is this service deployed and rolled back?"
|
||||
- name="Team Preferences", source_query="What tools and conventions does the team prefer?"
|
||||
|
||||
Args:
|
||||
name: Page name (must be unique within its folder)
|
||||
source_query: The question this page answers and rebuilds itself from
|
||||
parent_id: Optional parent folder id (a `kf-...` node id). Omit to create at the top level.
|
||||
tags: Optional tags scoping which memories the page is built from
|
||||
max_tokens: Maximum tokens for the generated content (default: 4096)
|
||||
refresh_after_consolidation: Whether the page rebuilds itself after each memory
|
||||
consolidation. Omit to keep the knowledge-page default (True).
|
||||
"""
|
||||
try:
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"error": "No bank_id configured"}
|
||||
|
||||
return await _do_create_knowledge_page(
|
||||
memory,
|
||||
target_bank,
|
||||
_get_request_context(config),
|
||||
name=name,
|
||||
source_query=source_query,
|
||||
parent_id=parent_id,
|
||||
tags=tags,
|
||||
max_tokens=max_tokens,
|
||||
refresh_after_consolidation=refresh_after_consolidation,
|
||||
)
|
||||
except OperationValidationError as e:
|
||||
logger.warning(f"Operation rejected: {e}")
|
||||
return {"error": str(e)}
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating knowledge page: {e}", exc_info=True)
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def _register_update_knowledge_node(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the update_knowledge_node tool."""
|
||||
|
||||
if config.include_bank_id_param:
|
||||
|
||||
@mcp.tool(annotations=_tool_annotations("update_knowledge_node"))
|
||||
async def update_knowledge_node(
|
||||
node_id: str,
|
||||
name: str | None = None,
|
||||
parent_id: str | None = None,
|
||||
source_query: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
max_tokens: int | None = None,
|
||||
refresh_after_consolidation: bool | None = None,
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Rename or move a folder/page, and/or update a page's options.
|
||||
|
||||
Only the arguments you pass are changed; everything else keeps its
|
||||
current value. Changing source_query schedules an async refresh so the
|
||||
page rebuilds against the new question.
|
||||
|
||||
Args:
|
||||
node_id: The ID of the folder (`kf-...`) or page (`kp-...`) to update
|
||||
name: New name for the node
|
||||
parent_id: Folder id to move the node into, or "root" to move it to the top level
|
||||
source_query: Pages only — the new question the page answers
|
||||
tags: Pages only — replacement tag list (pass [] to clear)
|
||||
max_tokens: Pages only — new maximum tokens for the generated content
|
||||
refresh_after_consolidation: Pages only — whether the page rebuilds itself
|
||||
after each memory consolidation
|
||||
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return '{"error": "No bank_id configured"}'
|
||||
|
||||
result = await _do_update_knowledge_node(
|
||||
memory,
|
||||
target_bank,
|
||||
_get_request_context(config),
|
||||
node_id=node_id,
|
||||
name=name,
|
||||
parent_id=parent_id,
|
||||
source_query=source_query,
|
||||
tags=tags,
|
||||
max_tokens=max_tokens,
|
||||
refresh_after_consolidation=refresh_after_consolidation,
|
||||
)
|
||||
return json.dumps(result, indent=2, default=str)
|
||||
except OperationValidationError as e:
|
||||
logger.warning(f"Operation rejected: {e}")
|
||||
return json.dumps({"error": str(e)})
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating knowledge node: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}"}}'
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool(annotations=_tool_annotations("update_knowledge_node"))
|
||||
async def update_knowledge_node(
|
||||
node_id: str,
|
||||
name: str | None = None,
|
||||
parent_id: str | None = None,
|
||||
source_query: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
max_tokens: int | None = None,
|
||||
refresh_after_consolidation: bool | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Rename or move a folder/page, and/or update a page's options.
|
||||
|
||||
Only the arguments you pass are changed; everything else keeps its
|
||||
current value. Changing source_query schedules an async refresh so the
|
||||
page rebuilds against the new question.
|
||||
|
||||
Args:
|
||||
node_id: The ID of the folder (`kf-...`) or page (`kp-...`) to update
|
||||
name: New name for the node
|
||||
parent_id: Folder id to move the node into, or "root" to move it to the top level
|
||||
source_query: Pages only — the new question the page answers
|
||||
tags: Pages only — replacement tag list (pass [] to clear)
|
||||
max_tokens: Pages only — new maximum tokens for the generated content
|
||||
refresh_after_consolidation: Pages only — whether the page rebuilds itself
|
||||
after each memory consolidation
|
||||
"""
|
||||
try:
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"error": "No bank_id configured"}
|
||||
|
||||
return await _do_update_knowledge_node(
|
||||
memory,
|
||||
target_bank,
|
||||
_get_request_context(config),
|
||||
node_id=node_id,
|
||||
name=name,
|
||||
parent_id=parent_id,
|
||||
source_query=source_query,
|
||||
tags=tags,
|
||||
max_tokens=max_tokens,
|
||||
refresh_after_consolidation=refresh_after_consolidation,
|
||||
)
|
||||
except OperationValidationError as e:
|
||||
logger.warning(f"Operation rejected: {e}")
|
||||
return {"error": str(e)}
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating knowledge node: {e}", exc_info=True)
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def _register_delete_knowledge_node(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the delete_knowledge_node tool."""
|
||||
|
||||
if config.include_bank_id_param:
|
||||
|
||||
@mcp.tool(annotations=_tool_annotations("delete_knowledge_node"))
|
||||
async def delete_knowledge_node(
|
||||
node_id: str,
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Delete a knowledge-base folder or page and everything under it.
|
||||
|
||||
Deleting a folder also deletes its whole subtree, and each deleted page
|
||||
takes its backing mental model with it. This cannot be undone.
|
||||
|
||||
Args:
|
||||
node_id: The ID of the folder (`kf-...`) or page (`kp-...`) to delete
|
||||
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return '{"error": "No bank_id configured"}'
|
||||
|
||||
result = await _do_delete_knowledge_node(
|
||||
memory, target_bank, _get_request_context(config), node_id=node_id
|
||||
)
|
||||
return json.dumps(result)
|
||||
except OperationValidationError as e:
|
||||
logger.warning(f"Operation rejected: {e}")
|
||||
return json.dumps({"error": str(e)})
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting knowledge node: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}"}}'
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool(annotations=_tool_annotations("delete_knowledge_node"))
|
||||
async def delete_knowledge_node(
|
||||
node_id: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Delete a knowledge-base folder or page and everything under it.
|
||||
|
||||
Deleting a folder also deletes its whole subtree, and each deleted page
|
||||
takes its backing mental model with it. This cannot be undone.
|
||||
|
||||
Args:
|
||||
node_id: The ID of the folder (`kf-...`) or page (`kp-...`) to delete
|
||||
"""
|
||||
try:
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"error": "No bank_id configured"}
|
||||
|
||||
return await _do_delete_knowledge_node(
|
||||
memory, target_bank, _get_request_context(config), node_id=node_id
|
||||
)
|
||||
except OperationValidationError as e:
|
||||
logger.warning(f"Operation rejected: {e}")
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting knowledge node: {e}", exc_info=True)
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# DIRECTIVE TOOLS
|
||||
# =========================================================================
|
||||
|
||||
@@ -360,6 +360,26 @@ async def test_middleware_handles_both_endpoints(mock_memory):
|
||||
assert "create_bank" not in single_bank_tools
|
||||
|
||||
|
||||
def test_registered_tool_sets_match_the_allowlists(mock_memory):
|
||||
"""Every allowlisted tool name must resolve to a really-registered tool.
|
||||
|
||||
Three hand-maintained lists have to agree: ``_ALL_TOOLS``, the default set in
|
||||
``register_mcp_tools()``, and the single-bank allowlist in
|
||||
``create_mcp_server()``. A name added to one but not the others is silent —
|
||||
the tool simply never appears on the endpoint and nothing fails — so assert
|
||||
over the whole sets rather than spot-checking tool names.
|
||||
"""
|
||||
from hindsight_api.api.mcp import create_mcp_server
|
||||
from hindsight_api.mcp_tools import _ALL_TOOLS
|
||||
|
||||
multi = set(_tools(create_mcp_server(mock_memory, multi_bank=True)))
|
||||
single = set(_tools(create_mcp_server(mock_memory, multi_bank=False)))
|
||||
|
||||
assert multi == set(_ALL_TOOLS)
|
||||
# Single-bank mode drops exactly the tools that operate across banks.
|
||||
assert multi - single == {"list_banks", "create_bank", "get_bank_stats"}
|
||||
|
||||
|
||||
def test_global_mcp_enabled_tools_filter_restricts_registered_tools(mock_memory):
|
||||
"""Test that global mcp_enabled_tools env setting restricts which tools are registered."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -9,7 +9,9 @@ import pytest
|
||||
|
||||
from hindsight_api.engine.memory_engine import DirectivePage, MentalModelPage
|
||||
from hindsight_api.mcp_tools import (
|
||||
KNOWLEDGE_ROOT_PARENT,
|
||||
MCPToolsConfig,
|
||||
_knowledge_tree_json,
|
||||
_validate_mental_model_inputs,
|
||||
build_content_dict,
|
||||
parse_timestamp,
|
||||
@@ -127,6 +129,43 @@ def _apply_detail(model: dict, detail: str) -> dict:
|
||||
return model
|
||||
|
||||
|
||||
# Knowledge-base fixtures: a root folder with one page under it, shaped like the
|
||||
# dicts MemoryEngine._row_to_knowledge_node returns.
|
||||
_KNOWLEDGE_FOLDER: dict[str, Any] = {
|
||||
"id": "kf-1",
|
||||
"kind": "folder",
|
||||
"name": "Runbooks",
|
||||
"parent_id": None,
|
||||
"mental_model_id": None,
|
||||
"managed": False,
|
||||
"updated_at": "2026-01-01T00:00:00+00:00",
|
||||
}
|
||||
|
||||
_KNOWLEDGE_PAGE_NODE: dict[str, Any] = {
|
||||
"id": "kp-1",
|
||||
"kind": "page",
|
||||
"name": "Deploys",
|
||||
"parent_id": "kf-1",
|
||||
"mental_model_id": "mm-page",
|
||||
"managed": False,
|
||||
"tags": ["ops"],
|
||||
"source_query": "How is the service deployed?",
|
||||
"last_refreshed_at": "2026-01-02T00:00:00+00:00",
|
||||
"trigger": {"refresh_after_consolidation": True},
|
||||
}
|
||||
|
||||
_KNOWLEDGE_NODES: list[dict[str, Any]] = [
|
||||
_KNOWLEDGE_FOLDER,
|
||||
{**_KNOWLEDGE_PAGE_NODE, "is_stale": False},
|
||||
]
|
||||
|
||||
_KNOWLEDGE_PAGE: dict[str, Any] = {
|
||||
**_KNOWLEDGE_PAGE_NODE,
|
||||
"tags": ["type:runbook", "ops"],
|
||||
"content": "Run `make deploy`.",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_memory():
|
||||
"""Create a mock MemoryEngine with all MCP tool methods."""
|
||||
@@ -221,6 +260,28 @@ def mock_memory():
|
||||
memory.update_bank = AsyncMock(side_effect=_update_bank)
|
||||
memory.list_banks = AsyncMock(return_value=[])
|
||||
|
||||
# Knowledge base methods
|
||||
memory.list_knowledge_nodes = AsyncMock(return_value=list(_KNOWLEDGE_NODES))
|
||||
memory.get_knowledge_page = AsyncMock(return_value=dict(_KNOWLEDGE_PAGE))
|
||||
memory.search_knowledge_pages = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"id": "kp-1",
|
||||
"name": "Deploys",
|
||||
"mental_model_id": "mm-page",
|
||||
"snippet": "How the service is deployed",
|
||||
"score": 0.9,
|
||||
"updated_at": "2026-01-02T00:00:00+00:00",
|
||||
}
|
||||
]
|
||||
)
|
||||
memory.create_knowledge_folder = AsyncMock(return_value=dict(_KNOWLEDGE_FOLDER))
|
||||
memory.create_knowledge_page = AsyncMock(return_value=dict(_KNOWLEDGE_PAGE_NODE))
|
||||
memory.rename_knowledge_node = AsyncMock(return_value=dict(_KNOWLEDGE_PAGE_NODE))
|
||||
memory.move_knowledge_node = AsyncMock(return_value=dict(_KNOWLEDGE_PAGE_NODE))
|
||||
memory.update_knowledge_page = AsyncMock(return_value=dict(_KNOWLEDGE_PAGE_NODE))
|
||||
memory.delete_knowledge_node = AsyncMock(return_value=True)
|
||||
|
||||
return memory
|
||||
|
||||
|
||||
@@ -387,7 +448,10 @@ class TestMentalModelToolRegistration:
|
||||
assert "clear_mental_model" in tools
|
||||
assert "update_memory" in tools
|
||||
assert "invalidate_memory" in tools
|
||||
assert len(tools) == 32
|
||||
assert "get_knowledge_base_tree" in tools
|
||||
assert "create_knowledge_page" in tools
|
||||
assert "delete_knowledge_node" in tools
|
||||
assert len(tools) == 39
|
||||
|
||||
def test_all_tools_have_nonempty_descriptions(self):
|
||||
"""Every registered tool must expose a non-empty description.
|
||||
@@ -1892,6 +1956,229 @@ class TestEmptyListReturns:
|
||||
assert '"items": []' in result or "[]" in result
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Knowledge Base Tool Tests
|
||||
# =========================================================================
|
||||
|
||||
_KB_TOOLS = {
|
||||
"get_knowledge_base_tree",
|
||||
"search_knowledge_base",
|
||||
"get_knowledge_page",
|
||||
"create_knowledge_folder",
|
||||
"create_knowledge_page",
|
||||
"update_knowledge_node",
|
||||
"delete_knowledge_node",
|
||||
}
|
||||
|
||||
|
||||
class TestKnowledgeTreeProjection:
|
||||
"""The flat node list nests into roots, with pages projected from their model."""
|
||||
|
||||
def test_nests_pages_under_their_folder(self):
|
||||
roots = _knowledge_tree_json(_KNOWLEDGE_NODES)
|
||||
assert [r["id"] for r in roots] == ["kf-1"]
|
||||
assert [c["id"] for c in roots[0]["children"]] == ["kp-1"]
|
||||
|
||||
def test_page_carries_mental_model_metadata(self):
|
||||
page = _knowledge_tree_json(_KNOWLEDGE_NODES)[0]["children"][0]
|
||||
assert page["description"] == "How is the service deployed?"
|
||||
assert page["tags"] == ["ops"]
|
||||
assert page["timestamp"] == "2026-01-02T00:00:00+00:00"
|
||||
assert page["is_stale"] is False
|
||||
assert page["trigger"] == {"refresh_after_consolidation": True}
|
||||
|
||||
def test_orphaned_node_becomes_a_root(self):
|
||||
# A node whose parent is not in the list (e.g. a partial read) must still
|
||||
# surface rather than vanish from the tree.
|
||||
roots = _knowledge_tree_json([{**_KNOWLEDGE_PAGE_NODE, "parent_id": "kf-missing"}])
|
||||
assert [r["id"] for r in roots] == ["kp-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestKnowledgeBaseTools:
|
||||
async def test_tools_registered(self, mock_memory):
|
||||
mcp = _make_mcp_server(mock_memory, _KB_TOOLS, include_bank_id=True)
|
||||
assert _KB_TOOLS == set(_tools(mcp).keys())
|
||||
|
||||
async def test_tools_registered_single_bank(self, mock_memory):
|
||||
mcp = _make_mcp_server(mock_memory, _KB_TOOLS, include_bank_id=False)
|
||||
assert _KB_TOOLS == set(_tools(mcp).keys())
|
||||
|
||||
async def test_get_tree(self, mock_memory):
|
||||
mcp = _make_mcp_server(mock_memory, {"get_knowledge_base_tree"}, include_bank_id=True)
|
||||
result = json.loads(await _tools(mcp)["get_knowledge_base_tree"].fn())
|
||||
assert [r["id"] for r in result["roots"]] == ["kf-1"]
|
||||
assert mock_memory.list_knowledge_nodes.call_args.kwargs["with_staleness"] is True
|
||||
|
||||
async def test_get_tree_single_bank(self, mock_memory):
|
||||
mcp = _make_mcp_server(mock_memory, {"get_knowledge_base_tree"}, include_bank_id=False)
|
||||
result = await _tools(mcp)["get_knowledge_base_tree"].fn()
|
||||
assert isinstance(result, dict)
|
||||
assert result["roots"][0]["kind"] == "folder"
|
||||
|
||||
async def test_search(self, mock_memory):
|
||||
mcp = _make_mcp_server(mock_memory, {"search_knowledge_base"}, include_bank_id=True)
|
||||
result = json.loads(await _tools(mcp)["search_knowledge_base"].fn(query="deploy", limit=5))
|
||||
assert result["total"] == 1
|
||||
assert result["results"][0]["id"] == "kp-1"
|
||||
call_kwargs = mock_memory.search_knowledge_pages.call_args.kwargs
|
||||
assert call_kwargs["query"] == "deploy"
|
||||
assert call_kwargs["limit"] == 5
|
||||
|
||||
async def test_search_clamps_limit(self, mock_memory):
|
||||
mcp = _make_mcp_server(mock_memory, {"search_knowledge_base"}, include_bank_id=True)
|
||||
await _tools(mcp)["search_knowledge_base"].fn(query="deploy", limit=500)
|
||||
assert mock_memory.search_knowledge_pages.call_args.kwargs["limit"] == 50
|
||||
|
||||
async def test_get_page_renders_markdown(self, mock_memory):
|
||||
mcp = _make_mcp_server(mock_memory, {"get_knowledge_page"}, include_bank_id=True)
|
||||
result = json.loads(await _tools(mcp)["get_knowledge_page"].fn(page_id="kp-1"))
|
||||
# The `type:` tag becomes the page type and drops out of the displayed tags.
|
||||
assert result["type"] == "runbook"
|
||||
assert result["tags"] == ["ops"]
|
||||
assert result["markdown"].startswith("---\n")
|
||||
assert "Run `make deploy`." in result["markdown"]
|
||||
|
||||
async def test_get_page_not_found(self, mock_memory):
|
||||
mock_memory.get_knowledge_page.return_value = None
|
||||
mcp = _make_mcp_server(mock_memory, {"get_knowledge_page"}, include_bank_id=True)
|
||||
result = await _tools(mcp)["get_knowledge_page"].fn(page_id="kp-missing")
|
||||
assert "not found" in result
|
||||
|
||||
async def test_create_folder(self, mock_memory):
|
||||
mcp = _make_mcp_server(mock_memory, {"create_knowledge_folder"}, include_bank_id=True)
|
||||
result = json.loads(await _tools(mcp)["create_knowledge_folder"].fn(name="Runbooks"))
|
||||
assert result["id"] == "kf-1"
|
||||
assert result["children"] == []
|
||||
assert mock_memory.create_knowledge_folder.call_args.kwargs["parent_id"] is None
|
||||
|
||||
async def test_create_folder_rejects_bad_parent(self, mock_memory):
|
||||
mock_memory.create_knowledge_folder.side_effect = ValueError("Parent folder 'kf-x' not found")
|
||||
mcp = _make_mcp_server(mock_memory, {"create_knowledge_folder"}, include_bank_id=True)
|
||||
result = await _tools(mcp)["create_knowledge_folder"].fn(name="Runbooks", parent_id="kf-x")
|
||||
assert "not found" in result
|
||||
|
||||
async def test_create_page_schedules_refresh(self, mock_memory):
|
||||
mcp = _make_mcp_server(mock_memory, {"create_knowledge_page"}, include_bank_id=True)
|
||||
result = json.loads(
|
||||
await _tools(mcp)["create_knowledge_page"].fn(name="Deploys", source_query="How is it deployed?")
|
||||
)
|
||||
assert result["page_id"] == "kp-1"
|
||||
assert result["operation_id"] == "op-123"
|
||||
create_kwargs = mock_memory.create_knowledge_page.call_args.kwargs
|
||||
# An unstated refresh setting must not overwrite the engine's page defaults.
|
||||
assert create_kwargs["trigger"] is None
|
||||
assert mock_memory.submit_async_refresh_mental_model.call_args.kwargs["mental_model_id"] == "mm-page"
|
||||
|
||||
async def test_create_page_with_refresh_flag(self, mock_memory):
|
||||
mcp = _make_mcp_server(mock_memory, {"create_knowledge_page"}, include_bank_id=True)
|
||||
await _tools(mcp)["create_knowledge_page"].fn(
|
||||
name="Deploys", source_query="q", tags=["ops"], max_tokens=512, refresh_after_consolidation=False
|
||||
)
|
||||
create_kwargs = mock_memory.create_knowledge_page.call_args.kwargs
|
||||
assert create_kwargs["trigger"] == {"refresh_after_consolidation": False}
|
||||
assert create_kwargs["tags"] == ["ops"]
|
||||
assert create_kwargs["max_tokens"] == 512
|
||||
|
||||
async def test_create_page_duplicate_name(self, mock_memory):
|
||||
mock_memory.create_knowledge_page.return_value = None
|
||||
mcp = _make_mcp_server(mock_memory, {"create_knowledge_page"}, include_bank_id=True)
|
||||
result = await _tools(mcp)["create_knowledge_page"].fn(name="Deploys", source_query="q")
|
||||
assert "already exists" in result
|
||||
mock_memory.submit_async_refresh_mental_model.assert_not_called()
|
||||
|
||||
async def test_update_rename(self, mock_memory):
|
||||
mcp = _make_mcp_server(mock_memory, {"update_knowledge_node"}, include_bank_id=True)
|
||||
result = json.loads(await _tools(mcp)["update_knowledge_node"].fn(node_id="kp-1", name="Deployments"))
|
||||
assert result["id"] == "kp-1"
|
||||
assert mock_memory.rename_knowledge_node.call_args.kwargs["name"] == "Deployments"
|
||||
mock_memory.update_knowledge_page.assert_not_called()
|
||||
mock_memory.move_knowledge_node.assert_not_called()
|
||||
|
||||
async def test_update_move_to_folder(self, mock_memory):
|
||||
mcp = _make_mcp_server(mock_memory, {"update_knowledge_node"}, include_bank_id=True)
|
||||
await _tools(mcp)["update_knowledge_node"].fn(node_id="kp-1", parent_id="kf-2")
|
||||
assert mock_memory.move_knowledge_node.call_args.kwargs["new_parent_id"] == "kf-2"
|
||||
|
||||
async def test_update_move_to_root(self, mock_memory):
|
||||
mcp = _make_mcp_server(mock_memory, {"update_knowledge_node"}, include_bank_id=True)
|
||||
await _tools(mcp)["update_knowledge_node"].fn(node_id="kp-1", parent_id=KNOWLEDGE_ROOT_PARENT)
|
||||
assert mock_memory.move_knowledge_node.call_args.kwargs["new_parent_id"] is None
|
||||
|
||||
async def test_update_source_query_triggers_refresh(self, mock_memory):
|
||||
mcp = _make_mcp_server(mock_memory, {"update_knowledge_node"}, include_bank_id=True)
|
||||
await _tools(mcp)["update_knowledge_node"].fn(node_id="kp-1", source_query="new question?")
|
||||
assert mock_memory.update_knowledge_page.call_args.kwargs["source_query"] == "new question?"
|
||||
assert mock_memory.submit_async_refresh_mental_model.call_args.kwargs["mental_model_id"] == "mm-page"
|
||||
|
||||
async def test_update_tags_only_does_not_refresh(self, mock_memory):
|
||||
mcp = _make_mcp_server(mock_memory, {"update_knowledge_node"}, include_bank_id=True)
|
||||
await _tools(mcp)["update_knowledge_node"].fn(node_id="kp-1", tags=[])
|
||||
assert mock_memory.update_knowledge_page.call_args.kwargs["tags"] == []
|
||||
mock_memory.submit_async_refresh_mental_model.assert_not_called()
|
||||
|
||||
async def test_update_requires_a_field(self, mock_memory):
|
||||
mcp = _make_mcp_server(mock_memory, {"update_knowledge_node"}, include_bank_id=True)
|
||||
result = await _tools(mcp)["update_knowledge_node"].fn(node_id="kp-1")
|
||||
assert "Provide name" in result
|
||||
mock_memory.rename_knowledge_node.assert_not_called()
|
||||
mock_memory.update_knowledge_page.assert_not_called()
|
||||
|
||||
async def test_update_not_found(self, mock_memory):
|
||||
mock_memory.rename_knowledge_node.return_value = None
|
||||
mcp = _make_mcp_server(mock_memory, {"update_knowledge_node"}, include_bank_id=True)
|
||||
result = await _tools(mcp)["update_knowledge_node"].fn(node_id="kp-missing", name="x")
|
||||
assert "not found" in result
|
||||
|
||||
async def test_delete_node(self, mock_memory):
|
||||
mcp = _make_mcp_server(mock_memory, {"delete_knowledge_node"}, include_bank_id=True)
|
||||
result = json.loads(await _tools(mcp)["delete_knowledge_node"].fn(node_id="kp-1"))
|
||||
assert result == {"status": "deleted", "node_id": "kp-1"}
|
||||
|
||||
async def test_delete_node_not_found(self, mock_memory):
|
||||
mock_memory.delete_knowledge_node.return_value = False
|
||||
mcp = _make_mcp_server(mock_memory, {"delete_knowledge_node"}, include_bank_id=True)
|
||||
result = await _tools(mcp)["delete_knowledge_node"].fn(node_id="kp-missing")
|
||||
assert "not found" in result
|
||||
|
||||
async def test_no_bank_configured(self, mock_memory):
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("test")
|
||||
register_mcp_tools(
|
||||
mcp,
|
||||
mock_memory,
|
||||
MCPToolsConfig(bank_id_resolver=lambda: None, include_bank_id_param=True, tools=_KB_TOOLS),
|
||||
)
|
||||
result = await _tools(mcp)["get_knowledge_base_tree"].fn()
|
||||
assert "No bank_id configured" in result
|
||||
|
||||
async def test_request_context_is_propagated(self, mock_memory):
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("test")
|
||||
register_mcp_tools(
|
||||
mcp,
|
||||
mock_memory,
|
||||
MCPToolsConfig(
|
||||
bank_id_resolver=lambda: "test-bank",
|
||||
api_key_resolver=lambda: "secret",
|
||||
include_bank_id_param=True,
|
||||
tools={"get_knowledge_base_tree"},
|
||||
),
|
||||
)
|
||||
await _tools(mcp)["get_knowledge_base_tree"].fn()
|
||||
assert mock_memory.list_knowledge_nodes.call_args.kwargs["request_context"].api_key == "secret"
|
||||
|
||||
async def test_read_only_annotations(self, mock_memory):
|
||||
mcp = _make_mcp_server(mock_memory, _KB_TOOLS)
|
||||
tools = _tools(mcp)
|
||||
for name in ("get_knowledge_base_tree", "search_knowledge_base", "get_knowledge_page"):
|
||||
assert tools[name].annotations.readOnlyHint is True, name
|
||||
assert tools["delete_knowledge_node"].annotations.destructiveHint is True
|
||||
assert tools["create_knowledge_page"].annotations.destructiveHint is False
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Bank-Level Tool Filtering Tests
|
||||
# =========================================================================
|
||||
|
||||
@@ -215,6 +215,19 @@ function getMcpToolGroups(t: (key: string) => string): McpToolGroup[] {
|
||||
tools: ["list_operations", "get_operation", "cancel_operation"],
|
||||
},
|
||||
{ key: "tags", label: t("mcpGroupTags"), tools: ["list_tags"] },
|
||||
{
|
||||
key: "knowledgeBase",
|
||||
label: t("mcpGroupKnowledgeBase"),
|
||||
tools: [
|
||||
"get_knowledge_base_tree",
|
||||
"search_knowledge_base",
|
||||
"get_knowledge_page",
|
||||
"create_knowledge_folder",
|
||||
"create_knowledge_page",
|
||||
"update_knowledge_node",
|
||||
"delete_knowledge_node",
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -251,6 +264,13 @@ const MCP_ALL_TOOLS: string[] = [
|
||||
"get_operation",
|
||||
"cancel_operation",
|
||||
"list_tags",
|
||||
"get_knowledge_base_tree",
|
||||
"search_knowledge_base",
|
||||
"get_knowledge_page",
|
||||
"create_knowledge_folder",
|
||||
"create_knowledge_page",
|
||||
"update_knowledge_node",
|
||||
"delete_knowledge_node",
|
||||
];
|
||||
const ALL_TOOLS: string[] = MCP_ALL_TOOLS;
|
||||
|
||||
|
||||
@@ -410,6 +410,7 @@
|
||||
"mcpGroupDocuments": "Dokumente",
|
||||
"mcpGroupOperations": "Operationen",
|
||||
"mcpGroupTags": "Tags",
|
||||
"mcpGroupKnowledgeBase": "Wissensdatenbank",
|
||||
"retainSectionDescription": "Standardeinstellungen für die Extraktion und benannte Strategien. Übergeben Sie einen Strategienamen in Retain-Anfragen, um Standardwerte pro Element zu überschreiben.",
|
||||
"defaultStrategyLabel": "Standardstrategie",
|
||||
"defaultStrategyDescription": "Wird automatisch angewendet, wenn keine Strategie in einer Anfrage angegeben ist.",
|
||||
|
||||
@@ -410,6 +410,7 @@
|
||||
"mcpGroupDocuments": "Documents",
|
||||
"mcpGroupOperations": "Operations",
|
||||
"mcpGroupTags": "Tags",
|
||||
"mcpGroupKnowledgeBase": "Knowledge base",
|
||||
"retainSectionDescription": "Default extraction settings and named strategies. Pass a strategy name on retain requests to override defaults per-item.",
|
||||
"defaultStrategyLabel": "Default strategy",
|
||||
"defaultStrategyDescription": "Applied automatically when no strategy is specified on a request.",
|
||||
|
||||
@@ -410,6 +410,7 @@
|
||||
"mcpGroupDocuments": "Documentos",
|
||||
"mcpGroupOperations": "Operaciones",
|
||||
"mcpGroupTags": "Etiquetas",
|
||||
"mcpGroupKnowledgeBase": "Base de conocimiento",
|
||||
"retainSectionDescription": "Configuración de extracción predeterminada y estrategias con nombre. Pasa un nombre de estrategia en las solicitudes de retención para anular los valores predeterminados por elemento.",
|
||||
"defaultStrategyLabel": "Estrategia predeterminada",
|
||||
"defaultStrategyDescription": "Se aplica automáticamente cuando no se especifica una estrategia en una solicitud.",
|
||||
|
||||
@@ -410,6 +410,7 @@
|
||||
"mcpGroupDocuments": "Documents",
|
||||
"mcpGroupOperations": "Opérations",
|
||||
"mcpGroupTags": "Tags",
|
||||
"mcpGroupKnowledgeBase": "Base de connaissances",
|
||||
"retainSectionDescription": "Paramètres d'extraction par défaut et stratégies nommées. Passez un nom de stratégie dans les requêtes de rétention pour remplacer les valeurs par défaut au cas par cas.",
|
||||
"defaultStrategyLabel": "Stratégie par défaut",
|
||||
"defaultStrategyDescription": "Appliquée automatiquement lorsqu'aucune stratégie n'est spécifiée dans une requête.",
|
||||
|
||||
@@ -410,6 +410,7 @@
|
||||
"mcpGroupDocuments": "ドキュメント",
|
||||
"mcpGroupOperations": "オペレーション",
|
||||
"mcpGroupTags": "タグ",
|
||||
"mcpGroupKnowledgeBase": "ナレッジベース",
|
||||
"retainSectionDescription": "デフォルトの抽出設定と名前付き戦略。retain リクエストで戦略名を渡すと、項目ごとにデフォルトを上書きできます。",
|
||||
"defaultStrategyLabel": "デフォルト戦略",
|
||||
"defaultStrategyDescription": "リクエストで戦略が指定されていない場合、自動的に適用されます。",
|
||||
|
||||
@@ -410,6 +410,7 @@
|
||||
"mcpGroupDocuments": "문서",
|
||||
"mcpGroupOperations": "작업",
|
||||
"mcpGroupTags": "태그",
|
||||
"mcpGroupKnowledgeBase": "지식 베이스",
|
||||
"retainSectionDescription": "기본 추출 설정 및 명명된 전략. retain 요청에 전략 이름을 전달하여 항목별로 기본값을 재정의합니다.",
|
||||
"defaultStrategyLabel": "기본 전략",
|
||||
"defaultStrategyDescription": "요청에 전략이 지정되지 않은 경우 자동으로 적용됩니다.",
|
||||
|
||||
@@ -410,6 +410,7 @@
|
||||
"mcpGroupDocuments": "Documentos",
|
||||
"mcpGroupOperations": "Operações",
|
||||
"mcpGroupTags": "Tags",
|
||||
"mcpGroupKnowledgeBase": "Base de conhecimento",
|
||||
"retainSectionDescription": "Configurações de extração padrão e estratégias nomeadas. Passe um nome de estratégia nas solicitações de retenção para substituir os padrões por item.",
|
||||
"defaultStrategyLabel": "Estratégia padrão",
|
||||
"defaultStrategyDescription": "Aplicada automaticamente quando nenhuma estratégia é especificada em uma solicitação.",
|
||||
|
||||
@@ -410,6 +410,7 @@
|
||||
"mcpGroupDocuments": "文件",
|
||||
"mcpGroupOperations": "操作",
|
||||
"mcpGroupTags": "標籤",
|
||||
"mcpGroupKnowledgeBase": "知識庫",
|
||||
"retainSectionDescription": "預設擷取設定和命名策略。在 retain 請求中傳入策略名稱,可逐項覆寫預設值。",
|
||||
"defaultStrategyLabel": "預設策略",
|
||||
"defaultStrategyDescription": "當請求未指定策略時會自動套用。",
|
||||
|
||||
@@ -410,6 +410,7 @@
|
||||
"mcpGroupDocuments": "文档",
|
||||
"mcpGroupOperations": "操作",
|
||||
"mcpGroupTags": "标签",
|
||||
"mcpGroupKnowledgeBase": "知识库",
|
||||
"retainSectionDescription": "默认提取设置和命名策略。在 retain 请求中传递策略名称可按项覆盖默认值。",
|
||||
"defaultStrategyLabel": "默认策略",
|
||||
"defaultStrategyDescription": "当请求未指定策略时自动应用。",
|
||||
|
||||
@@ -410,6 +410,7 @@
|
||||
"mcpGroupDocuments": "文件",
|
||||
"mcpGroupOperations": "操作",
|
||||
"mcpGroupTags": "標籤",
|
||||
"mcpGroupKnowledgeBase": "知識庫",
|
||||
"retainSectionDescription": "預設擷取設定和命名策略。在 retain 請求中傳入策略名稱,可逐項覆寫預設值。",
|
||||
"defaultStrategyLabel": "預設策略",
|
||||
"defaultStrategyDescription": "當請求未指定策略時自動應用。",
|
||||
|
||||
@@ -133,6 +133,18 @@ The local server exposes the full tool set (29 tools in multi-bank mode, 26 in s
|
||||
| `list_banks` | List all memory banks (multi-bank only) |
|
||||
| `create_bank` | Create or configure a memory bank (multi-bank only) |
|
||||
|
||||
**Knowledge Base**
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `get_knowledge_base_tree` | Browse the folder/page tree |
|
||||
| `search_knowledge_base` | Hybrid search over knowledge pages |
|
||||
| `get_knowledge_page` | Read a page as a markdown document |
|
||||
| `create_knowledge_folder` | Create a folder |
|
||||
| `create_knowledge_page` | Create a page (a living document over the bank's memories) |
|
||||
| `update_knowledge_node` | Rename/move a node or update a page's options |
|
||||
| `delete_knowledge_node` | Delete a folder or page and its subtree |
|
||||
|
||||
For detailed parameter documentation, see the [MCP Server reference](/developer/mcp-server#available-tools).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
@@ -284,7 +284,7 @@ An allowlist of MCP tool names that are enabled for this bank. When set, only th
|
||||
["recall", "reflect"]
|
||||
```
|
||||
|
||||
Available tool names: `retain`, `recall`, `reflect`, `list_banks`, `create_bank`, `list_mental_models`, `get_mental_model`, `create_mental_model`, `update_mental_model`, `delete_mental_model`, `refresh_mental_model`, `list_directives`, `create_directive`, `delete_directive`, `list_memories`, `get_memory`, `list_documents`, `get_document`, `delete_document`, `list_operations`, `get_operation`, `cancel_operation`, `list_tags`, `get_bank`, `get_bank_stats`, `update_bank`, `delete_bank`, `clear_memories`.
|
||||
Available tool names: `retain`, `recall`, `reflect`, `list_banks`, `create_bank`, `list_mental_models`, `get_mental_model`, `create_mental_model`, `update_mental_model`, `delete_mental_model`, `refresh_mental_model`, `list_directives`, `create_directive`, `delete_directive`, `list_memories`, `get_memory`, `list_documents`, `get_document`, `delete_document`, `list_operations`, `get_operation`, `cancel_operation`, `list_tags`, `get_bank`, `get_bank_stats`, `update_bank`, `delete_bank`, `clear_memories`, `get_knowledge_base_tree`, `search_knowledge_base`, `get_knowledge_page`, `create_knowledge_folder`, `create_knowledge_page`, `update_knowledge_node`, `delete_knowledge_node`.
|
||||
|
||||
### llm_gemini_safety_settings
|
||||
|
||||
|
||||
@@ -1904,7 +1904,7 @@ export HINDSIGHT_API_MCP_ENABLED_TOOLS=recall
|
||||
export HINDSIGHT_API_MCP_ENABLED_TOOLS=recall,reflect
|
||||
```
|
||||
|
||||
Available tool names: `retain`, `recall`, `reflect`, `list_banks`, `create_bank`, `list_mental_models`, `get_mental_model`, `create_mental_model`, `update_mental_model`, `delete_mental_model`, `refresh_mental_model`, `list_directives`, `create_directive`, `delete_directive`, `list_memories`, `get_memory`, `list_documents`, `get_document`, `delete_document`, `list_operations`, `get_operation`, `cancel_operation`, `list_tags`, `get_bank`, `get_bank_stats`, `update_bank`, `delete_bank`, `clear_memories`.
|
||||
Available tool names: `retain`, `recall`, `reflect`, `list_banks`, `create_bank`, `list_mental_models`, `get_mental_model`, `create_mental_model`, `update_mental_model`, `delete_mental_model`, `refresh_mental_model`, `list_directives`, `create_directive`, `delete_directive`, `list_memories`, `get_memory`, `list_documents`, `get_document`, `delete_document`, `list_operations`, `get_operation`, `cancel_operation`, `list_tags`, `get_bank`, `get_bank_stats`, `update_bank`, `delete_bank`, `clear_memories`, `get_knowledge_base_tree`, `search_knowledge_base`, `get_knowledge_page`, `create_knowledge_folder`, `create_knowledge_page`, `update_knowledge_node`, `delete_knowledge_node`.
|
||||
|
||||
This can also be overridden per bank via the [config API](#hierarchical-configuration):
|
||||
|
||||
|
||||
@@ -599,6 +599,91 @@ Clear all memories from a bank without deleting the bank itself. Optionally filt
|
||||
|
||||
---
|
||||
|
||||
### get_knowledge_base_tree
|
||||
|
||||
Browse the knowledge base as a nested tree of folders and pages. Each page reports `is_stale`: `false` means it is provably up to date, `true` means the bank has been written to since the page last refreshed.
|
||||
|
||||
---
|
||||
|
||||
### search_knowledge_base
|
||||
|
||||
Find knowledge pages by relevance (hybrid BM25 + vector search over page names and content). Returns ranked pages with a snippet each.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `query` | string | Yes | What to search for |
|
||||
| `limit` | integer | No | Maximum pages to return, 1–50 (default: 10) |
|
||||
|
||||
---
|
||||
|
||||
### get_knowledge_page
|
||||
|
||||
Read a knowledge page as a markdown document (YAML frontmatter + synthesized body).
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `page_id` | string | Yes | The ID of the page to read (a `kp-...` node id) |
|
||||
|
||||
---
|
||||
|
||||
### create_knowledge_folder
|
||||
|
||||
Create a folder in the knowledge base. Folders group pages and hold no content of their own.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `name` | string | Yes | Folder name |
|
||||
| `parent_id` | string | No | Parent folder id (a `kf-...` node id). Omit to create at the top level |
|
||||
|
||||
---
|
||||
|
||||
### create_knowledge_page
|
||||
|
||||
Create a page — a living document whose content is synthesized from the bank's memories by running `source_query`. Content is generated asynchronously; use the returned `operation_id` to track completion.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `name` | string | Yes | Page name (unique within its folder) |
|
||||
| `source_query` | string | Yes | The question this page answers and rebuilds itself from |
|
||||
| `parent_id` | string | No | Parent folder id (a `kf-...` node id). Omit to create at the top level |
|
||||
| `tags` | array | No | Tags scoping which memories the page is built from |
|
||||
| `max_tokens` | integer | No | Maximum tokens for the generated content (default: 4096) |
|
||||
| `refresh_after_consolidation` | boolean | No | Whether the page rebuilds after each consolidation. Omit to keep the knowledge-page default (`true`) |
|
||||
|
||||
---
|
||||
|
||||
### update_knowledge_node
|
||||
|
||||
Rename or move a folder/page, and/or update a page's options. Only the arguments you pass are changed. Changing `source_query` schedules an async refresh so the page rebuilds against the new question.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `node_id` | string | Yes | The folder (`kf-...`) or page (`kp-...`) to update |
|
||||
| `name` | string | No | New name for the node |
|
||||
| `parent_id` | string | No | Folder id to move the node into, or `"root"` to move it to the top level |
|
||||
| `source_query` | string | No | Pages only — the new question the page answers |
|
||||
| `tags` | array | No | Pages only — replacement tag list (pass `[]` to clear) |
|
||||
| `max_tokens` | integer | No | Pages only — new maximum tokens for the generated content |
|
||||
| `refresh_after_consolidation` | boolean | No | Pages only — whether the page rebuilds after each consolidation |
|
||||
|
||||
---
|
||||
|
||||
### delete_knowledge_node
|
||||
|
||||
Delete a folder or page and its whole subtree. Each deleted page takes its backing mental model with it.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `node_id` | string | Yes | The folder (`kf-...`) or page (`kp-...`) to delete |
|
||||
|
||||
:::note
|
||||
Exporting the knowledge base is deliberately not an MCP tool — it returns the whole
|
||||
bank as a single markdown bundle. Use the HTTP endpoint
|
||||
`GET /v1/default/banks/{bank_id}/knowledge-base/export` instead.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Integration with AI Assistants
|
||||
|
||||
The MCP server can be used with any MCP-compatible AI assistant. See the [Authentication](#authentication) section above for Claude Code and Claude Desktop configuration examples.
|
||||
|
||||
@@ -303,7 +303,7 @@ An allowlist of MCP tool names that are enabled for this bank. When set, only th
|
||||
["recall", "reflect"]
|
||||
```
|
||||
|
||||
Available tool names: `retain`, `recall`, `reflect`, `list_banks`, `create_bank`, `list_mental_models`, `get_mental_model`, `create_mental_model`, `update_mental_model`, `delete_mental_model`, `refresh_mental_model`, `list_directives`, `create_directive`, `delete_directive`, `list_memories`, `get_memory`, `list_documents`, `get_document`, `delete_document`, `list_operations`, `get_operation`, `cancel_operation`, `list_tags`, `get_bank`, `get_bank_stats`, `update_bank`, `delete_bank`, `clear_memories`.
|
||||
Available tool names: `retain`, `recall`, `reflect`, `list_banks`, `create_bank`, `list_mental_models`, `get_mental_model`, `create_mental_model`, `update_mental_model`, `delete_mental_model`, `refresh_mental_model`, `list_directives`, `create_directive`, `delete_directive`, `list_memories`, `get_memory`, `list_documents`, `get_document`, `delete_document`, `list_operations`, `get_operation`, `cancel_operation`, `list_tags`, `get_bank`, `get_bank_stats`, `update_bank`, `delete_bank`, `clear_memories`, `get_knowledge_base_tree`, `search_knowledge_base`, `get_knowledge_page`, `create_knowledge_folder`, `create_knowledge_page`, `update_knowledge_node`, `delete_knowledge_node`.
|
||||
|
||||
### llm_gemini_safety_settings
|
||||
|
||||
|
||||
@@ -1904,7 +1904,7 @@ export HINDSIGHT_API_MCP_ENABLED_TOOLS=recall
|
||||
export HINDSIGHT_API_MCP_ENABLED_TOOLS=recall,reflect
|
||||
```
|
||||
|
||||
Available tool names: `retain`, `recall`, `reflect`, `list_banks`, `create_bank`, `list_mental_models`, `get_mental_model`, `create_mental_model`, `update_mental_model`, `delete_mental_model`, `refresh_mental_model`, `list_directives`, `create_directive`, `delete_directive`, `list_memories`, `get_memory`, `list_documents`, `get_document`, `delete_document`, `list_operations`, `get_operation`, `cancel_operation`, `list_tags`, `get_bank`, `get_bank_stats`, `update_bank`, `delete_bank`, `clear_memories`.
|
||||
Available tool names: `retain`, `recall`, `reflect`, `list_banks`, `create_bank`, `list_mental_models`, `get_mental_model`, `create_mental_model`, `update_mental_model`, `delete_mental_model`, `refresh_mental_model`, `list_directives`, `create_directive`, `delete_directive`, `list_memories`, `get_memory`, `list_documents`, `get_document`, `delete_document`, `list_operations`, `get_operation`, `cancel_operation`, `list_tags`, `get_bank`, `get_bank_stats`, `update_bank`, `delete_bank`, `clear_memories`, `get_knowledge_base_tree`, `search_knowledge_base`, `get_knowledge_page`, `create_knowledge_folder`, `create_knowledge_page`, `update_knowledge_node`, `delete_knowledge_node`.
|
||||
|
||||
This can also be overridden per bank via the [config API](#hierarchical-configuration):
|
||||
|
||||
|
||||
@@ -599,6 +599,91 @@ Clear all memories from a bank without deleting the bank itself. Optionally filt
|
||||
|
||||
---
|
||||
|
||||
### get_knowledge_base_tree
|
||||
|
||||
Browse the knowledge base as a nested tree of folders and pages. Each page reports `is_stale`: `false` means it is provably up to date, `true` means the bank has been written to since the page last refreshed.
|
||||
|
||||
---
|
||||
|
||||
### search_knowledge_base
|
||||
|
||||
Find knowledge pages by relevance (hybrid BM25 + vector search over page names and content). Returns ranked pages with a snippet each.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `query` | string | Yes | What to search for |
|
||||
| `limit` | integer | No | Maximum pages to return, 1–50 (default: 10) |
|
||||
|
||||
---
|
||||
|
||||
### get_knowledge_page
|
||||
|
||||
Read a knowledge page as a markdown document (YAML frontmatter + synthesized body).
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `page_id` | string | Yes | The ID of the page to read (a `kp-...` node id) |
|
||||
|
||||
---
|
||||
|
||||
### create_knowledge_folder
|
||||
|
||||
Create a folder in the knowledge base. Folders group pages and hold no content of their own.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `name` | string | Yes | Folder name |
|
||||
| `parent_id` | string | No | Parent folder id (a `kf-...` node id). Omit to create at the top level |
|
||||
|
||||
---
|
||||
|
||||
### create_knowledge_page
|
||||
|
||||
Create a page — a living document whose content is synthesized from the bank's memories by running `source_query`. Content is generated asynchronously; use the returned `operation_id` to track completion.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `name` | string | Yes | Page name (unique within its folder) |
|
||||
| `source_query` | string | Yes | The question this page answers and rebuilds itself from |
|
||||
| `parent_id` | string | No | Parent folder id (a `kf-...` node id). Omit to create at the top level |
|
||||
| `tags` | array | No | Tags scoping which memories the page is built from |
|
||||
| `max_tokens` | integer | No | Maximum tokens for the generated content (default: 4096) |
|
||||
| `refresh_after_consolidation` | boolean | No | Whether the page rebuilds after each consolidation. Omit to keep the knowledge-page default (`true`) |
|
||||
|
||||
---
|
||||
|
||||
### update_knowledge_node
|
||||
|
||||
Rename or move a folder/page, and/or update a page's options. Only the arguments you pass are changed. Changing `source_query` schedules an async refresh so the page rebuilds against the new question.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `node_id` | string | Yes | The folder (`kf-...`) or page (`kp-...`) to update |
|
||||
| `name` | string | No | New name for the node |
|
||||
| `parent_id` | string | No | Folder id to move the node into, or `"root"` to move it to the top level |
|
||||
| `source_query` | string | No | Pages only — the new question the page answers |
|
||||
| `tags` | array | No | Pages only — replacement tag list (pass `[]` to clear) |
|
||||
| `max_tokens` | integer | No | Pages only — new maximum tokens for the generated content |
|
||||
| `refresh_after_consolidation` | boolean | No | Pages only — whether the page rebuilds after each consolidation |
|
||||
|
||||
---
|
||||
|
||||
### delete_knowledge_node
|
||||
|
||||
Delete a folder or page and its whole subtree. Each deleted page takes its backing mental model with it.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `node_id` | string | Yes | The folder (`kf-...`) or page (`kp-...`) to delete |
|
||||
|
||||
:::note
|
||||
Exporting the knowledge base is deliberately not an MCP tool — it returns the whole
|
||||
bank as a single markdown bundle. Use the HTTP endpoint
|
||||
`GET /v1/default/banks/{bank_id}/knowledge-base/export` instead.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Integration with AI Assistants
|
||||
|
||||
The MCP server can be used with any MCP-compatible AI assistant. See the [Authentication](#authentication) section above for Claude Code and Claude Desktop configuration examples.
|
||||
|
||||
@@ -128,6 +128,18 @@ The local server exposes the full tool set (29 tools in multi-bank mode, 26 in s
|
||||
| `list_banks` | List all memory banks (multi-bank only) |
|
||||
| `create_bank` | Create or configure a memory bank (multi-bank only) |
|
||||
|
||||
**Knowledge Base**
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `get_knowledge_base_tree` | Browse the folder/page tree |
|
||||
| `search_knowledge_base` | Hybrid search over knowledge pages |
|
||||
| `get_knowledge_page` | Read a page as a markdown document |
|
||||
| `create_knowledge_folder` | Create a folder |
|
||||
| `create_knowledge_page` | Create a page (a living document over the bank's memories) |
|
||||
| `update_knowledge_node` | Rename/move a node or update a page's options |
|
||||
| `delete_knowledge_node` | Delete a folder or page and its subtree |
|
||||
|
||||
For detailed parameter documentation, see the [MCP Server reference](../../developer/mcp-server.md#available-tools).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Reference in New Issue
Block a user