From d614fceeeb0a7b1a5ec36aa7ddd780df286fc45c Mon Sep 17 00:00:00 2001 From: Hulkito Date: Sun, 10 May 2026 19:12:59 +0200 Subject: [PATCH] feat(mcp): add compact descriptor mode Keep verbose meta-tool descriptions as the default while allowing clients to opt into shorter MCP descriptors for context-sensitive environments. --- app/config/settings.py | 3 + app/routes/mcp/meta_tools.py | 51 ++++++++++++++ docs/configuration.md | 9 +++ .../integration/test_meta_tools_docstrings.py | 67 +++++++++++++++++++ 4 files changed, 130 insertions(+) diff --git a/app/config/settings.py b/app/config/settings.py index 4506ce7..9ab869b 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -93,6 +93,9 @@ class Settings(BaseSettings): # Scoped Permissions Configuration FORGETFUL_SCOPES: str = "*" # Comma-separated scopes (e.g. "read", "write:memories", "read,write:entities") + # MCP descriptor presentation + MCP_DESCRIPTOR_MODE: str = "verbose" # "verbose" (default) or "compact" meta-tool docstrings + # Memory Configuration MEMORY_TITLE_MAX_LENGTH: int = 200 # Must be "easily titled" - scannable MEMORY_CONTENT_MAX_LENGTH: int = 2000 # ~300-400 words - single concept diff --git a/app/routes/mcp/meta_tools.py b/app/routes/mcp/meta_tools.py index 5159575..d450ea4 100644 --- a/app/routes/mcp/meta_tools.py +++ b/app/routes/mcp/meta_tools.py @@ -242,9 +242,58 @@ def _build_tool_categories_line() -> str: return " | ".join(categories) +def _get_mcp_descriptor_mode() -> str: + """Return the normalized descriptor mode for meta-tool docstrings.""" + mode = str(getattr(settings, "MCP_DESCRIPTOR_MODE", "verbose")).strip().lower() + if mode == "compact": + return "compact" + return "verbose" + + +def _build_compact_discover_docstring(categories: str) -> str: + """Build a compact discover_forgetful_tools docstring.""" + return f"""\ +Discover available Forgetful tools, optionally filtered by category. + + Use this tool to list the available inner tools. Call how_to_use_forgetful_tool(tool_name) + for full parameter documentation and examples before uncommon or write operations. + + Args: + category: Optional category filter ({categories}) + ctx: FastMCP Context (automatically injected) + + Returns: + Dictionary with tools_by_category, total_count, categories_available, and filtered_by. + """ + + +def _build_compact_execute_docstring(tool_categories: str) -> str: + """Build a compact execute_forgetful_tool docstring.""" + return f"""\ +Execute any registered Forgetful inner tool by name. + + Typical flow: + 1. Call discover_forgetful_tools(category?) to find tool names. + 2. Call how_to_use_forgetful_tool(tool_name) for full schemas and examples. + 3. Call execute_forgetful_tool(tool_name, arguments) to run the selected tool. + + Tool categories: {tool_categories} + + Args: + tool_name: Name of the inner tool to execute + arguments: Dictionary of arguments for the selected tool + ctx: FastMCP Context (automatically injected) + + Returns: + Tool execution result (format depends on the selected tool) + """ + + def _build_discover_docstring() -> str: """Build discover_forgetful_tools docstring based on feature flags.""" categories = _build_category_list() + if _get_mcp_descriptor_mode() == "compact": + return _build_compact_discover_docstring(categories) parts = [ f"""\ @@ -289,6 +338,8 @@ def _build_discover_docstring() -> str: def _build_execute_docstring() -> str: """Build execute_forgetful_tool docstring based on feature flags.""" tool_categories = _build_tool_categories_line() + if _get_mcp_descriptor_mode() == "compact": + return _build_compact_execute_docstring(tool_categories) parts = [ f"""\ diff --git a/docs/configuration.md b/docs/configuration.md index 02bea48..286d65a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -105,6 +105,15 @@ Forgetful provides three Docker Compose configurations: - `json` - Structured JSON format (recommended for production) - **Example**: `LOG_FORMAT=json` +### `MCP_DESCRIPTOR_MODE` + +- **Default**: `verbose` +- **Description**: Controls how much guidance is embedded in the MCP meta-tool descriptions exposed to clients +- **Values**: + - `verbose` - Include detailed inline guidance in `discover_forgetful_tools` and `execute_forgetful_tool` + - `compact` - Keep meta-tool descriptions short and use `how_to_use_forgetful_tool` for detailed schemas and examples +- **Example**: `MCP_DESCRIPTOR_MODE=compact` + --- ## Docker Configuration diff --git a/tests/integration/test_meta_tools_docstrings.py b/tests/integration/test_meta_tools_docstrings.py index 023ad04..7976767 100644 --- a/tests/integration/test_meta_tools_docstrings.py +++ b/tests/integration/test_meta_tools_docstrings.py @@ -12,6 +12,7 @@ _build_discover_docstring, _build_execute_docstring, _build_tool_categories_line, + _get_mcp_descriptor_mode, ) @@ -134,6 +135,37 @@ def test_all_categories_when_all_enabled(self): assert "task" in result +class TestMcpDescriptorMode: + """Verify descriptor mode normalization.""" + + def test_default_descriptor_mode_is_verbose(self): + """Missing descriptor mode falls back to verbose.""" + with patch("app.routes.mcp.meta_tools.settings") as mock_settings: + mock_settings.MCP_DESCRIPTOR_MODE = "" + + result = _get_mcp_descriptor_mode() + + assert result == "verbose" + + def test_compact_descriptor_mode_is_normalized(self): + """Compact mode accepts case and whitespace differences.""" + with patch("app.routes.mcp.meta_tools.settings") as mock_settings: + mock_settings.MCP_DESCRIPTOR_MODE = " Compact " + + result = _get_mcp_descriptor_mode() + + assert result == "compact" + + def test_invalid_descriptor_mode_falls_back_to_verbose(self): + """Unknown descriptor mode values are deterministic and safe.""" + with patch("app.routes.mcp.meta_tools.settings") as mock_settings: + mock_settings.MCP_DESCRIPTOR_MODE = "minimal" + + result = _get_mcp_descriptor_mode() + + assert result == "verbose" + + class TestDiscoverDocstring: """Verify discover_forgetful_tools docstring includes/excludes sections by flag.""" @@ -228,6 +260,23 @@ def test_workflow_section_always_present(self): assert "## Workflow" in doc + def test_compact_mode_omits_inline_tool_catalog(self): + """Compact mode keeps discovery guidance short.""" + with patch("app.routes.mcp.meta_tools.settings") as mock_settings: + mock_settings.MCP_DESCRIPTOR_MODE = "compact" + mock_settings.SKILLS_ENABLED = True + mock_settings.FILES_ENABLED = True + mock_settings.PLANNING_ENABLED = True + + doc = _build_discover_docstring() + + assert "Discover available Forgetful tools" in doc + assert "how_to_use_forgetful_tool(tool_name)" in doc + assert "## All Available Tools" not in doc + assert "**Memory Tools**" not in doc + assert "**Task Tools**" not in doc + assert len(doc) < 900 + class TestExecuteDocstring: """Verify execute_forgetful_tool docstring includes/excludes sections by flag.""" @@ -304,3 +353,21 @@ def test_linking_section_always_present(self): doc = _build_execute_docstring() assert "## Linking Best Practices" in doc + + def test_compact_mode_omits_inline_examples(self): + """Compact mode points to how_to_use instead of embedding long examples.""" + with patch("app.routes.mcp.meta_tools.settings") as mock_settings: + mock_settings.MCP_DESCRIPTOR_MODE = "compact" + mock_settings.SKILLS_ENABLED = True + mock_settings.FILES_ENABLED = True + mock_settings.PLANNING_ENABLED = True + + doc = _build_execute_docstring() + + assert "Execute any registered Forgetful inner tool by name" in doc + assert "how_to_use_forgetful_tool(tool_name)" in doc + assert "Tool categories:" in doc + assert "## Quick Start" not in doc + assert "**Memory Operations:**" not in doc + assert "## Linking Best Practices" not in doc + assert len(doc) < 1000