* fix: move mental model usage metering into engine for MCP support Mental model validation hooks (validate_mental_model_get, validate_mental_model_refresh) were only called in REST HTTP handlers, not in the engine. MCP tools call engine methods directly, so usage metering was skipped entirely for MCP mental model operations. Moved pre-validation and post-completion hooks into memory_engine.py (matching the retain/recall/reflect pattern) and removed the duplicate code from http.py. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove double validation from create_mental_model and add internal checks - Remove pre-validation from create_mental_model since callers always call submit_async_refresh_mental_model next (which validates), preventing double credit checks - Add is_internal checks to mental model metering validators (matching the existing pattern for recall/reflect) so background worker tasks skip billing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: prevent 307 redirect on /mcp that breaks MCP tool discovery Starlette's Mount class redirects /mcp to /mcp/ with a 307 Temporary Redirect. Many MCP clients don't follow POST redirects, which causes tool discovery to fail (0 tools discovered despite successful auth). Add _MCPPathRewriteMiddleware that rewrites /mcp to /mcp/ at the ASGI level before routing, preventing the redirect entirely. Both /mcp and /mcp/ now work identically. Add regression test test_mcp_no_trailing_slash_works to verify URLs with and without trailing slashes discover tools correctly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * harden MCP server for real-world usage - Remove MCP_ENDPOINTS blocklist so banks named "sse"/"messages" route correctly - Scope SSE body rewriting to text/event-stream responses only to prevent data corruption - Add _validate_mental_model_inputs for name, source_query, max_tokens validation in MCP tools - Improve "not found" error messages to include bank_id context - Fix fragile tool count assertions (exact → minimum bounds) - Add integration tests: tool execution, input validation, edge-case bank names - Add unit tests for validation helper and tool-level validation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: replace Mount + rewrite middleware with wrapping middleware Starlette's Mount class redirects /mcp -> /mcp/ with 307, which MCP clients don't follow. Previously we patched this with _MCPPathRewriteMiddleware. Now MCPMiddleware wraps the FastAPI app directly via add_middleware, intercepting /mcp* requests before they reach Starlette's router. No Mount means no redirect. - Remove _MCPPathRewriteMiddleware (no longer needed) - Remove app.mount() call - Add prefix parameter to MCPMiddleware - Use app.add_middleware() for proper Starlette integration - Simplify path stripping (just remove prefix, no mount/root_path handling) - Update routing test to match current behavior (no MCP_ENDPOINTS blocklist) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: update stale docstring referencing removed _MCPPathRewriteMiddleware Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1116 lines
44 KiB
Python
1116 lines
44 KiB
Python
"""Shared MCP tool implementations for Hindsight.
|
|
|
|
This module provides the core tool logic used by both:
|
|
- mcp_local.py (stdio transport for Claude Code)
|
|
- api/mcp.py (HTTP transport for API server)
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from typing import Any, Callable
|
|
|
|
from fastmcp import FastMCP
|
|
|
|
from hindsight_api import MemoryEngine
|
|
from hindsight_api.config import (
|
|
DEFAULT_MCP_RECALL_DESCRIPTION,
|
|
DEFAULT_MCP_RETAIN_DESCRIPTION,
|
|
)
|
|
from hindsight_api.engine.memory_engine import Budget
|
|
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
|
|
from hindsight_api.models import RequestContext
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class MCPToolsConfig:
|
|
"""Configuration for MCP tools registration."""
|
|
|
|
# How to resolve bank_id for operations
|
|
bank_id_resolver: Callable[[], str | None]
|
|
|
|
# How to resolve API key for tenant auth (optional)
|
|
api_key_resolver: Callable[[], str | None] | None = None
|
|
|
|
# How to resolve tenant_id for usage metering (set by MCP middleware after auth)
|
|
tenant_id_resolver: Callable[[], str | None] | None = None
|
|
|
|
# How to resolve api_key_id for usage metering (set by MCP middleware after auth)
|
|
api_key_id_resolver: Callable[[], str | None] | None = None
|
|
|
|
# Whether to include bank_id as a parameter on tools (for multi-bank support)
|
|
include_bank_id_param: bool = False
|
|
|
|
# Which tools to register
|
|
tools: set[str] | None = None # None means all tools
|
|
|
|
# Custom descriptions (if None, uses defaults)
|
|
retain_description: str | None = None
|
|
recall_description: str | None = None
|
|
|
|
# Retain behavior
|
|
retain_fire_and_forget: bool = False # If True, use asyncio.create_task pattern
|
|
|
|
|
|
def _get_request_context(config: MCPToolsConfig) -> RequestContext:
|
|
"""Create RequestContext with auth details from resolvers.
|
|
|
|
This enables tenant auth and usage metering to work with MCP tools by propagating
|
|
the authentication results from the MCP middleware to the memory engine.
|
|
"""
|
|
api_key = config.api_key_resolver() if config.api_key_resolver else None
|
|
tenant_id = config.tenant_id_resolver() if config.tenant_id_resolver else None
|
|
api_key_id = config.api_key_id_resolver() if config.api_key_id_resolver else None
|
|
return RequestContext(api_key=api_key, tenant_id=tenant_id, api_key_id=api_key_id)
|
|
|
|
|
|
def parse_timestamp(timestamp: str) -> datetime | None:
|
|
"""Parse an ISO format timestamp string.
|
|
|
|
Args:
|
|
timestamp: ISO format timestamp (e.g., '2024-01-15T10:30:00Z')
|
|
|
|
Returns:
|
|
Parsed datetime or None if invalid
|
|
|
|
Raises:
|
|
ValueError: If timestamp format is invalid
|
|
"""
|
|
try:
|
|
return datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
|
except ValueError as e:
|
|
raise ValueError(
|
|
f"Invalid timestamp format '{timestamp}'. "
|
|
"Expected ISO format like '2024-01-15T10:30:00' or '2024-01-15T10:30:00Z'"
|
|
) from e
|
|
|
|
|
|
def build_content_dict(
|
|
content: str,
|
|
context: str,
|
|
timestamp: str | None = None,
|
|
) -> tuple[dict[str, Any], str | None]:
|
|
"""Build a content dict for retain operations.
|
|
|
|
Args:
|
|
content: The memory content
|
|
context: Category for the memory
|
|
timestamp: Optional ISO timestamp
|
|
|
|
Returns:
|
|
Tuple of (content_dict, error_message). error_message is None if successful.
|
|
"""
|
|
content_dict: dict[str, Any] = {"content": content, "context": context}
|
|
|
|
if timestamp:
|
|
try:
|
|
parsed_timestamp = parse_timestamp(timestamp)
|
|
content_dict["event_date"] = parsed_timestamp
|
|
except ValueError as e:
|
|
return {}, str(e)
|
|
|
|
return content_dict, None
|
|
|
|
|
|
def register_mcp_tools(
|
|
mcp: FastMCP,
|
|
memory: MemoryEngine,
|
|
config: MCPToolsConfig,
|
|
) -> None:
|
|
"""Register MCP tools on a FastMCP server.
|
|
|
|
Args:
|
|
mcp: FastMCP server instance
|
|
memory: MemoryEngine instance
|
|
config: Tool configuration
|
|
"""
|
|
tools_to_register = config.tools or {
|
|
"retain",
|
|
"recall",
|
|
"reflect",
|
|
"list_banks",
|
|
"create_bank",
|
|
"list_mental_models",
|
|
"get_mental_model",
|
|
"create_mental_model",
|
|
"update_mental_model",
|
|
"delete_mental_model",
|
|
"refresh_mental_model",
|
|
}
|
|
|
|
if "retain" in tools_to_register:
|
|
_register_retain(mcp, memory, config)
|
|
|
|
if "recall" in tools_to_register:
|
|
_register_recall(mcp, memory, config)
|
|
|
|
if "reflect" in tools_to_register:
|
|
_register_reflect(mcp, memory, config)
|
|
|
|
if "list_banks" in tools_to_register:
|
|
_register_list_banks(mcp, memory, config)
|
|
|
|
if "create_bank" in tools_to_register:
|
|
_register_create_bank(mcp, memory, config)
|
|
|
|
# Mental model tools
|
|
if "list_mental_models" in tools_to_register:
|
|
_register_list_mental_models(mcp, memory, config)
|
|
|
|
if "get_mental_model" in tools_to_register:
|
|
_register_get_mental_model(mcp, memory, config)
|
|
|
|
if "create_mental_model" in tools_to_register:
|
|
_register_create_mental_model(mcp, memory, config)
|
|
|
|
if "update_mental_model" in tools_to_register:
|
|
_register_update_mental_model(mcp, memory, config)
|
|
|
|
if "delete_mental_model" in tools_to_register:
|
|
_register_delete_mental_model(mcp, memory, config)
|
|
|
|
if "refresh_mental_model" in tools_to_register:
|
|
_register_refresh_mental_model(mcp, memory, config)
|
|
|
|
|
|
def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
|
"""Register the retain tool."""
|
|
description = config.retain_description or DEFAULT_MCP_RETAIN_DESCRIPTION
|
|
|
|
if config.include_bank_id_param:
|
|
if config.retain_fire_and_forget:
|
|
|
|
@mcp.tool(description=description)
|
|
async def retain(
|
|
content: str,
|
|
context: str = "general",
|
|
timestamp: str | None = None,
|
|
bank_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.
|
|
bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations.
|
|
"""
|
|
import asyncio
|
|
|
|
target_bank = bank_id or config.bank_id_resolver()
|
|
if target_bank is None:
|
|
return {"status": "error", "message": "No bank_id configured"}
|
|
|
|
content_dict, error = build_content_dict(content, context, timestamp)
|
|
if error:
|
|
return {"status": "error", "message": error}
|
|
|
|
request_context = _get_request_context(config)
|
|
|
|
async def _retain():
|
|
try:
|
|
await memory.retain_batch_async(
|
|
bank_id=target_bank,
|
|
contents=[content_dict],
|
|
request_context=request_context,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error storing memory: {e}", exc_info=True)
|
|
|
|
asyncio.create_task(_retain())
|
|
return {"status": "accepted", "message": "Memory storage initiated"}
|
|
|
|
else:
|
|
|
|
@mcp.tool(description=description)
|
|
async def retain(
|
|
content: str,
|
|
context: str = "general",
|
|
timestamp: str | None = None,
|
|
async_processing: bool = True,
|
|
bank_id: str | None = None,
|
|
) -> str:
|
|
"""
|
|
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.
|
|
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.
|
|
"""
|
|
try:
|
|
target_bank = bank_id or config.bank_id_resolver()
|
|
if target_bank is None:
|
|
return "Error: No bank_id configured"
|
|
|
|
content_dict, error = build_content_dict(content, context, timestamp)
|
|
if error:
|
|
return f"Error: {error}"
|
|
|
|
contents = [content_dict]
|
|
request_context = _get_request_context(config)
|
|
if async_processing:
|
|
result = await memory.submit_async_retain(
|
|
bank_id=target_bank, contents=contents, request_context=request_context
|
|
)
|
|
return f"Memory queued for background processing (operation_id: {result.get('operation_id', 'N/A')})"
|
|
else:
|
|
await memory.retain_batch_async(
|
|
bank_id=target_bank,
|
|
contents=contents,
|
|
request_context=request_context,
|
|
)
|
|
return f"Memory stored successfully in bank '{target_bank}'"
|
|
except Exception as e:
|
|
logger.error(f"Error storing memory: {e}", exc_info=True)
|
|
return f"Error: {str(e)}"
|
|
|
|
else:
|
|
# No bank_id param - use fixed bank from resolver
|
|
|
|
@mcp.tool(description=description)
|
|
async def retain(
|
|
content: str,
|
|
context: str = "general",
|
|
timestamp: 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.
|
|
"""
|
|
import asyncio
|
|
|
|
target_bank = config.bank_id_resolver()
|
|
if target_bank is None:
|
|
return {"status": "error", "message": "No bank_id configured"}
|
|
|
|
content_dict, error = build_content_dict(content, context, timestamp)
|
|
if error:
|
|
return {"status": "error", "message": error}
|
|
|
|
request_context = _get_request_context(config)
|
|
|
|
async def _retain():
|
|
try:
|
|
await memory.retain_batch_async(
|
|
bank_id=target_bank,
|
|
contents=[content_dict],
|
|
request_context=request_context,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error storing memory: {e}", exc_info=True)
|
|
|
|
asyncio.create_task(_retain())
|
|
return {"status": "accepted", "message": "Memory storage initiated"}
|
|
|
|
|
|
def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
|
"""Register the recall tool."""
|
|
description = config.recall_description or DEFAULT_MCP_RECALL_DESCRIPTION
|
|
|
|
if config.include_bank_id_param:
|
|
|
|
@mcp.tool(description=description)
|
|
async def recall(
|
|
query: str,
|
|
max_tokens: int = 4096,
|
|
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)
|
|
bank_id: Optional bank to search in (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"
|
|
|
|
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),
|
|
)
|
|
|
|
return recall_result.model_dump_json(indent=2)
|
|
except Exception as e:
|
|
logger.error(f"Error searching: {e}", exc_info=True)
|
|
return f'{{"error": "{e}", "results": []}}'
|
|
|
|
else:
|
|
|
|
@mcp.tool(description=description)
|
|
async def recall(
|
|
query: str,
|
|
max_tokens: int = 4096,
|
|
) -> 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)
|
|
"""
|
|
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),
|
|
)
|
|
|
|
return recall_result.model_dump()
|
|
except Exception as e:
|
|
logger.error(f"Error searching: {e}", exc_info=True)
|
|
return {"error": str(e), "results": []}
|
|
|
|
|
|
def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
|
"""Register the reflect tool."""
|
|
|
|
if config.include_bank_id_param:
|
|
|
|
@mcp.tool()
|
|
async def reflect(
|
|
query: str,
|
|
context: str | None = None,
|
|
budget: str = "low",
|
|
bank_id: str | None = None,
|
|
) -> str:
|
|
"""
|
|
Generate thoughtful analysis by synthesizing stored memories with the bank's personality.
|
|
|
|
WHEN TO USE THIS TOOL:
|
|
Use reflect when you need reasoned analysis, not just fact retrieval. This tool
|
|
thinks through the question using everything the bank knows and its personality traits.
|
|
|
|
EXAMPLES OF GOOD QUERIES:
|
|
- "What patterns have emerged in how I approach debugging?"
|
|
- "Based on my past decisions, what architectural style do I prefer?"
|
|
- "What might be the best approach for this problem given what you know about me?"
|
|
- "How should I prioritize these tasks based on my goals?"
|
|
|
|
HOW IT DIFFERS FROM RECALL:
|
|
- recall: Returns raw facts matching your search (fast lookup)
|
|
- reflect: Reasons across memories to form a synthesized answer (deeper analysis)
|
|
|
|
Use recall for "what did I say about X?" and reflect for "what should I do about X?"
|
|
|
|
Args:
|
|
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')
|
|
bank_id: Optional bank to reflect in (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"
|
|
|
|
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),
|
|
)
|
|
|
|
return reflect_result.model_dump_json(indent=2)
|
|
except Exception as e:
|
|
logger.error(f"Error reflecting: {e}", exc_info=True)
|
|
return f'{{"error": "{e}", "text": ""}}'
|
|
|
|
else:
|
|
|
|
@mcp.tool()
|
|
async def reflect(
|
|
query: str,
|
|
context: str | None = None,
|
|
budget: str = "low",
|
|
) -> dict:
|
|
"""
|
|
Generate thoughtful analysis by synthesizing stored memories with the bank's personality.
|
|
|
|
WHEN TO USE THIS TOOL:
|
|
Use reflect when you need reasoned analysis, not just fact retrieval. This tool
|
|
thinks through the question using everything the bank knows and its personality traits.
|
|
|
|
EXAMPLES OF GOOD QUERIES:
|
|
- "What patterns have emerged in how I approach debugging?"
|
|
- "Based on my past decisions, what architectural style do I prefer?"
|
|
- "What might be the best approach for this problem given what you know about me?"
|
|
- "How should I prioritize these tasks based on my goals?"
|
|
|
|
HOW IT DIFFERS FROM RECALL:
|
|
- recall: Returns raw facts matching your search (fast lookup)
|
|
- reflect: Reasons across memories to form a synthesized answer (deeper analysis)
|
|
|
|
Use recall for "what did I say about X?" and reflect for "what should I do about X?"
|
|
|
|
Args:
|
|
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')
|
|
"""
|
|
try:
|
|
target_bank = config.bank_id_resolver()
|
|
if target_bank is None:
|
|
return {"error": "No bank_id configured", "text": ""}
|
|
|
|
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),
|
|
)
|
|
|
|
return reflect_result.model_dump()
|
|
except Exception as e:
|
|
logger.error(f"Error reflecting: {e}", exc_info=True)
|
|
return {"error": str(e), "text": ""}
|
|
|
|
|
|
def _register_list_banks(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
|
"""Register the list_banks tool."""
|
|
|
|
@mcp.tool()
|
|
async def list_banks() -> str:
|
|
"""
|
|
List all available memory banks.
|
|
|
|
Use this tool to discover what memory banks exist in the system.
|
|
Each bank is an isolated memory store (like a separate "brain").
|
|
|
|
Returns:
|
|
JSON list of banks with their IDs, names, dispositions, and missions.
|
|
"""
|
|
try:
|
|
banks = await memory.list_banks(request_context=_get_request_context(config))
|
|
return json.dumps({"banks": banks}, indent=2)
|
|
except Exception as e:
|
|
logger.error(f"Error listing banks: {e}", exc_info=True)
|
|
return f'{{"error": "{e}", "banks": []}}'
|
|
|
|
|
|
def _register_create_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
|
"""Register the create_bank tool."""
|
|
|
|
@mcp.tool()
|
|
async def create_bank(bank_id: str, name: str | None = None, mission: str | None = None) -> str:
|
|
"""
|
|
Create a new memory bank or get an existing one.
|
|
|
|
Memory banks are isolated stores - each one is like a separate "brain" for a user/agent.
|
|
Banks are auto-created with default settings if they don't exist.
|
|
|
|
Args:
|
|
bank_id: Unique identifier for the bank (e.g., 'user-123', 'agent-alpha')
|
|
name: Optional human-friendly name for the bank
|
|
mission: Optional mission describing who the agent is and what they're trying to accomplish
|
|
"""
|
|
try:
|
|
request_context = _get_request_context(config)
|
|
# get_bank_profile auto-creates bank if it doesn't exist
|
|
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
|
|
|
|
# Update name/mission if provided
|
|
if name is not None or mission is not None:
|
|
await memory.update_bank(
|
|
bank_id,
|
|
name=name,
|
|
mission=mission,
|
|
request_context=request_context,
|
|
)
|
|
# Fetch updated profile
|
|
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
|
|
|
|
# Serialize disposition if it's a Pydantic model
|
|
if "disposition" in profile and hasattr(profile["disposition"], "model_dump"):
|
|
profile["disposition"] = profile["disposition"].model_dump()
|
|
return json.dumps(profile, indent=2)
|
|
except Exception as e:
|
|
logger.error(f"Error creating bank: {e}", exc_info=True)
|
|
return f'{{"error": "{e}"}}'
|
|
|
|
|
|
def _validate_mental_model_inputs(
|
|
name: str | None = None, source_query: str | None = None, max_tokens: int | None = None
|
|
) -> str | None:
|
|
"""Validate mental model inputs, returning an error message or None if valid."""
|
|
if name is not None and not name.strip():
|
|
return "name cannot be empty"
|
|
if source_query is not None and not source_query.strip():
|
|
return "source_query cannot be empty"
|
|
if max_tokens is not None and (max_tokens < 256 or max_tokens > 8192):
|
|
return f"max_tokens must be between 256 and 8192, got {max_tokens}"
|
|
return None
|
|
|
|
|
|
# =========================================================================
|
|
# MENTAL MODEL TOOLS
|
|
# =========================================================================
|
|
|
|
|
|
def _register_list_mental_models(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
|
"""Register the list_mental_models tool."""
|
|
|
|
if config.include_bank_id_param:
|
|
|
|
@mcp.tool()
|
|
async def list_mental_models(
|
|
tags: list[str] | None = None,
|
|
bank_id: str | None = None,
|
|
) -> str:
|
|
"""
|
|
List mental models (pinned reflections) for a memory bank.
|
|
|
|
Mental models are living documents that stay current by periodically re-running
|
|
a source query through reflect. Use them to maintain up-to-date summaries,
|
|
preferences, or synthesized knowledge.
|
|
|
|
Args:
|
|
tags: Optional tags to filter by (returns models matching any tag)
|
|
bank_id: Optional bank to list from (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", "items": []}'
|
|
|
|
models = await memory.list_mental_models(
|
|
bank_id=target_bank,
|
|
tags=tags,
|
|
request_context=_get_request_context(config),
|
|
)
|
|
return json.dumps({"items": models}, indent=2, default=str)
|
|
except Exception as e:
|
|
logger.error(f"Error listing mental models: {e}", exc_info=True)
|
|
return f'{{"error": "{e}", "items": []}}'
|
|
|
|
else:
|
|
|
|
@mcp.tool()
|
|
async def list_mental_models(
|
|
tags: list[str] | None = None,
|
|
) -> dict:
|
|
"""
|
|
List mental models (pinned reflections) for this memory bank.
|
|
|
|
Mental models are living documents that stay current by periodically re-running
|
|
a source query through reflect. Use them to maintain up-to-date summaries,
|
|
preferences, or synthesized knowledge.
|
|
|
|
Args:
|
|
tags: Optional tags to filter by (returns models matching any tag)
|
|
"""
|
|
try:
|
|
target_bank = config.bank_id_resolver()
|
|
if target_bank is None:
|
|
return {"error": "No bank_id configured", "items": []}
|
|
|
|
models = await memory.list_mental_models(
|
|
bank_id=target_bank,
|
|
tags=tags,
|
|
request_context=_get_request_context(config),
|
|
)
|
|
return {"items": models}
|
|
except Exception as e:
|
|
logger.error(f"Error listing mental models: {e}", exc_info=True)
|
|
return {"error": str(e), "items": []}
|
|
|
|
|
|
def _register_get_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
|
"""Register the get_mental_model tool."""
|
|
|
|
if config.include_bank_id_param:
|
|
|
|
@mcp.tool()
|
|
async def get_mental_model(
|
|
mental_model_id: str,
|
|
bank_id: str | None = None,
|
|
) -> str:
|
|
"""
|
|
Get a specific mental model by ID.
|
|
|
|
Returns the full mental model including its generated content, source query,
|
|
and metadata. Use list_mental_models first to discover available model IDs.
|
|
|
|
Args:
|
|
mental_model_id: The ID of the mental model 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"}'
|
|
|
|
model = await memory.get_mental_model(
|
|
bank_id=target_bank,
|
|
mental_model_id=mental_model_id,
|
|
request_context=_get_request_context(config),
|
|
)
|
|
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)
|
|
except Exception as e:
|
|
logger.error(f"Error getting mental model: {e}", exc_info=True)
|
|
return f'{{"error": "{e}"}}'
|
|
|
|
else:
|
|
|
|
@mcp.tool()
|
|
async def get_mental_model(
|
|
mental_model_id: str,
|
|
) -> dict:
|
|
"""
|
|
Get a specific mental model by ID.
|
|
|
|
Returns the full mental model including its generated content, source query,
|
|
and metadata. Use list_mental_models first to discover available model IDs.
|
|
|
|
Args:
|
|
mental_model_id: The ID of the mental model to retrieve
|
|
"""
|
|
try:
|
|
target_bank = config.bank_id_resolver()
|
|
if target_bank is None:
|
|
return {"error": "No bank_id configured"}
|
|
|
|
model = await memory.get_mental_model(
|
|
bank_id=target_bank,
|
|
mental_model_id=mental_model_id,
|
|
request_context=_get_request_context(config),
|
|
)
|
|
if model is None:
|
|
return {"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"}
|
|
return model
|
|
except Exception as e:
|
|
logger.error(f"Error getting mental model: {e}", exc_info=True)
|
|
return {"error": str(e)}
|
|
|
|
|
|
def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
|
"""Register the create_mental_model tool."""
|
|
|
|
if config.include_bank_id_param:
|
|
|
|
@mcp.tool()
|
|
async def create_mental_model(
|
|
name: str,
|
|
source_query: str,
|
|
mental_model_id: str | None = None,
|
|
tags: list[str] | None = None,
|
|
max_tokens: int = 2048,
|
|
bank_id: str | None = None,
|
|
) -> str:
|
|
"""
|
|
Create a new mental model (pinned reflection).
|
|
|
|
A mental model is a living document generated by running the source_query through
|
|
reflect. The content is auto-generated asynchronously - use the returned operation_id
|
|
to track progress.
|
|
|
|
EXAMPLES:
|
|
- name="Coding Preferences", source_query="What coding patterns and tools does the user prefer?"
|
|
- name="Project Goals", source_query="What are the user's current project goals and priorities?"
|
|
- name="Communication Style", source_query="How does the user prefer to communicate?"
|
|
|
|
Args:
|
|
name: Human-readable name for the mental model
|
|
source_query: The query to run through reflect to generate content
|
|
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)
|
|
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"}'
|
|
|
|
validation_error = _validate_mental_model_inputs(
|
|
name=name, source_query=source_query, max_tokens=max_tokens
|
|
)
|
|
if validation_error:
|
|
return json.dumps({"error": validation_error})
|
|
|
|
request_context = _get_request_context(config)
|
|
|
|
# Create with placeholder content
|
|
model = await memory.create_mental_model(
|
|
bank_id=target_bank,
|
|
name=name,
|
|
source_query=source_query,
|
|
content="Generating content...",
|
|
mental_model_id=mental_model_id,
|
|
tags=tags,
|
|
max_tokens=max_tokens,
|
|
request_context=request_context,
|
|
)
|
|
|
|
# Schedule async refresh to generate actual content
|
|
result = await memory.submit_async_refresh_mental_model(
|
|
bank_id=target_bank,
|
|
mental_model_id=model["id"],
|
|
request_context=request_context,
|
|
)
|
|
|
|
return json.dumps(
|
|
{
|
|
"mental_model_id": model["id"],
|
|
"operation_id": result["operation_id"],
|
|
"status": "created",
|
|
"message": f"Mental model '{name}' created. Content is being generated asynchronously.",
|
|
}
|
|
)
|
|
except ValueError as e:
|
|
return json.dumps({"error": str(e)})
|
|
except Exception as e:
|
|
logger.error(f"Error creating mental model: {e}", exc_info=True)
|
|
return f'{{"error": "{e}"}}'
|
|
|
|
else:
|
|
|
|
@mcp.tool()
|
|
async def create_mental_model(
|
|
name: str,
|
|
source_query: str,
|
|
mental_model_id: str | None = None,
|
|
tags: list[str] | None = None,
|
|
max_tokens: int = 2048,
|
|
) -> dict:
|
|
"""
|
|
Create a new mental model (pinned reflection).
|
|
|
|
A mental model is a living document generated by running the source_query through
|
|
reflect. The content is auto-generated asynchronously - use the returned operation_id
|
|
to track progress.
|
|
|
|
EXAMPLES:
|
|
- name="Coding Preferences", source_query="What coding patterns and tools does the user prefer?"
|
|
- name="Project Goals", source_query="What are the user's current project goals and priorities?"
|
|
- name="Communication Style", source_query="How does the user prefer to communicate?"
|
|
|
|
Args:
|
|
name: Human-readable name for the mental model
|
|
source_query: The query to run through reflect to generate content
|
|
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)
|
|
"""
|
|
try:
|
|
target_bank = config.bank_id_resolver()
|
|
if target_bank is None:
|
|
return {"error": "No bank_id configured"}
|
|
|
|
validation_error = _validate_mental_model_inputs(
|
|
name=name, source_query=source_query, max_tokens=max_tokens
|
|
)
|
|
if validation_error:
|
|
return {"error": validation_error}
|
|
|
|
request_context = _get_request_context(config)
|
|
|
|
model = await memory.create_mental_model(
|
|
bank_id=target_bank,
|
|
name=name,
|
|
source_query=source_query,
|
|
content="Generating content...",
|
|
mental_model_id=mental_model_id,
|
|
tags=tags,
|
|
max_tokens=max_tokens,
|
|
request_context=request_context,
|
|
)
|
|
|
|
result = await memory.submit_async_refresh_mental_model(
|
|
bank_id=target_bank,
|
|
mental_model_id=model["id"],
|
|
request_context=request_context,
|
|
)
|
|
|
|
return {
|
|
"mental_model_id": model["id"],
|
|
"operation_id": result["operation_id"],
|
|
"status": "created",
|
|
"message": f"Mental model '{name}' created. Content is being generated asynchronously.",
|
|
}
|
|
except ValueError as e:
|
|
return {"error": str(e)}
|
|
except Exception as e:
|
|
logger.error(f"Error creating mental model: {e}", exc_info=True)
|
|
return {"error": str(e)}
|
|
|
|
|
|
def _register_update_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
|
"""Register the update_mental_model tool."""
|
|
|
|
if config.include_bank_id_param:
|
|
|
|
@mcp.tool()
|
|
async def update_mental_model(
|
|
mental_model_id: str,
|
|
name: str | None = None,
|
|
source_query: str | None = None,
|
|
max_tokens: int | None = None,
|
|
tags: list[str] | None = None,
|
|
bank_id: str | None = None,
|
|
) -> str:
|
|
"""
|
|
Update a mental model's metadata.
|
|
|
|
Changes the name, source query, or tags of an existing mental model.
|
|
To regenerate the content, use refresh_mental_model after updating the source query.
|
|
|
|
Args:
|
|
mental_model_id: The ID of the mental model to update
|
|
name: New name (leave None to keep current)
|
|
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)
|
|
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"}'
|
|
|
|
validation_error = _validate_mental_model_inputs(
|
|
name=name, source_query=source_query, max_tokens=max_tokens
|
|
)
|
|
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),
|
|
)
|
|
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)
|
|
except Exception as e:
|
|
logger.error(f"Error updating mental model: {e}", exc_info=True)
|
|
return f'{{"error": "{e}"}}'
|
|
|
|
else:
|
|
|
|
@mcp.tool()
|
|
async def update_mental_model(
|
|
mental_model_id: str,
|
|
name: str | None = None,
|
|
source_query: str | None = None,
|
|
max_tokens: int | None = None,
|
|
tags: list[str] | None = None,
|
|
) -> dict:
|
|
"""
|
|
Update a mental model's metadata.
|
|
|
|
Changes the name, source query, or tags of an existing mental model.
|
|
To regenerate the content, use refresh_mental_model after updating the source query.
|
|
|
|
Args:
|
|
mental_model_id: The ID of the mental model to update
|
|
name: New name (leave None to keep current)
|
|
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)
|
|
"""
|
|
try:
|
|
target_bank = config.bank_id_resolver()
|
|
if target_bank is None:
|
|
return {"error": "No bank_id configured"}
|
|
|
|
validation_error = _validate_mental_model_inputs(
|
|
name=name, source_query=source_query, max_tokens=max_tokens
|
|
)
|
|
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),
|
|
)
|
|
if model is None:
|
|
return {"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"}
|
|
return model
|
|
except Exception as e:
|
|
logger.error(f"Error updating mental model: {e}", exc_info=True)
|
|
return {"error": str(e)}
|
|
|
|
|
|
def _register_delete_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
|
"""Register the delete_mental_model tool."""
|
|
|
|
if config.include_bank_id_param:
|
|
|
|
@mcp.tool()
|
|
async def delete_mental_model(
|
|
mental_model_id: str,
|
|
bank_id: str | None = None,
|
|
) -> str:
|
|
"""
|
|
Delete a mental model.
|
|
|
|
Permanently removes a mental model and its generated content.
|
|
|
|
Args:
|
|
mental_model_id: The ID of the mental model 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_mental_model(
|
|
bank_id=target_bank,
|
|
mental_model_id=mental_model_id,
|
|
request_context=_get_request_context(config),
|
|
)
|
|
if not deleted:
|
|
return json.dumps({"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"})
|
|
return json.dumps({"status": "deleted", "mental_model_id": mental_model_id})
|
|
except Exception as e:
|
|
logger.error(f"Error deleting mental model: {e}", exc_info=True)
|
|
return f'{{"error": "{e}"}}'
|
|
|
|
else:
|
|
|
|
@mcp.tool()
|
|
async def delete_mental_model(
|
|
mental_model_id: str,
|
|
) -> dict:
|
|
"""
|
|
Delete a mental model.
|
|
|
|
Permanently removes a mental model and its generated content.
|
|
|
|
Args:
|
|
mental_model_id: The ID of the mental model to delete
|
|
"""
|
|
try:
|
|
target_bank = config.bank_id_resolver()
|
|
if target_bank is None:
|
|
return {"error": "No bank_id configured"}
|
|
|
|
deleted = await memory.delete_mental_model(
|
|
bank_id=target_bank,
|
|
mental_model_id=mental_model_id,
|
|
request_context=_get_request_context(config),
|
|
)
|
|
if not deleted:
|
|
return {"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"}
|
|
return {"status": "deleted", "mental_model_id": mental_model_id}
|
|
except Exception as e:
|
|
logger.error(f"Error deleting mental model: {e}", exc_info=True)
|
|
return {"error": str(e)}
|
|
|
|
|
|
def _register_refresh_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
|
"""Register the refresh_mental_model tool."""
|
|
|
|
if config.include_bank_id_param:
|
|
|
|
@mcp.tool()
|
|
async def refresh_mental_model(
|
|
mental_model_id: str,
|
|
bank_id: str | None = None,
|
|
) -> str:
|
|
"""
|
|
Refresh a mental model by re-running its source query.
|
|
|
|
Schedules an async task to re-run the source query through reflect and update the
|
|
mental model's content with fresh results. Use this after adding new memories or
|
|
when the mental model's content may be stale.
|
|
|
|
Args:
|
|
mental_model_id: The ID of the mental model to refresh
|
|
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.submit_async_refresh_mental_model(
|
|
bank_id=target_bank,
|
|
mental_model_id=mental_model_id,
|
|
request_context=_get_request_context(config),
|
|
)
|
|
return json.dumps(
|
|
{
|
|
"operation_id": result["operation_id"],
|
|
"status": "queued",
|
|
"message": f"Refresh queued for mental model '{mental_model_id}'.",
|
|
}
|
|
)
|
|
except ValueError as e:
|
|
return json.dumps({"error": str(e)})
|
|
except Exception as e:
|
|
logger.error(f"Error refreshing mental model: {e}", exc_info=True)
|
|
return f'{{"error": "{e}"}}'
|
|
|
|
else:
|
|
|
|
@mcp.tool()
|
|
async def refresh_mental_model(
|
|
mental_model_id: str,
|
|
) -> dict:
|
|
"""
|
|
Refresh a mental model by re-running its source query.
|
|
|
|
Schedules an async task to re-run the source query through reflect and update the
|
|
mental model's content with fresh results. Use this after adding new memories or
|
|
when the mental model's content may be stale.
|
|
|
|
Args:
|
|
mental_model_id: The ID of the mental model to refresh
|
|
"""
|
|
try:
|
|
target_bank = config.bank_id_resolver()
|
|
if target_bank is None:
|
|
return {"error": "No bank_id configured"}
|
|
|
|
result = await memory.submit_async_refresh_mental_model(
|
|
bank_id=target_bank,
|
|
mental_model_id=mental_model_id,
|
|
request_context=_get_request_context(config),
|
|
)
|
|
return {
|
|
"operation_id": result["operation_id"],
|
|
"status": "queued",
|
|
"message": f"Refresh queued for mental model '{mental_model_id}'.",
|
|
}
|
|
except ValueError as e:
|
|
return {"error": str(e)}
|
|
except Exception as e:
|
|
logger.error(f"Error refreshing mental model: {e}", exc_info=True)
|
|
return {"error": str(e)}
|