From 3ffec650903823e136e20a67c685b0eb3abcee97 Mon Sep 17 00:00:00 2001 From: DK09876 Date: Wed, 25 Feb 2026 02:26:46 -0700 Subject: [PATCH] feat: expand MCP tool surface area with 18 new tools and enhanced parameters (#435) Add directives, memory browsing, documents, operations, tags, and bank management tools to the MCP server. Expose previously hardcoded parameters (budget, types, tags, response_schema, trigger) on retain, recall, reflect, and mental model tools. Update docs for all new tools and parameters. Co-authored-by: Claude Opus 4.6 --- hindsight-api/hindsight_api/api/mcp.py | 19 +- hindsight-api/hindsight_api/mcp_tools.py | 1533 ++++++++++++++++- hindsight-api/tests/test_mcp_extension.py | 4 +- hindsight-api/tests/test_mcp_tools.py | 606 ++++++- hindsight-docs/docs/developer/mcp-server.md | 210 ++- .../docs/sdks/integrations/local-mcp.md | 66 +- 6 files changed, 2367 insertions(+), 71 deletions(-) diff --git a/hindsight-api/hindsight_api/api/mcp.py b/hindsight-api/hindsight_api/api/mcp.py index e3d41591..8094c650 100644 --- a/hindsight-api/hindsight_api/api/mcp.py +++ b/hindsight-api/hindsight_api/api/mcp.py @@ -101,7 +101,24 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP: "update_mental_model", "delete_mental_model", "refresh_mental_model", - }, # Scoped tools for single-bank mode (excludes bank management: list_banks, create_bank) + "list_directives", + "create_directive", + "delete_directive", + "list_memories", + "get_memory", + "delete_memory", + "list_documents", + "get_document", + "delete_document", + "list_operations", + "get_operation", + "cancel_operation", + "list_tags", + "get_bank", + "update_bank", + "delete_bank", + "clear_memories", + }, # Scoped tools for single-bank mode (excludes multi-bank management: list_banks, create_bank, get_bank_stats) retain_fire_and_forget=False, # HTTP MCP supports sync/async modes ) diff --git a/hindsight-api/hindsight_api/mcp_tools.py b/hindsight-api/hindsight_api/mcp_tools.py index 4a3528ac..bdecaa74 100644 --- a/hindsight-api/hindsight_api/mcp_tools.py +++ b/hindsight-api/hindsight_api/mcp_tools.py @@ -92,6 +92,9 @@ def build_content_dict( content: str, context: str, timestamp: str | None = None, + tags: list[str] | None = None, + metadata: dict[str, str] | None = None, + document_id: str | None = None, ) -> tuple[dict[str, Any], str | None]: """Build a content dict for retain operations. @@ -99,6 +102,9 @@ def build_content_dict( content: The memory content context: Category for the memory timestamp: Optional ISO timestamp + tags: Optional tags for scoped visibility filtering + metadata: Optional key-value metadata to attach to the memory + document_id: Optional document ID to associate the memory with Returns: Tuple of (content_dict, error_message). error_message is None if successful. @@ -112,6 +118,13 @@ def build_content_dict( except ValueError as e: return {}, str(e) + if tags is not None: + content_dict["tags"] = tags + if metadata is not None: + content_dict["metadata"] = metadata + if document_id is not None: + content_dict["document_id"] = document_id + return content_dict, None @@ -139,6 +152,24 @@ def register_mcp_tools( "update_mental_model", "delete_mental_model", "refresh_mental_model", + "list_directives", + "create_directive", + "delete_directive", + "list_memories", + "get_memory", + "delete_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", } if "retain" in tools_to_register: @@ -175,6 +206,65 @@ def register_mcp_tools( if "refresh_mental_model" in tools_to_register: _register_refresh_mental_model(mcp, memory, config) + # Directive tools + if "list_directives" in tools_to_register: + _register_list_directives(mcp, memory, config) + + if "create_directive" in tools_to_register: + _register_create_directive(mcp, memory, config) + + if "delete_directive" in tools_to_register: + _register_delete_directive(mcp, memory, config) + + # Memory browsing tools + if "list_memories" in tools_to_register: + _register_list_memories(mcp, memory, config) + + if "get_memory" in tools_to_register: + _register_get_memory(mcp, memory, config) + + if "delete_memory" in tools_to_register: + _register_delete_memory(mcp, memory, config) + + # Document tools + if "list_documents" in tools_to_register: + _register_list_documents(mcp, memory, config) + + if "get_document" in tools_to_register: + _register_get_document(mcp, memory, config) + + if "delete_document" in tools_to_register: + _register_delete_document(mcp, memory, config) + + # Operation tools + if "list_operations" in tools_to_register: + _register_list_operations(mcp, memory, config) + + if "get_operation" in tools_to_register: + _register_get_operation(mcp, memory, config) + + if "cancel_operation" in tools_to_register: + _register_cancel_operation(mcp, memory, config) + + # Tags & bank tools + if "list_tags" in tools_to_register: + _register_list_tags(mcp, memory, config) + + if "get_bank" in tools_to_register: + _register_get_bank(mcp, memory, config) + + if "get_bank_stats" in tools_to_register: + _register_get_bank_stats(mcp, memory, config) + + if "update_bank" in tools_to_register: + _register_update_bank(mcp, memory, config) + + if "delete_bank" in tools_to_register: + _register_delete_bank(mcp, memory, config) + + if "clear_memories" in tools_to_register: + _register_clear_memories(mcp, memory, config) + def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: """Register the retain tool.""" @@ -188,6 +278,9 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) content: str, context: str = "general", timestamp: str | None = None, + tags: list[str] | None = None, + metadata: dict[str, str] | None = None, + document_id: str | None = None, bank_id: str | None = None, ) -> dict: """ @@ -195,6 +288,9 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) content: The fact/memory to store (be specific and include relevant details) context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general' timestamp: When this event/fact occurred (ISO format, e.g., '2024-01-15T10:30:00Z'). Useful for timeline tracking. + tags: Optional tags for scoped visibility filtering (e.g., ['project:alpha', 'user:123']) + metadata: Optional key-value metadata to attach (e.g., {'source': 'slack', 'channel': 'general'}) + document_id: Optional document ID to associate this memory with bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations. """ import asyncio @@ -203,7 +299,7 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) if target_bank is None: return {"status": "error", "message": "No bank_id configured"} - content_dict, error = build_content_dict(content, context, timestamp) + content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id) if error: return {"status": "error", "message": error} @@ -229,6 +325,9 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) content: str, context: str = "general", timestamp: str | None = None, + tags: list[str] | None = None, + metadata: dict[str, str] | None = None, + document_id: str | None = None, async_processing: bool = True, bank_id: str | None = None, ) -> str: @@ -237,6 +336,9 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) content: The fact/memory to store (be specific and include relevant details) context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general' timestamp: When this event/fact occurred (ISO format, e.g., '2024-01-15T10:30:00Z'). Useful for timeline tracking. + tags: Optional tags for scoped visibility filtering (e.g., ['project:alpha', 'user:123']) + metadata: Optional key-value metadata to attach (e.g., {'source': 'slack', 'channel': 'general'}) + document_id: Optional document ID to associate this memory with async_processing: If True, queue for background processing and return immediately. If False, wait for completion. Default: True bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations. """ @@ -245,7 +347,7 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) if target_bank is None: return "Error: No bank_id configured" - content_dict, error = build_content_dict(content, context, timestamp) + content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id) if error: return f"Error: {error}" @@ -275,12 +377,18 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) content: str, context: str = "general", timestamp: str | None = None, + tags: list[str] | None = None, + metadata: dict[str, str] | None = None, + document_id: str | None = None, ) -> dict: """ Args: content: The fact/memory to store (be specific and include relevant details) context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general' timestamp: When this event/fact occurred (ISO format, e.g., '2024-01-15T10:30:00Z'). Useful for timeline tracking. + tags: Optional tags for scoped visibility filtering (e.g., ['project:alpha', 'user:123']) + metadata: Optional key-value metadata to attach (e.g., {'source': 'slack', 'channel': 'general'}) + document_id: Optional document ID to associate this memory with """ import asyncio @@ -288,7 +396,7 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) if target_bank is None: return {"status": "error", "message": "No bank_id configured"} - content_dict, error = build_content_dict(content, context, timestamp) + content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id) if error: return {"status": "error", "message": error} @@ -318,12 +426,22 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) async def recall( query: str, max_tokens: int = 4096, + budget: str = "high", + types: list[str] | None = None, + tags: list[str] | None = None, + tags_match: str = "any", + query_timestamp: str | None = None, bank_id: str | None = None, ) -> str | dict: """ Args: query: Natural language search query (e.g., "user's food preferences", "what projects is user working on") max_tokens: Maximum tokens to return in results (default: 4096) + budget: Search budget - 'low', 'mid', or 'high' (default: 'high'). Higher budgets search more thoroughly. + types: Fact types to include (e.g., ['world', 'experience']). Default: all types. + tags: Optional tags to filter results by (e.g., ['project:alpha']) + tags_match: How to match tags - 'any' (match any tag) or 'all' (match all tags). Default: 'any' + query_timestamp: Temporal context for the query (ISO format, e.g., '2024-01-15T10:30:00Z'). Helps retrieve time-relevant memories. bank_id: Optional bank to search in (defaults to session bank). Use for cross-bank operations. """ try: @@ -331,16 +449,29 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) if target_bank is None: return "Error: No bank_id configured" - recall_result = await memory.recall_async( - bank_id=target_bank, - query=query, - fact_type=list(VALID_RECALL_FACT_TYPES), - budget=Budget.HIGH, - max_tokens=max_tokens, - request_context=_get_request_context(config), - ) + budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH} + budget_enum = budget_map.get(budget.lower(), Budget.HIGH) + fact_types = types if types is not None else list(VALID_RECALL_FACT_TYPES) + + recall_kwargs: dict[str, Any] = { + "bank_id": target_bank, + "query": query, + "fact_type": fact_types, + "budget": budget_enum, + "max_tokens": max_tokens, + "request_context": _get_request_context(config), + } + if tags is not None: + recall_kwargs["tags"] = tags + recall_kwargs["tags_match"] = tags_match + if query_timestamp is not None: + recall_kwargs["question_date"] = parse_timestamp(query_timestamp) + + recall_result = await memory.recall_async(**recall_kwargs) return recall_result.model_dump_json(indent=2) + except ValueError as e: + return f'{{"error": "{e}", "results": []}}' except Exception as e: logger.error(f"Error searching: {e}", exc_info=True) return f'{{"error": "{e}", "results": []}}' @@ -351,27 +482,50 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) async def recall( query: str, max_tokens: int = 4096, + budget: str = "high", + types: list[str] | None = None, + tags: list[str] | None = None, + tags_match: str = "any", + query_timestamp: str | None = None, ) -> dict: """ Args: query: Natural language search query (e.g., "user's food preferences", "what projects is user working on") max_tokens: Maximum tokens to return in results (default: 4096) + budget: Search budget - 'low', 'mid', or 'high' (default: 'high'). Higher budgets search more thoroughly. + types: Fact types to include (e.g., ['world', 'experience']). Default: all types. + tags: Optional tags to filter results by (e.g., ['project:alpha']) + tags_match: How to match tags - 'any' (match any tag) or 'all' (match all tags). Default: 'any' + query_timestamp: Temporal context for the query (ISO format, e.g., '2024-01-15T10:30:00Z'). Helps retrieve time-relevant memories. """ try: target_bank = config.bank_id_resolver() if target_bank is None: return {"error": "No bank_id configured", "results": []} - recall_result = await memory.recall_async( - bank_id=target_bank, - query=query, - fact_type=list(VALID_RECALL_FACT_TYPES), - budget=Budget.HIGH, - max_tokens=max_tokens, - request_context=_get_request_context(config), - ) + budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH} + budget_enum = budget_map.get(budget.lower(), Budget.HIGH) + fact_types = types if types is not None else list(VALID_RECALL_FACT_TYPES) + + recall_kwargs: dict[str, Any] = { + "bank_id": target_bank, + "query": query, + "fact_type": fact_types, + "budget": budget_enum, + "max_tokens": max_tokens, + "request_context": _get_request_context(config), + } + if tags is not None: + recall_kwargs["tags"] = tags + recall_kwargs["tags_match"] = tags_match + if query_timestamp is not None: + recall_kwargs["question_date"] = parse_timestamp(query_timestamp) + + recall_result = await memory.recall_async(**recall_kwargs) return recall_result.model_dump() + except ValueError as e: + return {"error": str(e), "results": []} except Exception as e: logger.error(f"Error searching: {e}", exc_info=True) return {"error": str(e), "results": []} @@ -387,6 +541,10 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig query: str, context: str | None = None, budget: str = "low", + max_tokens: int = 4096, + response_schema: dict | None = None, + tags: list[str] | None = None, + tags_match: str = "any", bank_id: str | None = None, ) -> str: """ @@ -412,6 +570,10 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig query: The question or topic to reflect on context: Optional context about why this reflection is needed budget: Search budget - 'low', 'mid', or 'high' (default: 'low') + max_tokens: Maximum tokens for the response (default: 4096) + response_schema: Optional JSON schema for structured output. When provided, the response includes a 'structured_output' field. + tags: Optional tags to filter memories by (e.g., ['project:alpha']) + tags_match: How to match tags - 'any' (match any tag) or 'all' (match all tags). Default: 'any' bank_id: Optional bank to reflect in (defaults to session bank). Use for cross-bank operations. """ try: @@ -422,15 +584,26 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH} budget_enum = budget_map.get(budget.lower(), Budget.LOW) - reflect_result = await memory.reflect_async( - bank_id=target_bank, - query=query, - budget=budget_enum, - context=context, - request_context=_get_request_context(config), - ) + reflect_kwargs: dict[str, Any] = { + "bank_id": target_bank, + "query": query, + "budget": budget_enum, + "context": context, + "max_tokens": max_tokens, + "request_context": _get_request_context(config), + } + if response_schema is not None: + reflect_kwargs["response_schema"] = response_schema + if tags is not None: + reflect_kwargs["tags"] = tags + reflect_kwargs["tags_match"] = tags_match - return reflect_result.model_dump_json(indent=2) + reflect_result = await memory.reflect_async(**reflect_kwargs) + + result_data = json.loads(reflect_result.model_dump_json(indent=2)) + if response_schema is not None and hasattr(reflect_result, "structured_output"): + result_data["structured_output"] = reflect_result.structured_output + return json.dumps(result_data, indent=2) except Exception as e: logger.error(f"Error reflecting: {e}", exc_info=True) return f'{{"error": "{e}", "text": ""}}' @@ -442,6 +615,10 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig query: str, context: str | None = None, budget: str = "low", + max_tokens: int = 4096, + response_schema: dict | None = None, + tags: list[str] | None = None, + tags_match: str = "any", ) -> dict: """ Generate thoughtful analysis by synthesizing stored memories with the bank's personality. @@ -466,6 +643,10 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig query: The question or topic to reflect on context: Optional context about why this reflection is needed budget: Search budget - 'low', 'mid', or 'high' (default: 'low') + max_tokens: Maximum tokens for the response (default: 4096) + response_schema: Optional JSON schema for structured output. When provided, the response includes a 'structured_output' field. + tags: Optional tags to filter memories by (e.g., ['project:alpha']) + tags_match: How to match tags - 'any' (match any tag) or 'all' (match all tags). Default: 'any' """ try: target_bank = config.bank_id_resolver() @@ -475,15 +656,26 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH} budget_enum = budget_map.get(budget.lower(), Budget.LOW) - reflect_result = await memory.reflect_async( - bank_id=target_bank, - query=query, - budget=budget_enum, - context=context, - request_context=_get_request_context(config), - ) + reflect_kwargs: dict[str, Any] = { + "bank_id": target_bank, + "query": query, + "budget": budget_enum, + "context": context, + "max_tokens": max_tokens, + "request_context": _get_request_context(config), + } + if response_schema is not None: + reflect_kwargs["response_schema"] = response_schema + if tags is not None: + reflect_kwargs["tags"] = tags + reflect_kwargs["tags_match"] = tags_match - return reflect_result.model_dump() + reflect_result = await memory.reflect_async(**reflect_kwargs) + + result_data = reflect_result.model_dump() + if response_schema is not None and hasattr(reflect_result, "structured_output"): + result_data["structured_output"] = reflect_result.structured_output + return result_data except Exception as e: logger.error(f"Error reflecting: {e}", exc_info=True) return {"error": str(e), "text": ""} @@ -720,6 +912,7 @@ def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC mental_model_id: str | None = None, tags: list[str] | None = None, max_tokens: int = 2048, + trigger_refresh_after_consolidation: bool = False, bank_id: str | None = None, ) -> str: """ @@ -740,6 +933,7 @@ def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC mental_model_id: Optional custom ID (alphanumeric lowercase with hyphens). Auto-generated if not provided. tags: Optional tags for scoped visibility filtering max_tokens: Maximum tokens for generated content (256-8192, default: 2048) + trigger_refresh_after_consolidation: If True, automatically refresh this model after memory consolidation. Default: False bank_id: Optional bank (defaults to session bank). Use for cross-bank operations. """ try: @@ -754,6 +948,7 @@ def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC return json.dumps({"error": validation_error}) request_context = _get_request_context(config) + trigger = {"refresh_after_consolidation": trigger_refresh_after_consolidation} # Create with placeholder content model = await memory.create_mental_model( @@ -764,6 +959,7 @@ def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC mental_model_id=mental_model_id, tags=tags, max_tokens=max_tokens, + trigger=trigger, request_context=request_context, ) @@ -797,6 +993,7 @@ def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC mental_model_id: str | None = None, tags: list[str] | None = None, max_tokens: int = 2048, + trigger_refresh_after_consolidation: bool = False, ) -> dict: """ Create a new mental model (pinned reflection). @@ -816,6 +1013,7 @@ def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC mental_model_id: Optional custom ID (alphanumeric lowercase with hyphens). Auto-generated if not provided. tags: Optional tags for scoped visibility filtering max_tokens: Maximum tokens for generated content (256-8192, default: 2048) + trigger_refresh_after_consolidation: If True, automatically refresh this model after memory consolidation. Default: False """ try: target_bank = config.bank_id_resolver() @@ -829,6 +1027,7 @@ def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC return {"error": validation_error} request_context = _get_request_context(config) + trigger = {"refresh_after_consolidation": trigger_refresh_after_consolidation} model = await memory.create_mental_model( bank_id=target_bank, @@ -838,6 +1037,7 @@ def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC mental_model_id=mental_model_id, tags=tags, max_tokens=max_tokens, + trigger=trigger, request_context=request_context, ) @@ -872,6 +1072,7 @@ def _register_update_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC source_query: str | None = None, max_tokens: int | None = None, tags: list[str] | None = None, + trigger_refresh_after_consolidation: bool | None = None, bank_id: str | None = None, ) -> str: """ @@ -886,6 +1087,7 @@ def _register_update_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC source_query: New source query (leave None to keep current) max_tokens: New max tokens for content generation (256-8192, leave None to keep current) tags: New tags (leave None to keep current) + trigger_refresh_after_consolidation: If set, update whether this model auto-refreshes after consolidation bank_id: Optional bank (defaults to session bank). Use for cross-bank operations. """ try: @@ -899,15 +1101,19 @@ def _register_update_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC if validation_error: return json.dumps({"error": validation_error}) - model = await memory.update_mental_model( - bank_id=target_bank, - mental_model_id=mental_model_id, - name=name, - source_query=source_query, - max_tokens=max_tokens, - tags=tags, - request_context=_get_request_context(config), - ) + update_kwargs: dict[str, Any] = { + "bank_id": target_bank, + "mental_model_id": mental_model_id, + "name": name, + "source_query": source_query, + "max_tokens": max_tokens, + "tags": tags, + "request_context": _get_request_context(config), + } + if trigger_refresh_after_consolidation is not None: + update_kwargs["trigger"] = {"refresh_after_consolidation": trigger_refresh_after_consolidation} + + model = await memory.update_mental_model(**update_kwargs) if model is None: return json.dumps({"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"}) return json.dumps(model, indent=2, default=str) @@ -924,6 +1130,7 @@ def _register_update_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC source_query: str | None = None, max_tokens: int | None = None, tags: list[str] | None = None, + trigger_refresh_after_consolidation: bool | None = None, ) -> dict: """ Update a mental model's metadata. @@ -937,6 +1144,7 @@ def _register_update_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC source_query: New source query (leave None to keep current) max_tokens: New max tokens for content generation (256-8192, leave None to keep current) tags: New tags (leave None to keep current) + trigger_refresh_after_consolidation: If set, update whether this model auto-refreshes after consolidation """ try: target_bank = config.bank_id_resolver() @@ -949,15 +1157,19 @@ def _register_update_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC if validation_error: return {"error": validation_error} - model = await memory.update_mental_model( - bank_id=target_bank, - mental_model_id=mental_model_id, - name=name, - source_query=source_query, - max_tokens=max_tokens, - tags=tags, - request_context=_get_request_context(config), - ) + update_kwargs: dict[str, Any] = { + "bank_id": target_bank, + "mental_model_id": mental_model_id, + "name": name, + "source_query": source_query, + "max_tokens": max_tokens, + "tags": tags, + "request_context": _get_request_context(config), + } + if trigger_refresh_after_consolidation is not None: + update_kwargs["trigger"] = {"refresh_after_consolidation": trigger_refresh_after_consolidation} + + model = await memory.update_mental_model(**update_kwargs) if model is None: return {"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"} return model @@ -1114,3 +1326,1218 @@ def _register_refresh_mental_model(mcp: FastMCP, memory: MemoryEngine, config: M except Exception as e: logger.error(f"Error refreshing mental model: {e}", exc_info=True) return {"error": str(e)} + + +# ========================================================================= +# DIRECTIVE TOOLS +# ========================================================================= + + +def _register_list_directives(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Register the list_directives tool.""" + + if config.include_bank_id_param: + + @mcp.tool() + async def list_directives( + tags: list[str] | None = None, + active_only: bool = True, + bank_id: str | None = None, + ) -> str: + """ + List directives for a memory bank. + + Directives are instructions that guide how the memory engine processes and + responds to queries. They influence reflect behavior and memory organization. + + Args: + tags: Optional tags to filter by + active_only: If True, only return active directives (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"}' + + directives = await memory.list_directives( + target_bank, + tags=tags, + active_only=active_only, + request_context=_get_request_context(config), + ) + return json.dumps({"items": directives}, indent=2, default=str) + except Exception as e: + logger.error(f"Error listing directives: {e}", exc_info=True) + return f'{{"error": "{e}", "items": []}}' + + else: + + @mcp.tool() + async def list_directives( + tags: list[str] | None = None, + active_only: bool = True, + ) -> dict: + """ + List directives for this memory bank. + + Directives are instructions that guide how the memory engine processes and + responds to queries. They influence reflect behavior and memory organization. + + Args: + tags: Optional tags to filter by + active_only: If True, only return active directives (default: True) + """ + try: + target_bank = config.bank_id_resolver() + if target_bank is None: + return {"error": "No bank_id configured", "items": []} + + directives = await memory.list_directives( + target_bank, + tags=tags, + active_only=active_only, + request_context=_get_request_context(config), + ) + return {"items": directives} + except Exception as e: + logger.error(f"Error listing directives: {e}", exc_info=True) + return {"error": str(e), "items": []} + + +def _register_create_directive(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Register the create_directive tool.""" + + if config.include_bank_id_param: + + @mcp.tool() + async def create_directive( + name: str, + content: str, + priority: int = 0, + is_active: bool = True, + tags: list[str] | None = None, + bank_id: str | None = None, + ) -> str: + """ + Create a new directive for a memory bank. + + Directives guide how the memory engine processes queries and generates reflections. + + Args: + name: Human-readable name for the directive + content: The directive content/instructions + priority: Priority level (higher = more important, default: 0) + is_active: Whether the directive is active (default: True) + tags: Optional tags for filtering + 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"}' + + directive = await memory.create_directive( + target_bank, + name=name, + content=content, + priority=priority, + is_active=is_active, + tags=tags, + request_context=_get_request_context(config), + ) + return json.dumps(directive, indent=2, default=str) + except Exception as e: + logger.error(f"Error creating directive: {e}", exc_info=True) + return f'{{"error": "{e}"}}' + + else: + + @mcp.tool() + async def create_directive( + name: str, + content: str, + priority: int = 0, + is_active: bool = True, + tags: list[str] | None = None, + ) -> dict: + """ + Create a new directive for this memory bank. + + Directives guide how the memory engine processes queries and generates reflections. + + Args: + name: Human-readable name for the directive + content: The directive content/instructions + priority: Priority level (higher = more important, default: 0) + is_active: Whether the directive is active (default: True) + tags: Optional tags for filtering + """ + try: + target_bank = config.bank_id_resolver() + if target_bank is None: + return {"error": "No bank_id configured"} + + directive = await memory.create_directive( + target_bank, + name=name, + content=content, + priority=priority, + is_active=is_active, + tags=tags, + request_context=_get_request_context(config), + ) + return directive + except Exception as e: + logger.error(f"Error creating directive: {e}", exc_info=True) + return {"error": str(e)} + + +def _register_delete_directive(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Register the delete_directive tool.""" + + if config.include_bank_id_param: + + @mcp.tool() + async def delete_directive( + directive_id: str, + bank_id: str | None = None, + ) -> str: + """ + Delete a directive. + + Permanently removes a directive from the memory bank. + + Args: + directive_id: The ID of the directive 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"}' + + deleted = await memory.delete_directive( + target_bank, + directive_id, + request_context=_get_request_context(config), + ) + if not deleted: + return json.dumps({"error": f"Directive '{directive_id}' not found"}) + return json.dumps({"status": "deleted", "directive_id": directive_id}) + except Exception as e: + logger.error(f"Error deleting directive: {e}", exc_info=True) + return f'{{"error": "{e}"}}' + + else: + + @mcp.tool() + async def delete_directive( + directive_id: str, + ) -> dict: + """ + Delete a directive. + + Permanently removes a directive from this memory bank. + + Args: + directive_id: The ID of the directive to delete + """ + try: + target_bank = config.bank_id_resolver() + if target_bank is None: + return {"error": "No bank_id configured"} + + deleted = await memory.delete_directive( + target_bank, + directive_id, + request_context=_get_request_context(config), + ) + if not deleted: + return {"error": f"Directive '{directive_id}' not found"} + return {"status": "deleted", "directive_id": directive_id} + except Exception as e: + logger.error(f"Error deleting directive: {e}", exc_info=True) + return {"error": str(e)} + + +# ========================================================================= +# MEMORY BROWSING TOOLS +# ========================================================================= + + +def _register_list_memories(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Register the list_memories tool.""" + + if config.include_bank_id_param: + + @mcp.tool() + async def list_memories( + type: str | None = None, + q: str | None = None, + limit: int = 100, + offset: int = 0, + bank_id: str | None = None, + ) -> str: + """ + Browse stored memories with optional filtering. + + Lists memory units (facts) stored in the bank. Unlike recall, this is a direct + browse/search without relevance ranking. + + Args: + type: Filter by fact type: 'world', 'experience', or 'opinion' + q: Optional text search query to filter memories + limit: Maximum number of results (default: 100) + offset: Pagination offset (default: 0) + 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 memory.list_memory_units( + target_bank, + fact_type=type, + search_query=q, + limit=limit, + offset=offset, + request_context=_get_request_context(config), + ) + return json.dumps(result, indent=2, default=str) + except Exception as e: + logger.error(f"Error listing memories: {e}", exc_info=True) + return f'{{"error": "{e}"}}' + + else: + + @mcp.tool() + async def list_memories( + type: str | None = None, + q: str | None = None, + limit: int = 100, + offset: int = 0, + ) -> dict: + """ + Browse stored memories with optional filtering. + + Lists memory units (facts) stored in the bank. Unlike recall, this is a direct + browse/search without relevance ranking. + + Args: + type: Filter by fact type: 'world', 'experience', or 'opinion' + q: Optional text search query to filter memories + limit: Maximum number of results (default: 100) + offset: Pagination offset (default: 0) + """ + try: + target_bank = config.bank_id_resolver() + if target_bank is None: + return {"error": "No bank_id configured"} + + result = await memory.list_memory_units( + target_bank, + fact_type=type, + search_query=q, + limit=limit, + offset=offset, + request_context=_get_request_context(config), + ) + return result + except Exception as e: + logger.error(f"Error listing memories: {e}", exc_info=True) + return {"error": str(e)} + + +def _register_get_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Register the get_memory tool.""" + + if config.include_bank_id_param: + + @mcp.tool() + async def get_memory( + memory_id: str, + bank_id: str | None = None, + ) -> str: + """ + Get a specific memory by ID. + + Returns the full memory unit including content, metadata, and timestamps. + + Args: + memory_id: The ID of the memory to retrieve + 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 memory.get_memory_unit( + target_bank, + memory_id, + request_context=_get_request_context(config), + ) + if result is None: + return json.dumps({"error": f"Memory '{memory_id}' not found"}) + return json.dumps(result, indent=2, default=str) + except Exception as e: + logger.error(f"Error getting memory: {e}", exc_info=True) + return f'{{"error": "{e}"}}' + + else: + + @mcp.tool() + async def get_memory( + memory_id: str, + ) -> dict: + """ + Get a specific memory by ID. + + Returns the full memory unit including content, metadata, and timestamps. + + Args: + memory_id: The ID of the memory to retrieve + """ + try: + target_bank = config.bank_id_resolver() + if target_bank is None: + return {"error": "No bank_id configured"} + + result = await memory.get_memory_unit( + target_bank, + memory_id, + request_context=_get_request_context(config), + ) + if result is None: + return {"error": f"Memory '{memory_id}' not found"} + return result + except Exception as e: + logger.error(f"Error getting memory: {e}", exc_info=True) + return {"error": str(e)} + + +def _register_delete_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Register the delete_memory tool.""" + + if config.include_bank_id_param: + + @mcp.tool() + async def delete_memory( + memory_id: str, + bank_id: str | None = None, + ) -> str: + """ + Delete a specific memory by ID. + + Permanently removes a memory unit and its associated data. + + Args: + memory_id: The ID of the memory to delete + bank_id: Optional bank (accepted for consistency, not used in deletion). + """ + try: + target_bank = bank_id or config.bank_id_resolver() + if target_bank is None: + return '{"error": "No bank_id configured"}' + + result = await memory.delete_memory_unit( + unit_id=memory_id, + request_context=_get_request_context(config), + ) + return json.dumps({"status": "deleted", "memory_id": memory_id, **result}, default=str) + except Exception as e: + logger.error(f"Error deleting memory: {e}", exc_info=True) + return f'{{"error": "{e}"}}' + + else: + + @mcp.tool() + async def delete_memory( + memory_id: str, + ) -> dict: + """ + Delete a specific memory by ID. + + Permanently removes a memory unit and its associated data. + + Args: + memory_id: The ID of the memory to delete + """ + try: + target_bank = config.bank_id_resolver() + if target_bank is None: + return {"error": "No bank_id configured"} + + result = await memory.delete_memory_unit( + unit_id=memory_id, + request_context=_get_request_context(config), + ) + return {"status": "deleted", "memory_id": memory_id, **result} + except Exception as e: + logger.error(f"Error deleting memory: {e}", exc_info=True) + return {"error": str(e)} + + +# ========================================================================= +# DOCUMENT TOOLS +# ========================================================================= + + +def _register_list_documents(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Register the list_documents tool.""" + + if config.include_bank_id_param: + + @mcp.tool() + async def list_documents( + q: str | None = None, + limit: int = 100, + bank_id: str | None = None, + ) -> str: + """ + List documents in a memory bank. + + Documents are containers for related memories (e.g., a conversation transcript, + a meeting notes file). Memories created with a document_id are grouped under that document. + + Args: + q: Optional search query to filter documents + limit: Maximum number of results (default: 100) + 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 memory.list_documents( + target_bank, + search_query=q, + limit=limit, + request_context=_get_request_context(config), + ) + return json.dumps(result, indent=2, default=str) + except Exception as e: + logger.error(f"Error listing documents: {e}", exc_info=True) + return f'{{"error": "{e}"}}' + + else: + + @mcp.tool() + async def list_documents( + q: str | None = None, + limit: int = 100, + ) -> dict: + """ + List documents in this memory bank. + + Documents are containers for related memories (e.g., a conversation transcript, + a meeting notes file). Memories created with a document_id are grouped under that document. + + Args: + q: Optional search query to filter documents + limit: Maximum number of results (default: 100) + """ + try: + target_bank = config.bank_id_resolver() + if target_bank is None: + return {"error": "No bank_id configured"} + + result = await memory.list_documents( + target_bank, + search_query=q, + limit=limit, + request_context=_get_request_context(config), + ) + return result + except Exception as e: + logger.error(f"Error listing documents: {e}", exc_info=True) + return {"error": str(e)} + + +def _register_get_document(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Register the get_document tool.""" + + if config.include_bank_id_param: + + @mcp.tool() + async def get_document( + document_id: str, + bank_id: str | None = None, + ) -> str: + """ + Get a specific document by ID. + + Returns document metadata and associated memory information. + + Args: + document_id: The ID of the document to retrieve + 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 memory.get_document( + document_id, + target_bank, + request_context=_get_request_context(config), + ) + if result is None: + return json.dumps({"error": f"Document '{document_id}' not found"}) + return json.dumps(result, indent=2, default=str) + except Exception as e: + logger.error(f"Error getting document: {e}", exc_info=True) + return f'{{"error": "{e}"}}' + + else: + + @mcp.tool() + async def get_document( + document_id: str, + ) -> dict: + """ + Get a specific document by ID. + + Returns document metadata and associated memory information. + + Args: + document_id: The ID of the document to retrieve + """ + try: + target_bank = config.bank_id_resolver() + if target_bank is None: + return {"error": "No bank_id configured"} + + result = await memory.get_document( + document_id, + target_bank, + request_context=_get_request_context(config), + ) + if result is None: + return {"error": f"Document '{document_id}' not found"} + return result + except Exception as e: + logger.error(f"Error getting document: {e}", exc_info=True) + return {"error": str(e)} + + +def _register_delete_document(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Register the delete_document tool.""" + + if config.include_bank_id_param: + + @mcp.tool() + async def delete_document( + document_id: str, + bank_id: str | None = None, + ) -> str: + """ + Delete a document and its associated memories. + + Permanently removes a document and all memories linked to it. + + Args: + document_id: The ID of the document 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 memory.delete_document( + document_id, + target_bank, + request_context=_get_request_context(config), + ) + return json.dumps({"status": "deleted", "document_id": document_id, **result}, default=str) + except Exception as e: + logger.error(f"Error deleting document: {e}", exc_info=True) + return f'{{"error": "{e}"}}' + + else: + + @mcp.tool() + async def delete_document( + document_id: str, + ) -> dict: + """ + Delete a document and its associated memories. + + Permanently removes a document and all memories linked to it. + + Args: + document_id: The ID of the document to delete + """ + try: + target_bank = config.bank_id_resolver() + if target_bank is None: + return {"error": "No bank_id configured"} + + result = await memory.delete_document( + document_id, + target_bank, + request_context=_get_request_context(config), + ) + return {"status": "deleted", "document_id": document_id, **result} + except Exception as e: + logger.error(f"Error deleting document: {e}", exc_info=True) + return {"error": str(e)} + + +# ========================================================================= +# OPERATION TOOLS +# ========================================================================= + + +def _register_list_operations(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Register the list_operations tool.""" + + if config.include_bank_id_param: + + @mcp.tool() + async def list_operations( + status: str | None = None, + limit: int = 20, + bank_id: str | None = None, + ) -> str: + """ + List async operations for a memory bank. + + Operations track background tasks like retain processing, mental model refresh, etc. + + Args: + status: Filter by status: 'pending', 'running', 'completed', 'failed', 'cancelled' + limit: Maximum number of results (default: 20) + 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 memory.list_operations( + target_bank, + status=status, + limit=limit, + request_context=_get_request_context(config), + ) + return json.dumps(result, indent=2, default=str) + except Exception as e: + logger.error(f"Error listing operations: {e}", exc_info=True) + return f'{{"error": "{e}"}}' + + else: + + @mcp.tool() + async def list_operations( + status: str | None = None, + limit: int = 20, + ) -> dict: + """ + List async operations for this memory bank. + + Operations track background tasks like retain processing, mental model refresh, etc. + + Args: + status: Filter by status: 'pending', 'running', 'completed', 'failed', 'cancelled' + limit: Maximum number of results (default: 20) + """ + try: + target_bank = config.bank_id_resolver() + if target_bank is None: + return {"error": "No bank_id configured"} + + result = await memory.list_operations( + target_bank, + status=status, + limit=limit, + request_context=_get_request_context(config), + ) + return result + except Exception as e: + logger.error(f"Error listing operations: {e}", exc_info=True) + return {"error": str(e)} + + +def _register_get_operation(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Register the get_operation tool.""" + + if config.include_bank_id_param: + + @mcp.tool() + async def get_operation( + operation_id: str, + bank_id: str | None = None, + ) -> str: + """ + Get the status of an async operation. + + Check progress of background tasks like retain processing or mental model refresh. + + Args: + operation_id: The ID of the operation to check + 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 memory.get_operation_status( + target_bank, + operation_id, + request_context=_get_request_context(config), + ) + return json.dumps(result, indent=2, default=str) + except Exception as e: + logger.error(f"Error getting operation: {e}", exc_info=True) + return f'{{"error": "{e}"}}' + + else: + + @mcp.tool() + async def get_operation( + operation_id: str, + ) -> dict: + """ + Get the status of an async operation. + + Check progress of background tasks like retain processing or mental model refresh. + + Args: + operation_id: The ID of the operation to check + """ + try: + target_bank = config.bank_id_resolver() + if target_bank is None: + return {"error": "No bank_id configured"} + + result = await memory.get_operation_status( + target_bank, + operation_id, + request_context=_get_request_context(config), + ) + return result + except Exception as e: + logger.error(f"Error getting operation: {e}", exc_info=True) + return {"error": str(e)} + + +def _register_cancel_operation(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Register the cancel_operation tool.""" + + if config.include_bank_id_param: + + @mcp.tool() + async def cancel_operation( + operation_id: str, + bank_id: str | None = None, + ) -> str: + """ + Cancel a pending or running async operation. + + Args: + operation_id: The ID of the operation to cancel + 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 memory.cancel_operation( + target_bank, + operation_id, + request_context=_get_request_context(config), + ) + return json.dumps(result, indent=2, default=str) + except Exception as e: + logger.error(f"Error cancelling operation: {e}", exc_info=True) + return f'{{"error": "{e}"}}' + + else: + + @mcp.tool() + async def cancel_operation( + operation_id: str, + ) -> dict: + """ + Cancel a pending or running async operation. + + Args: + operation_id: The ID of the operation to cancel + """ + try: + target_bank = config.bank_id_resolver() + if target_bank is None: + return {"error": "No bank_id configured"} + + result = await memory.cancel_operation( + target_bank, + operation_id, + request_context=_get_request_context(config), + ) + return result + except Exception as e: + logger.error(f"Error cancelling operation: {e}", exc_info=True) + return {"error": str(e)} + + +# ========================================================================= +# TAGS & BANK TOOLS +# ========================================================================= + + +def _register_list_tags(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Register the list_tags tool.""" + + if config.include_bank_id_param: + + @mcp.tool() + async def list_tags( + q: str | None = None, + limit: int = 100, + bank_id: str | None = None, + ) -> str: + """ + List tags used in a memory bank. + + Tags are used to organize and filter memories, directives, and mental models. + + Args: + q: Optional pattern to filter tags (e.g., 'project:*') + limit: Maximum number of results (default: 100) + 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 memory.list_tags( + target_bank, + pattern=q, + limit=limit, + request_context=_get_request_context(config), + ) + return json.dumps(result, indent=2, default=str) + except Exception as e: + logger.error(f"Error listing tags: {e}", exc_info=True) + return f'{{"error": "{e}"}}' + + else: + + @mcp.tool() + async def list_tags( + q: str | None = None, + limit: int = 100, + ) -> dict: + """ + List tags used in this memory bank. + + Tags are used to organize and filter memories, directives, and mental models. + + Args: + q: Optional pattern to filter tags (e.g., 'project:*') + limit: Maximum number of results (default: 100) + """ + try: + target_bank = config.bank_id_resolver() + if target_bank is None: + return {"error": "No bank_id configured"} + + result = await memory.list_tags( + target_bank, + pattern=q, + limit=limit, + request_context=_get_request_context(config), + ) + return result + except Exception as e: + logger.error(f"Error listing tags: {e}", exc_info=True) + return {"error": str(e)} + + +def _register_get_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Register the get_bank tool.""" + + if config.include_bank_id_param: + + @mcp.tool() + async def get_bank( + bank_id: str | None = None, + ) -> str: + """ + Get the profile of a memory bank. + + Returns bank metadata including name, disposition, and mission. + + 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"}' + + profile = await memory.get_bank_profile( + target_bank, + request_context=_get_request_context(config), + ) + if "disposition" in profile and hasattr(profile["disposition"], "model_dump"): + profile["disposition"] = profile["disposition"].model_dump() + return json.dumps(profile, indent=2, default=str) + except Exception as e: + logger.error(f"Error getting bank: {e}", exc_info=True) + return f'{{"error": "{e}"}}' + + else: + + @mcp.tool() + async def get_bank() -> dict: + """ + Get the profile of this memory bank. + + Returns bank metadata including name, disposition, and mission. + """ + try: + target_bank = config.bank_id_resolver() + if target_bank is None: + return {"error": "No bank_id configured"} + + profile = await memory.get_bank_profile( + target_bank, + request_context=_get_request_context(config), + ) + if "disposition" in profile and hasattr(profile["disposition"], "model_dump"): + profile["disposition"] = profile["disposition"].model_dump() + return profile + except Exception as e: + logger.error(f"Error getting bank: {e}", exc_info=True) + return {"error": str(e)} + + +def _register_get_bank_stats(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Register the get_bank_stats tool (multi-bank only).""" + + @mcp.tool() + async def get_bank_stats( + bank_id: str | None = None, + ) -> str: + """ + Get statistics for a memory bank. + + Returns counts of nodes, links, and other metrics. + + 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"}' + + result = await memory.get_bank_stats( + target_bank, + request_context=_get_request_context(config), + ) + return json.dumps(result, indent=2, default=str) + except Exception as e: + logger.error(f"Error getting bank stats: {e}", exc_info=True) + return f'{{"error": "{e}"}}' + + +def _register_update_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Register the update_bank tool.""" + + if config.include_bank_id_param: + + @mcp.tool() + async def update_bank( + name: str | None = None, + mission: str | None = None, + bank_id: str | None = None, + ) -> str: + """ + Update a memory bank's metadata. + + Changes the name or mission of an existing bank. + + Args: + name: New human-friendly name for the bank + mission: New mission describing who the agent is and what they're trying to accomplish + 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 memory.update_bank( + target_bank, + name=name, + mission=mission, + request_context=_get_request_context(config), + ) + return json.dumps(result, indent=2, default=str) + except Exception as e: + logger.error(f"Error updating bank: {e}", exc_info=True) + return f'{{"error": "{e}"}}' + + else: + + @mcp.tool() + async def update_bank( + name: str | None = None, + mission: str | None = None, + ) -> dict: + """ + Update this memory bank's metadata. + + Changes the name or mission of the bank. + + Args: + name: New human-friendly name for the bank + mission: New mission describing who the agent is and what they're trying to accomplish + """ + try: + target_bank = config.bank_id_resolver() + if target_bank is None: + return {"error": "No bank_id configured"} + + result = await memory.update_bank( + target_bank, + name=name, + mission=mission, + request_context=_get_request_context(config), + ) + return result + except Exception as e: + logger.error(f"Error updating bank: {e}", exc_info=True) + return {"error": str(e)} + + +def _register_delete_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Register the delete_bank tool.""" + + if config.include_bank_id_param: + + @mcp.tool() + async def delete_bank( + bank_id: str | None = None, + ) -> str: + """ + Delete a memory bank and all its data. + + WARNING: This permanently deletes the bank and all its memories, documents, + mental models, directives, and other data. This action cannot be undone. + + 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"}' + + result = await memory.delete_bank( + target_bank, + request_context=_get_request_context(config), + ) + return json.dumps({"status": "deleted", "bank_id": target_bank, **result}, default=str) + except Exception as e: + logger.error(f"Error deleting bank: {e}", exc_info=True) + return f'{{"error": "{e}"}}' + + else: + + @mcp.tool() + async def delete_bank() -> dict: + """ + Delete this memory bank and all its data. + + WARNING: This permanently deletes the bank and all its memories, documents, + mental models, directives, and other data. This action cannot be undone. + """ + try: + target_bank = config.bank_id_resolver() + if target_bank is None: + return {"error": "No bank_id configured"} + + result = await memory.delete_bank( + target_bank, + request_context=_get_request_context(config), + ) + return {"status": "deleted", "bank_id": target_bank, **result} + except Exception as e: + logger.error(f"Error deleting bank: {e}", exc_info=True) + return {"error": str(e)} + + +def _register_clear_memories(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Register the clear_memories tool.""" + + if config.include_bank_id_param: + + @mcp.tool() + async def clear_memories( + type: str | None = None, + bank_id: str | None = None, + ) -> str: + """ + Clear all memories from a bank without deleting the bank itself. + + Optionally filter by fact type to only clear specific kinds of memories. + + Args: + type: Optional fact type filter: 'world', 'experience', or 'opinion'. If not specified, clears all. + 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 memory.delete_bank( + target_bank, + fact_type=type, + request_context=_get_request_context(config), + ) + return json.dumps({"status": "cleared", "bank_id": target_bank, **result}, default=str) + except Exception as e: + logger.error(f"Error clearing memories: {e}", exc_info=True) + return f'{{"error": "{e}"}}' + + else: + + @mcp.tool() + async def clear_memories( + type: str | None = None, + ) -> dict: + """ + Clear all memories from this bank without deleting the bank itself. + + Optionally filter by fact type to only clear specific kinds of memories. + + Args: + type: Optional fact type filter: 'world', 'experience', or 'opinion'. If not specified, clears all. + """ + try: + target_bank = config.bank_id_resolver() + if target_bank is None: + return {"error": "No bank_id configured"} + + result = await memory.delete_bank( + target_bank, + fact_type=type, + request_context=_get_request_context(config), + ) + return {"status": "cleared", "bank_id": target_bank, **result} + except Exception as e: + logger.error(f"Error clearing memories: {e}", exc_info=True) + return {"error": str(e)} diff --git a/hindsight-api/tests/test_mcp_extension.py b/hindsight-api/tests/test_mcp_extension.py index 8615967c..352c274d 100644 --- a/hindsight-api/tests/test_mcp_extension.py +++ b/hindsight-api/tests/test_mcp_extension.py @@ -165,5 +165,5 @@ class TestMCPExtensionIntegration: assert "create_bank" in tools # Extension tool also present assert "test_extension_tool" in tools - # At least 11 core + 1 extension = 12 tools (may grow as new tools are added) - assert len(tools) >= 12 + # At least 29 core + 1 extension = 30 tools (may grow as new tools are added) + assert len(tools) >= 30 diff --git a/hindsight-api/tests/test_mcp_tools.py b/hindsight-api/tests/test_mcp_tools.py index fb0eca77..2a259da9 100644 --- a/hindsight-api/tests/test_mcp_tools.py +++ b/hindsight-api/tests/test_mcp_tools.py @@ -77,8 +77,9 @@ class TestBuildContentDict: @pytest.fixture def mock_memory(): - """Create a mock MemoryEngine with mental model methods.""" + """Create a mock MemoryEngine with all MCP tool methods.""" memory = MagicMock() + # Mental model methods memory.list_mental_models = AsyncMock( return_value=[ {"id": "mm-1", "name": "Coding Prefs", "source_query": "coding preferences?", "content": "Prefers Python"}, @@ -104,6 +105,41 @@ def mock_memory(): } ) memory.delete_mental_model = AsyncMock(return_value=True) + + # Retain/recall/reflect + memory.retain_batch_async = AsyncMock() + memory.submit_async_retain = AsyncMock(return_value={"operation_id": "op-retain"}) + memory.recall_async = AsyncMock(return_value=MagicMock(model_dump_json=lambda indent=None: '{"results": []}', model_dump=lambda: {"results": []})) + memory.reflect_async = AsyncMock(return_value=MagicMock(model_dump_json=lambda indent=None: '{"text": "reflection"}', model_dump=lambda: {"text": "reflection"}, structured_output=None)) + + # Directive methods + memory.list_directives = AsyncMock(return_value=[{"id": "dir-1", "name": "Be concise", "content": "Keep responses short"}]) + memory.create_directive = AsyncMock(return_value={"id": "dir-new", "name": "Test", "content": "Test content"}) + memory.delete_directive = AsyncMock(return_value=True) + + # Memory browsing methods + memory.list_memory_units = AsyncMock(return_value={"items": [{"id": "mem-1", "content": "Test"}], "total": 1}) + memory.get_memory_unit = AsyncMock(return_value={"id": "mem-1", "content": "Test memory"}) + memory.delete_memory_unit = AsyncMock(return_value={"deleted_count": 1}) + + # Document methods + memory.list_documents = AsyncMock(return_value={"items": [{"id": "doc-1", "name": "Test Doc"}], "total": 1}) + memory.get_document = AsyncMock(return_value={"id": "doc-1", "name": "Test Doc"}) + memory.delete_document = AsyncMock(return_value={"deleted_memories": 5}) + + # Operation methods + memory.list_operations = AsyncMock(return_value={"items": [{"id": "op-1", "status": "completed"}]}) + memory.get_operation_status = AsyncMock(return_value={"id": "op-1", "status": "completed", "progress": 100}) + memory.cancel_operation = AsyncMock(return_value={"id": "op-1", "status": "cancelled"}) + + # Tags & bank methods + memory.list_tags = AsyncMock(return_value={"items": ["tag1", "tag2"], "total": 2}) + memory.get_bank_profile = AsyncMock(return_value={"id": "test-bank", "name": "Test Bank", "mission": "Testing"}) + memory.get_bank_stats = AsyncMock(return_value={"nodes": 100, "links": 50}) + memory.update_bank = AsyncMock(return_value={"id": "test-bank", "name": "Updated"}) + memory.delete_bank = AsyncMock(return_value={"deleted_memories": 10, "deleted_entities": 5}) + memory.list_banks = AsyncMock(return_value=[]) + return memory @@ -211,7 +247,7 @@ class TestMentalModelToolRegistration: assert request_context.api_key == "test-api-key" def test_mental_model_tools_in_default_set(self): - """Mental model tools should be in the default tools set when config.tools is None.""" + """All tools should be in the default tools set when config.tools is None.""" from fastmcp import FastMCP memory = MagicMock() @@ -229,6 +265,21 @@ class TestMentalModelToolRegistration: memory.submit_async_refresh_mental_model = AsyncMock() memory.update_mental_model = AsyncMock() memory.delete_mental_model = AsyncMock() + memory.list_directives = AsyncMock(return_value=[]) + memory.create_directive = AsyncMock() + memory.delete_directive = AsyncMock() + memory.list_memory_units = AsyncMock(return_value={}) + memory.get_memory_unit = AsyncMock() + memory.delete_memory_unit = AsyncMock() + memory.list_documents = AsyncMock(return_value={}) + memory.get_document = AsyncMock() + memory.delete_document = AsyncMock() + memory.list_operations = AsyncMock(return_value={}) + memory.get_operation_status = AsyncMock() + memory.cancel_operation = AsyncMock() + memory.list_tags = AsyncMock(return_value={}) + memory.get_bank_stats = AsyncMock(return_value={}) + memory.delete_bank = AsyncMock(return_value={}) mcp = FastMCP("test", stateless_http=True) config = MCPToolsConfig( @@ -241,6 +292,18 @@ class TestMentalModelToolRegistration: assert "list_mental_models" in tools assert "create_mental_model" in tools assert "refresh_mental_model" in tools + # New tools + assert "list_directives" in tools + assert "list_memories" in tools + assert "list_documents" in tools + assert "list_operations" in tools + assert "list_tags" in tools + assert "get_bank" in tools + assert "get_bank_stats" in tools + assert "update_bank" in tools + assert "delete_bank" in tools + assert "clear_memories" in tools + assert len(tools) == 29 @pytest.fixture @@ -644,3 +707,542 @@ class TestMentalModelInputValidation: result = await _tools(mcp_server_single_bank)["get_mental_model"].fn(mental_model_id="missing") assert isinstance(result, dict) assert "fixed-bank" in result["error"] + + +# ========================================================================= +# New Parameter Tests for Existing Tools +# ========================================================================= + + +def _make_mcp_server(mock_memory, tools, include_bank_id=True): + """Helper to create an MCP server with specific tools.""" + from fastmcp import FastMCP + + mcp = FastMCP("test", stateless_http=True) + config = MCPToolsConfig( + bank_id_resolver=lambda: "test-bank", + include_bank_id_param=include_bank_id, + tools=tools, + ) + register_mcp_tools(mcp, mock_memory, config) + return mcp + + +@pytest.mark.asyncio +class TestRetainNewParams: + """Tests for new retain parameters: tags, metadata, document_id.""" + + async def test_retain_with_tags(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"retain"}) + await _tools(mcp)["retain"].fn(content="test", tags=["user:123", "project:alpha"]) + call_args = mock_memory.submit_async_retain.call_args + contents = call_args.kwargs["contents"] + assert contents[0]["tags"] == ["user:123", "project:alpha"] + + async def test_retain_with_metadata(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"retain"}) + await _tools(mcp)["retain"].fn(content="test", metadata={"source": "slack"}) + call_args = mock_memory.submit_async_retain.call_args + contents = call_args.kwargs["contents"] + assert contents[0]["metadata"] == {"source": "slack"} + + async def test_retain_with_document_id(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"retain"}) + await _tools(mcp)["retain"].fn(content="test", document_id="doc-1") + call_args = mock_memory.submit_async_retain.call_args + contents = call_args.kwargs["contents"] + assert contents[0]["document_id"] == "doc-1" + + async def test_retain_without_new_params_backward_compat(self, mock_memory): + """Existing behavior preserved when new params not provided.""" + mcp = _make_mcp_server(mock_memory, {"retain"}) + await _tools(mcp)["retain"].fn(content="test") + call_args = mock_memory.submit_async_retain.call_args + contents = call_args.kwargs["contents"] + assert "tags" not in contents[0] + assert "metadata" not in contents[0] + assert "document_id" not in contents[0] + + +@pytest.mark.asyncio +class TestRecallNewParams: + """Tests for new recall parameters: budget, types, tags, tags_match, query_timestamp.""" + + async def test_recall_default_budget_high(self, mock_memory): + """Default budget should be HIGH (backward compat).""" + from hindsight_api.engine.memory_engine import Budget + + mcp = _make_mcp_server(mock_memory, {"recall"}) + await _tools(mcp)["recall"].fn(query="test") + call_kwargs = mock_memory.recall_async.call_args.kwargs + assert call_kwargs["budget"] == Budget.HIGH + + async def test_recall_budget_low(self, mock_memory): + from hindsight_api.engine.memory_engine import Budget + + mcp = _make_mcp_server(mock_memory, {"recall"}) + await _tools(mcp)["recall"].fn(query="test", budget="low") + call_kwargs = mock_memory.recall_async.call_args.kwargs + assert call_kwargs["budget"] == Budget.LOW + + async def test_recall_with_types(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"recall"}) + await _tools(mcp)["recall"].fn(query="test", types=["world"]) + call_kwargs = mock_memory.recall_async.call_args.kwargs + assert call_kwargs["fact_type"] == ["world"] + + async def test_recall_default_types_all(self, mock_memory): + from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES + + mcp = _make_mcp_server(mock_memory, {"recall"}) + await _tools(mcp)["recall"].fn(query="test") + call_kwargs = mock_memory.recall_async.call_args.kwargs + assert call_kwargs["fact_type"] == list(VALID_RECALL_FACT_TYPES) + + async def test_recall_with_tags(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"recall"}) + await _tools(mcp)["recall"].fn(query="test", tags=["project:x"]) + call_kwargs = mock_memory.recall_async.call_args.kwargs + assert call_kwargs["tags"] == ["project:x"] + assert call_kwargs["tags_match"] == "any" + + async def test_recall_with_query_timestamp(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"recall"}) + await _tools(mcp)["recall"].fn(query="test", query_timestamp="2024-01-01T00:00:00Z") + call_kwargs = mock_memory.recall_async.call_args.kwargs + assert call_kwargs["question_date"] == datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + + +@pytest.mark.asyncio +class TestReflectNewParams: + """Tests for new reflect parameters: max_tokens, response_schema, tags, tags_match.""" + + async def test_reflect_with_max_tokens(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"reflect"}) + await _tools(mcp)["reflect"].fn(query="test", max_tokens=2048) + call_kwargs = mock_memory.reflect_async.call_args.kwargs + assert call_kwargs["max_tokens"] == 2048 + + async def test_reflect_with_response_schema(self, mock_memory): + schema = {"type": "object", "properties": {"answer": {"type": "string"}}} + mock_memory.reflect_async = AsyncMock( + return_value=MagicMock( + model_dump_json=lambda indent=None: '{"text": "reflection"}', + model_dump=lambda: {"text": "reflection"}, + structured_output={"answer": "yes"}, + ) + ) + mcp = _make_mcp_server(mock_memory, {"reflect"}) + result = await _tools(mcp)["reflect"].fn(query="test", response_schema=schema) + call_kwargs = mock_memory.reflect_async.call_args.kwargs + assert call_kwargs["response_schema"] == schema + # Multi-bank returns JSON string + import json + + parsed = json.loads(result) + assert parsed["structured_output"] == {"answer": "yes"} + + async def test_reflect_with_tags(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"reflect"}) + await _tools(mcp)["reflect"].fn(query="test", tags=["scope:work"], tags_match="all") + call_kwargs = mock_memory.reflect_async.call_args.kwargs + assert call_kwargs["tags"] == ["scope:work"] + assert call_kwargs["tags_match"] == "all" + + async def test_reflect_without_tags_no_tags_in_kwargs(self, mock_memory): + """When tags not provided, they should not be passed to engine.""" + mcp = _make_mcp_server(mock_memory, {"reflect"}) + await _tools(mcp)["reflect"].fn(query="test") + call_kwargs = mock_memory.reflect_async.call_args.kwargs + assert "tags" not in call_kwargs + + +@pytest.mark.asyncio +class TestMentalModelTrigger: + """Tests for trigger_refresh_after_consolidation on create/update mental model.""" + + async def test_create_with_trigger(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"create_mental_model"}) + await _tools(mcp)["create_mental_model"].fn( + name="Test", source_query="query", trigger_refresh_after_consolidation=True + ) + call_kwargs = mock_memory.create_mental_model.call_args.kwargs + assert call_kwargs["trigger"] == {"refresh_after_consolidation": True} + + async def test_create_default_trigger_false(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"create_mental_model"}) + await _tools(mcp)["create_mental_model"].fn(name="Test", source_query="query") + call_kwargs = mock_memory.create_mental_model.call_args.kwargs + assert call_kwargs["trigger"] == {"refresh_after_consolidation": False} + + async def test_update_with_trigger(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"update_mental_model"}) + await _tools(mcp)["update_mental_model"].fn( + mental_model_id="mm-1", trigger_refresh_after_consolidation=True + ) + call_kwargs = mock_memory.update_mental_model.call_args.kwargs + assert call_kwargs["trigger"] == {"refresh_after_consolidation": True} + + async def test_update_without_trigger_no_trigger_in_kwargs(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"update_mental_model"}) + await _tools(mcp)["update_mental_model"].fn(mental_model_id="mm-1", name="New Name") + call_kwargs = mock_memory.update_mental_model.call_args.kwargs + assert "trigger" not in call_kwargs + + +# ========================================================================= +# Directive Tool Tests +# ========================================================================= + + +@pytest.mark.asyncio +class TestDirectiveTools: + async def test_list_directives_multi_bank(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"list_directives"}, include_bank_id=True) + result = await _tools(mcp)["list_directives"].fn() + assert '"dir-1"' in result + mock_memory.list_directives.assert_called_once() + assert mock_memory.list_directives.call_args[0][0] == "test-bank" + + async def test_list_directives_single_bank(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"list_directives"}, include_bank_id=False) + result = await _tools(mcp)["list_directives"].fn() + assert isinstance(result, dict) + assert len(result["items"]) == 1 + + async def test_create_directive(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"create_directive"}, include_bank_id=True) + result = await _tools(mcp)["create_directive"].fn(name="Test", content="Be concise", priority=5) + assert '"dir-new"' in result + call_args = mock_memory.create_directive.call_args + assert call_args[0][0] == "test-bank" + assert call_args.kwargs["name"] == "Test" + assert call_args.kwargs["content"] == "Be concise" + assert call_args.kwargs["priority"] == 5 + + async def test_delete_directive(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"delete_directive"}, include_bank_id=True) + result = await _tools(mcp)["delete_directive"].fn(directive_id="dir-1") + assert '"deleted"' in result + assert mock_memory.delete_directive.call_args[0][1] == "dir-1" + + async def test_delete_directive_not_found(self, mock_memory): + mock_memory.delete_directive.return_value = False + mcp = _make_mcp_server(mock_memory, {"delete_directive"}, include_bank_id=True) + result = await _tools(mcp)["delete_directive"].fn(directive_id="missing") + assert "not found" in result + + +# ========================================================================= +# Memory Browsing Tool Tests +# ========================================================================= + + +@pytest.mark.asyncio +class TestMemoryBrowsingTools: + async def test_list_memories_default(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"list_memories"}, include_bank_id=True) + result = await _tools(mcp)["list_memories"].fn() + assert '"mem-1"' in result + call_kwargs = mock_memory.list_memory_units.call_args.kwargs + assert call_kwargs["limit"] == 100 + assert call_kwargs["offset"] == 0 + + async def test_list_memories_with_filters(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"list_memories"}, include_bank_id=True) + await _tools(mcp)["list_memories"].fn(type="world", q="test query", limit=50) + call_kwargs = mock_memory.list_memory_units.call_args.kwargs + assert call_kwargs["fact_type"] == "world" + assert call_kwargs["search_query"] == "test query" + assert call_kwargs["limit"] == 50 + + async def test_get_memory(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"get_memory"}, include_bank_id=True) + result = await _tools(mcp)["get_memory"].fn(memory_id="mem-1") + assert '"mem-1"' in result + + async def test_get_memory_not_found(self, mock_memory): + mock_memory.get_memory_unit.return_value = None + mcp = _make_mcp_server(mock_memory, {"get_memory"}, include_bank_id=True) + result = await _tools(mcp)["get_memory"].fn(memory_id="missing") + assert "not found" in result + + async def test_delete_memory(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"delete_memory"}, include_bank_id=True) + result = await _tools(mcp)["delete_memory"].fn(memory_id="mem-1") + assert '"deleted"' in result + assert mock_memory.delete_memory_unit.call_args.kwargs["unit_id"] == "mem-1" + + async def test_list_memories_single_bank(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"list_memories"}, include_bank_id=False) + result = await _tools(mcp)["list_memories"].fn() + assert isinstance(result, dict) + + +# ========================================================================= +# Document Tool Tests +# ========================================================================= + + +@pytest.mark.asyncio +class TestDocumentTools: + async def test_list_documents(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"list_documents"}, include_bank_id=True) + result = await _tools(mcp)["list_documents"].fn() + assert '"doc-1"' in result + + async def test_get_document(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"get_document"}, include_bank_id=True) + result = await _tools(mcp)["get_document"].fn(document_id="doc-1") + assert '"doc-1"' in result + + async def test_get_document_not_found(self, mock_memory): + mock_memory.get_document.return_value = None + mcp = _make_mcp_server(mock_memory, {"get_document"}, include_bank_id=True) + result = await _tools(mcp)["get_document"].fn(document_id="missing") + assert "not found" in result + + async def test_delete_document(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"delete_document"}, include_bank_id=True) + result = await _tools(mcp)["delete_document"].fn(document_id="doc-1") + assert '"deleted"' in result + + async def test_list_documents_single_bank(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"list_documents"}, include_bank_id=False) + result = await _tools(mcp)["list_documents"].fn() + assert isinstance(result, dict) + + +# ========================================================================= +# Operation Tool Tests +# ========================================================================= + + +@pytest.mark.asyncio +class TestOperationTools: + async def test_list_operations(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"list_operations"}, include_bank_id=True) + result = await _tools(mcp)["list_operations"].fn() + assert '"op-1"' in result + + async def test_list_operations_with_status(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"list_operations"}, include_bank_id=True) + await _tools(mcp)["list_operations"].fn(status="completed", limit=10) + call_kwargs = mock_memory.list_operations.call_args.kwargs + assert call_kwargs["status"] == "completed" + assert call_kwargs["limit"] == 10 + + async def test_get_operation(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"get_operation"}, include_bank_id=True) + result = await _tools(mcp)["get_operation"].fn(operation_id="op-1") + assert '"op-1"' in result + + async def test_cancel_operation(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"cancel_operation"}, include_bank_id=True) + result = await _tools(mcp)["cancel_operation"].fn(operation_id="op-1") + assert '"cancelled"' in result + + async def test_list_operations_single_bank(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"list_operations"}, include_bank_id=False) + result = await _tools(mcp)["list_operations"].fn() + assert isinstance(result, dict) + + +# ========================================================================= +# Tags & Bank Tool Tests +# ========================================================================= + + +@pytest.mark.asyncio +class TestTagsAndBankTools: + async def test_list_tags(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"list_tags"}, include_bank_id=True) + result = await _tools(mcp)["list_tags"].fn(q="project:*", limit=50) + call_kwargs = mock_memory.list_tags.call_args.kwargs + assert call_kwargs["pattern"] == "project:*" + assert call_kwargs["limit"] == 50 + + async def test_get_bank(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"get_bank"}, include_bank_id=True) + result = await _tools(mcp)["get_bank"].fn() + assert '"test-bank"' in result or "test-bank" in result + + async def test_get_bank_stats(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"get_bank_stats"}, include_bank_id=True) + result = await _tools(mcp)["get_bank_stats"].fn() + assert "100" in result # nodes count + + async def test_update_bank(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"update_bank"}, include_bank_id=True) + result = await _tools(mcp)["update_bank"].fn(name="New Name", mission="New Mission") + call_kwargs = mock_memory.update_bank.call_args.kwargs + assert call_kwargs["name"] == "New Name" + assert call_kwargs["mission"] == "New Mission" + + async def test_delete_bank(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"delete_bank"}, include_bank_id=True) + result = await _tools(mcp)["delete_bank"].fn() + assert '"deleted"' in result + mock_memory.delete_bank.assert_called_once() + + async def test_clear_memories(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"clear_memories"}, include_bank_id=True) + result = await _tools(mcp)["clear_memories"].fn() + assert '"cleared"' in result + mock_memory.delete_bank.assert_called_once() + + async def test_clear_memories_with_type_filter(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"clear_memories"}, include_bank_id=True) + await _tools(mcp)["clear_memories"].fn(type="world") + call_kwargs = mock_memory.delete_bank.call_args.kwargs + assert call_kwargs["fact_type"] == "world" + + async def test_list_tags_single_bank(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"list_tags"}, include_bank_id=False) + result = await _tools(mcp)["list_tags"].fn() + assert isinstance(result, dict) + + async def test_get_bank_single_bank(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"get_bank"}, include_bank_id=False) + result = await _tools(mcp)["get_bank"].fn() + assert isinstance(result, dict) + + async def test_delete_bank_single_bank(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"delete_bank"}, include_bank_id=False) + result = await _tools(mcp)["delete_bank"].fn() + assert isinstance(result, dict) + assert result["status"] == "deleted" + + async def test_clear_memories_single_bank(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"clear_memories"}, include_bank_id=False) + result = await _tools(mcp)["clear_memories"].fn() + assert isinstance(result, dict) + assert result["status"] == "cleared" + + +# ========================================================================= +# Additional Error Handling & Edge Case Tests +# ========================================================================= + + +@pytest.mark.asyncio +class TestOperationErrorHandling: + """Error handling tests for operation tools.""" + + async def test_get_operation_engine_error(self, mock_memory): + mock_memory.get_operation_status.side_effect = RuntimeError("Operation not found") + mcp = _make_mcp_server(mock_memory, {"get_operation"}, include_bank_id=True) + result = await _tools(mcp)["get_operation"].fn(operation_id="missing") + assert "error" in result + assert "Operation not found" in result + + async def test_get_operation_engine_error_single_bank(self, mock_memory): + mock_memory.get_operation_status.side_effect = RuntimeError("Operation not found") + mcp = _make_mcp_server(mock_memory, {"get_operation"}, include_bank_id=False) + result = await _tools(mcp)["get_operation"].fn(operation_id="missing") + assert isinstance(result, dict) + assert "Operation not found" in result["error"] + + async def test_cancel_operation_engine_error(self, mock_memory): + mock_memory.cancel_operation.side_effect = RuntimeError("Cannot cancel completed operation") + mcp = _make_mcp_server(mock_memory, {"cancel_operation"}, include_bank_id=True) + result = await _tools(mcp)["cancel_operation"].fn(operation_id="op-done") + assert "error" in result + assert "Cannot cancel" in result + + async def test_cancel_operation_engine_error_single_bank(self, mock_memory): + mock_memory.cancel_operation.side_effect = RuntimeError("Cannot cancel") + mcp = _make_mcp_server(mock_memory, {"cancel_operation"}, include_bank_id=False) + result = await _tools(mcp)["cancel_operation"].fn(operation_id="op-done") + assert isinstance(result, dict) + assert "Cannot cancel" in result["error"] + + +@pytest.mark.asyncio +class TestDeleteErrorHandling: + """Error handling tests for delete operations.""" + + async def test_delete_memory_engine_error(self, mock_memory): + mock_memory.delete_memory_unit.side_effect = RuntimeError("DB error") + mcp = _make_mcp_server(mock_memory, {"delete_memory"}, include_bank_id=True) + result = await _tools(mcp)["delete_memory"].fn(memory_id="mem-1") + assert "error" in result + assert "DB error" in result + + async def test_delete_memory_single_bank(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"delete_memory"}, include_bank_id=False) + result = await _tools(mcp)["delete_memory"].fn(memory_id="mem-1") + assert isinstance(result, dict) + assert result["status"] == "deleted" + + async def test_delete_document_engine_error(self, mock_memory): + mock_memory.delete_document.side_effect = RuntimeError("DB error") + mcp = _make_mcp_server(mock_memory, {"delete_document"}, include_bank_id=True) + result = await _tools(mcp)["delete_document"].fn(document_id="doc-1") + assert "error" in result + assert "DB error" in result + + async def test_delete_document_single_bank(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"delete_document"}, include_bank_id=False) + result = await _tools(mcp)["delete_document"].fn(document_id="doc-1") + assert isinstance(result, dict) + assert result["status"] == "deleted" + + +@pytest.mark.asyncio +class TestUpdateBankVariants: + """Additional tests for update_bank tool.""" + + async def test_update_bank_single_bank(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"update_bank"}, include_bank_id=False) + result = await _tools(mcp)["update_bank"].fn(name="New Name") + assert isinstance(result, dict) + call_kwargs = mock_memory.update_bank.call_args.kwargs + assert call_kwargs["name"] == "New Name" + + async def test_update_bank_engine_error(self, mock_memory): + mock_memory.update_bank.side_effect = RuntimeError("DB error") + mcp = _make_mcp_server(mock_memory, {"update_bank"}, include_bank_id=True) + result = await _tools(mcp)["update_bank"].fn(name="X") + assert "error" in result + + async def test_get_bank_stats_engine_error(self, mock_memory): + mock_memory.get_bank_stats.side_effect = RuntimeError("DB error") + mcp = _make_mcp_server(mock_memory, {"get_bank_stats"}, include_bank_id=True) + result = await _tools(mcp)["get_bank_stats"].fn() + assert "error" in result + + +@pytest.mark.asyncio +class TestEmptyListReturns: + """Tests that empty lists are handled gracefully.""" + + async def test_list_memories_empty(self, mock_memory): + mock_memory.list_memory_units.return_value = {"items": [], "total": 0} + mcp = _make_mcp_server(mock_memory, {"list_memories"}, include_bank_id=True) + result = await _tools(mcp)["list_memories"].fn() + assert '"items": []' in result or "[]" in result + + async def test_list_documents_empty(self, mock_memory): + mock_memory.list_documents.return_value = {"items": [], "total": 0} + mcp = _make_mcp_server(mock_memory, {"list_documents"}, include_bank_id=True) + result = await _tools(mcp)["list_documents"].fn() + assert '"items": []' in result or "[]" in result + + async def test_list_operations_empty(self, mock_memory): + mock_memory.list_operations.return_value = {"items": []} + mcp = _make_mcp_server(mock_memory, {"list_operations"}, include_bank_id=True) + result = await _tools(mcp)["list_operations"].fn() + assert '"items": []' in result or "[]" in result + + async def test_list_directives_empty(self, mock_memory): + mock_memory.list_directives.return_value = [] + mcp = _make_mcp_server(mock_memory, {"list_directives"}, include_bank_id=True) + result = await _tools(mcp)["list_directives"].fn() + assert "[]" in result + + async def test_list_tags_empty(self, mock_memory): + mock_memory.list_tags.return_value = {"items": [], "total": 0} + mcp = _make_mcp_server(mock_memory, {"list_tags"}, include_bank_id=True) + result = await _tools(mcp)["list_tags"].fn() + assert '"items": []' in result or "[]" in result diff --git a/hindsight-docs/docs/developer/mcp-server.md b/hindsight-docs/docs/developer/mcp-server.md index 5048bd53..3a8b61cf 100644 --- a/hindsight-docs/docs/developer/mcp-server.md +++ b/hindsight-docs/docs/developer/mcp-server.md @@ -100,12 +100,12 @@ The MCP server operates in two modes depending on the URL: | Mode | URL | Tools | bank_id | |------|-----|-------|---------| -| **Single-bank** | `/mcp/{bank_id}/` | Memory + mental model tools | Implicit from URL | -| **Multi-bank** | `/mcp/` | All tools including bank management | Explicit `bank_id` parameter on each tool | +| **Single-bank** | `/mcp/{bank_id}/` | 26 tools (memory, mental models, directives, documents, operations, tags, bank management) | Implicit from URL | +| **Multi-bank** | `/mcp/` | All 29 tools including `list_banks`, `create_bank`, `get_bank_stats` | Explicit `bank_id` parameter on each tool | **Single-bank mode** (recommended) scopes all operations to the bank in the URL. Tools don't expose a `bank_id` parameter. -**Multi-bank mode** exposes all tools with an optional `bank_id` parameter, plus bank management tools (`list_banks`, `create_bank`). +**Multi-bank mode** exposes all tools with an optional `bank_id` parameter, plus bank management tools (`list_banks`, `create_bank`, `get_bank_stats`). --- @@ -120,6 +120,9 @@ Store information to long-term memory. | `content` | string | Yes | The fact or memory to store | | `context` | string | No | Category for the memory (default: `general`) | | `timestamp` | string | No | ISO 8601 timestamp for when the event occurred | +| `tags` | list[string] | No | Tags for organizing and filtering this memory | +| `metadata` | object | No | Key-value metadata to attach (e.g., `{"source": "slack"}`) | +| `document_id` | string | No | Associate this memory with an existing document | **Example:** ```json @@ -127,7 +130,8 @@ Store information to long-term memory. "name": "retain", "arguments": { "content": "User prefers Python over JavaScript for backend development", - "context": "programming_preferences" + "context": "programming_preferences", + "tags": ["user:alice", "preferences"] } } ``` @@ -148,13 +152,20 @@ Search memories to provide personalized responses. |-----------|------|----------|-------------| | `query` | string | Yes | Natural language search query | | `max_tokens` | integer | No | Maximum tokens to return (default: 4096) | +| `budget` | string | No | Search thoroughness: `low`, `mid`, or `high` (default: `high`) | +| `types` | list[string] | No | Filter by fact type: `world`, `experience`, `opinion`. Defaults to all | +| `tags` | list[string] | No | Filter memories by tags | +| `tags_match` | string | No | Tag matching mode: `any` (default) or `all` | +| `query_timestamp` | string | No | ISO 8601 timestamp — recall as if asking at this point in time | **Example:** ```json { "name": "recall", "arguments": { - "query": "What are the user's programming language preferences?" + "query": "What are the user's programming language preferences?", + "tags": ["preferences"], + "budget": "high" } } ``` @@ -176,6 +187,10 @@ Generate thoughtful analysis by synthesizing stored memories with the bank's per | `query` | string | Yes | The question or topic to reflect on | | `context` | string | No | Optional context about why this reflection is needed | | `budget` | string | No | Search budget: `low`, `mid`, or `high` (default: `low`) | +| `max_tokens` | integer | No | Maximum tokens in the response (default: 4096) | +| `response_schema` | object | No | JSON Schema for structured output. When provided, the response includes a `structured_output` field | +| `tags` | list[string] | No | Filter memories by tags before reflecting | +| `tags_match` | string | No | Tag matching mode: `any` (default) or `all` | **Example:** ```json @@ -183,7 +198,8 @@ Generate thoughtful analysis by synthesizing stored memories with the bank's per "name": "reflect", "arguments": { "query": "Based on my past decisions, what architectural style do I prefer?", - "budget": "mid" + "budget": "mid", + "tags": ["architecture"] } } ``` @@ -206,6 +222,7 @@ Create a mental model — a living document that stays current with your memorie | `mental_model_id` | string | No | Custom ID (alphanumeric lowercase with hyphens). Auto-generated if not provided | | `tags` | list[string] | No | Tags for organizing and filtering models | | `max_tokens` | integer | No | Maximum tokens for model content (default: 2048) | +| `trigger_refresh_after_consolidation` | boolean | No | Auto-refresh this model after memory consolidation (default: `false`) | **Example:** ```json @@ -254,6 +271,7 @@ Update a mental model's metadata or settings. | `source_query` | string | No | New source query | | `tags` | list[string] | No | New tags | | `max_tokens` | integer | No | New max tokens | +| `trigger_refresh_after_consolidation` | boolean | No | Auto-refresh after consolidation. Only set when you want to change this setting | --- @@ -295,6 +313,186 @@ Create a new memory bank or retrieve an existing one. --- +### list_directives + +List all directives in a bank. Directives are instructions that guide how the memory system processes and responds to queries. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `tags` | list[string] | No | Filter directives by tags | +| `active_only` | boolean | No | Only return active directives (default: `true`) | + +--- + +### create_directive + +Create a new directive in a bank. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | Yes | Human-readable name for the directive | +| `content` | string | Yes | The directive content/instruction | +| `priority` | integer | No | Priority level (higher = more important) | +| `is_active` | boolean | No | Whether the directive is active (default: `true`) | +| `tags` | list[string] | No | Tags for organizing directives | + +--- + +### delete_directive + +Delete a directive by ID. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `directive_id` | string | Yes | The ID of the directive to delete | + +--- + +### list_memories + +Browse stored memories with optional filtering and pagination. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `type` | string | No | Filter by fact type: `world`, `experience`, or `opinion` | +| `q` | string | No | Search query to filter memories | +| `limit` | integer | No | Maximum number of results (default: 100) | +| `offset` | integer | No | Number of results to skip for pagination (default: 0) | + +--- + +### get_memory + +Retrieve a specific memory by ID. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `memory_id` | string | Yes | The ID of the memory to retrieve | + +--- + +### delete_memory + +Permanently delete a specific memory. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `memory_id` | string | Yes | The ID of the memory to delete | + +--- + +### list_documents + +List documents that have been ingested into the memory bank. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `q` | string | No | Search query to filter documents | +| `limit` | integer | No | Maximum number of results (default: 100) | + +--- + +### get_document + +Retrieve a specific document by ID, including its metadata. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `document_id` | string | Yes | The ID of the document to retrieve | + +--- + +### delete_document + +Delete a document and all memories linked to it. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `document_id` | string | Yes | The ID of the document to delete | + +--- + +### list_operations + +List async operations (retain processing, mental model refresh, etc.) with optional status filtering. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `status` | string | No | Filter by status: `pending`, `running`, `completed`, `failed`, `cancelled` | +| `limit` | integer | No | Maximum number of results (default: 100) | + +--- + +### get_operation + +Get the status and details of an async operation. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `operation_id` | string | Yes | The ID of the operation to check | + +--- + +### cancel_operation + +Cancel a pending or running async operation. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `operation_id` | string | Yes | The ID of the operation to cancel | + +--- + +### list_tags + +List all unique tags used in a bank, optionally filtered by pattern. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `q` | string | No | Glob pattern to filter tags (e.g., `project:*`) | +| `limit` | integer | No | Maximum number of results (default: 100) | + +--- + +### get_bank + +Get information about a memory bank, including its name, mission, and disposition. + +--- + +### get_bank_stats (multi-bank mode only) + +Get statistics for a memory bank (node/link counts). + +--- + +### update_bank + +Update a memory bank's metadata. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | No | New human-friendly name for the bank | +| `mission` | string | No | New mission describing who the agent is and what they're trying to accomplish | + +--- + +### delete_bank + +Permanently delete a memory bank and all its data (memories, documents, entities, mental models). + +--- + +### clear_memories + +Clear all memories from a bank without deleting the bank itself. Optionally filter by fact type to only clear specific kinds of memories. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `type` | string | No | Fact type to clear: `world`, `experience`, or `opinion`. If not specified, clears all | + +--- + ## 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. diff --git a/hindsight-docs/docs/sdks/integrations/local-mcp.md b/hindsight-docs/docs/sdks/integrations/local-mcp.md index 8d3740a3..524b2b2f 100644 --- a/hindsight-docs/docs/sdks/integrations/local-mcp.md +++ b/hindsight-docs/docs/sdks/integrations/local-mcp.md @@ -66,22 +66,74 @@ claude mcp add --transport http hindsight http://localhost:8888/mcp/my-bank/ ## Available Tools -The local server exposes the full tool set: +The local server exposes the full tool set (29 tools in multi-bank mode, 26 in single-bank mode): + +**Core Memory** + +| Tool | Description | +|------|-------------| +| `retain` | Store information to long-term memory with optional tags, metadata, and document association | +| `recall` | Search memories with natural language, configurable budget, type filters, and tag filters | +| `reflect` | Synthesize memories into a reasoned answer with optional structured output | + +**Mental Models** | Tool | Description | |------|-------------| -| `retain` | Store information to long-term memory (fire-and-forget) | -| `recall` | Search memories with natural language | -| `reflect` | Synthesize memories into a reasoned answer | -| `list_banks` | List all memory banks | -| `create_bank` | Create or configure a memory bank | | `list_mental_models` | List pinned reflections for a bank | | `get_mental_model` | Get a specific mental model | -| `create_mental_model` | Create a new mental model | +| `create_mental_model` | Create a new mental model with optional auto-refresh trigger | | `update_mental_model` | Update a mental model's metadata | | `delete_mental_model` | Delete a mental model | | `refresh_mental_model` | Regenerate a mental model's content | +**Directives** + +| Tool | Description | +|------|-------------| +| `list_directives` | List directives that guide memory processing | +| `create_directive` | Create a new directive | +| `delete_directive` | Delete a directive | + +**Memory Browsing** + +| Tool | Description | +|------|-------------| +| `list_memories` | Browse memories with filtering and pagination | +| `get_memory` | Get a specific memory by ID | +| `delete_memory` | Delete a specific memory | + +**Documents** + +| Tool | Description | +|------|-------------| +| `list_documents` | List ingested documents | +| `get_document` | Get a specific document | +| `delete_document` | Delete a document and its linked memories | + +**Operations** + +| Tool | Description | +|------|-------------| +| `list_operations` | List async operations with status filtering | +| `get_operation` | Check operation status and progress | +| `cancel_operation` | Cancel a pending or running operation | + +**Tags & Bank Management** + +| Tool | Description | +|------|-------------| +| `list_tags` | List unique tags used in a bank | +| `get_bank` | Get bank profile (name, mission, disposition) | +| `get_bank_stats` | Get bank statistics (multi-bank only) | +| `update_bank` | Update bank name or mission | +| `delete_bank` | Delete an entire bank and all its data | +| `clear_memories` | Clear memories without deleting the bank | +| `list_banks` | List all memory banks (multi-bank only) | +| `create_bank` | Create or configure a memory bank (multi-bank only) | + +For detailed parameter documentation, see the [MCP Server reference](/developer/mcp-server#available-tools). + ## Environment Variables All standard [Hindsight configuration variables](/developer/configuration) are supported. Key ones for local use: