Compare commits

...
9 changed files with 514 additions and 12 deletions
+58 -11
View File
@@ -8,12 +8,48 @@ from contextvars import ContextVar
from fastmcp import FastMCP
from hindsight_api import MemoryEngine
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.memory_engine import _current_schema
from hindsight_api.extensions import MCPExtension, load_extension
from hindsight_api.extensions.tenant import AuthenticationError
from hindsight_api.mcp_tools import MCPToolsConfig, register_mcp_tools
from hindsight_api.models import RequestContext
# All tools available in the system (explicit list — no wildcards)
_ALL_TOOLS: frozenset[str] = frozenset(
{
"retain",
"recall",
"reflect",
"list_banks",
"create_bank",
"list_mental_models",
"get_mental_model",
"create_mental_model",
"update_mental_model",
"delete_mental_model",
"refresh_mental_model",
"list_directives",
"create_directive",
"delete_directive",
"list_memories",
"get_memory",
"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",
}
)
# Configure logging from HINDSIGHT_API_LOG_LEVEL environment variable
_log_level_str = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "info").lower()
_log_level_map = {
@@ -82,16 +118,11 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
"""
mcp = FastMCP("hindsight-mcp-server")
# Configure and register tools using shared module
config = MCPToolsConfig(
bank_id_resolver=get_current_bank_id,
api_key_resolver=get_current_api_key, # Propagate API key for tenant auth
tenant_id_resolver=get_current_tenant_id, # Propagate tenant_id for usage metering
api_key_id_resolver=get_current_api_key_id, # Propagate api_key_id for usage metering
include_bank_id_param=multi_bank,
tools=None
if multi_bank
else {
global_config = _get_raw_config()
# Tools available for this mode (multi-bank exposes all tools; single-bank excludes bank-management tools)
_SINGLE_BANK_TOOLS: frozenset[str] = frozenset(
{
"retain",
"recall",
"reflect",
@@ -118,7 +149,23 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
"update_bank",
"delete_bank",
"clear_memories",
}, # Scoped tools for single-bank mode (excludes multi-bank management: list_banks, create_bank, get_bank_stats)
}
)
base_tools: frozenset[str] | None = None if multi_bank else _SINGLE_BANK_TOOLS
# Apply global mcp_enabled_tools filter (env-level allowlist)
if global_config.mcp_enabled_tools is not None:
allowed = frozenset(global_config.mcp_enabled_tools)
base_tools = (base_tools if base_tools is not None else _ALL_TOOLS) & allowed
# Configure and register tools using shared module
config = MCPToolsConfig(
bank_id_resolver=get_current_bank_id,
api_key_resolver=get_current_api_key, # Propagate API key for tenant auth
tenant_id_resolver=get_current_tenant_id, # Propagate tenant_id for usage metering
api_key_id_resolver=get_current_api_key_id, # Propagate api_key_id for usage metering
include_bank_id_param=multi_bank,
tools=base_tools,
retain_fire_and_forget=False, # HTTP MCP supports sync/async modes
)
+8
View File
@@ -232,6 +232,7 @@ ENV_LOG_LEVEL = "HINDSIGHT_API_LOG_LEVEL"
ENV_LOG_FORMAT = "HINDSIGHT_API_LOG_FORMAT"
ENV_WORKERS = "HINDSIGHT_API_WORKERS"
ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
ENV_MCP_ENABLED_TOOLS = "HINDSIGHT_API_MCP_ENABLED_TOOLS"
ENV_ENABLE_BANK_CONFIG_API = "HINDSIGHT_API_ENABLE_BANK_CONFIG_API"
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
ENV_MPFP_TOP_K_NEIGHBORS = "HINDSIGHT_API_MPFP_TOP_K_NEIGHBORS"
@@ -397,6 +398,7 @@ DEFAULT_LOG_LEVEL = "info"
DEFAULT_LOG_FORMAT = "text" # Options: "text", "json"
DEFAULT_WORKERS = 1
DEFAULT_MCP_ENABLED = True
DEFAULT_MCP_ENABLED_TOOLS: list[str] | None = None # None = all tools enabled
DEFAULT_ENABLE_BANK_CONFIG_API = True
DEFAULT_GRAPH_RETRIEVER = "link_expansion" # Options: "link_expansion", "mpfp", "bfs"
DEFAULT_MPFP_TOP_K_NEIGHBORS = 20 # Fan-out limit per node in MPFP graph traversal
@@ -638,6 +640,7 @@ class HindsightConfig:
log_level: str
log_format: str
mcp_enabled: bool
mcp_enabled_tools: list[str] | None # None = all tools; explicit list = allowlist
enable_bank_config_api: bool
# Recall
@@ -757,6 +760,8 @@ class HindsightConfig:
# These fields are manually tagged as safe to expose and modify.
# Excludes credentials, infrastructure config, provider/model selection, and performance tuning.
_CONFIGURABLE_FIELDS = {
# MCP tool access control
"mcp_enabled_tools",
# Retention settings (behavioral)
"retain_chunk_size",
"retain_extraction_mode",
@@ -1033,6 +1038,9 @@ class HindsightConfig:
log_level=os.getenv(ENV_LOG_LEVEL, DEFAULT_LOG_LEVEL),
log_format=os.getenv(ENV_LOG_FORMAT, DEFAULT_LOG_FORMAT).lower(),
mcp_enabled=os.getenv(ENV_MCP_ENABLED, str(DEFAULT_MCP_ENABLED)).lower() == "true",
mcp_enabled_tools=[t.strip() for t in os.getenv(ENV_MCP_ENABLED_TOOLS).split(",") if t.strip()]
if os.getenv(ENV_MCP_ENABLED_TOOLS)
else DEFAULT_MCP_ENABLED_TOOLS,
enable_bank_config_api=os.getenv(ENV_ENABLE_BANK_CONFIG_API, str(DEFAULT_ENABLE_BANK_CONFIG_API)).lower()
== "true",
# Recall
+1
View File
@@ -239,6 +239,7 @@ def main():
log_level=args.log_level,
log_format=config.log_format,
mcp_enabled=config.mcp_enabled,
mcp_enabled_tools=config.mcp_enabled_tools,
enable_bank_config_api=config.enable_bank_config_api,
graph_retriever=config.graph_retriever,
mpfp_top_k_neighbors=config.mpfp_top_k_neighbors,
+49
View File
@@ -265,6 +265,55 @@ def register_mcp_tools(
if "clear_memories" in tools_to_register:
_register_clear_memories(mcp, memory, config)
_apply_bank_tool_filtering(mcp, memory, config)
def _apply_bank_tool_filtering(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Filter bank-level mcp_enabled_tools from both tools/list and tool invocation.
Wraps _tool_manager.get_tools() so that:
- tools/list only returns permitted tools (they are hidden, not just blocked)
- tools/call for a disabled tool raises NotFoundError (via the manager) before run()
tool.run wrappers are kept as defense-in-depth for any caller that bypasses the manager.
"""
try:
tool_manager = mcp._tool_manager
original_get_tools = tool_manager.get_tools
async def _filtered_get_tools():
all_tools = await original_get_tools()
bank_id = config.bank_id_resolver()
if not bank_id:
return all_tools
request_context = _get_request_context(config)
bank_cfg = await memory._config_resolver.get_bank_config(bank_id, request_context)
enabled: list[str] | None = bank_cfg.get("mcp_enabled_tools")
if enabled is None:
return all_tools
enabled_set = set(enabled)
return {k: v for k, v in all_tools.items() if k in enabled_set}
setattr(tool_manager, "get_tools", _filtered_get_tools)
# Defense-in-depth: also wrap tool.run for any direct caller that bypasses the manager
for name, tool in tool_manager._tools.items():
original_run = tool.run
async def _filtered_run(arguments, _name=name, _orig=original_run):
bank_id = config.bank_id_resolver()
if bank_id:
request_context = _get_request_context(config)
bank_cfg = await memory._config_resolver.get_bank_config(bank_id, request_context)
enabled: list[str] | None = bank_cfg.get("mcp_enabled_tools")
if enabled is not None and _name not in enabled:
raise ValueError(f"Tool '{_name}' is not enabled for bank '{bank_id}'")
return await _orig(arguments)
object.__setattr__(tool, "run", _filtered_run)
except (AttributeError, KeyError) as e:
logger.warning(f"Could not apply bank tool filtering: {e}")
def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the retain tool."""
@@ -82,7 +82,7 @@ async def test_hierarchical_fields_categorization():
assert "disposition_empathy" in configurable
# Verify count is correct
assert len(configurable) == 10
assert len(configurable) == 11
# Verify credential fields (NEVER exposed)
assert "llm_api_key" in credentials
+63
View File
@@ -352,6 +352,69 @@ async def test_middleware_handles_both_endpoints(mock_memory):
assert "create_bank" not in single_bank_tools
def test_global_mcp_enabled_tools_filter_restricts_registered_tools(mock_memory):
"""Test that global mcp_enabled_tools env setting restricts which tools are registered."""
from unittest.mock import MagicMock, patch
from hindsight_api.api.mcp import create_mcp_server
mock_cfg = MagicMock()
mock_cfg.mcp_enabled_tools = ["retain", "recall"]
with patch("hindsight_api.api.mcp._get_raw_config", return_value=mock_cfg):
mcp_server = create_mcp_server(mock_memory, multi_bank=True)
tools = mcp_server._tool_manager._tools
assert "retain" in tools
assert "recall" in tools
assert "reflect" not in tools
assert "list_banks" not in tools
assert "create_bank" not in tools
assert "list_mental_models" not in tools
def test_global_mcp_enabled_tools_none_exposes_all_tools(mock_memory):
"""Test that mcp_enabled_tools=None (default) exposes all tools."""
from unittest.mock import MagicMock, patch
from hindsight_api.api.mcp import create_mcp_server
mock_cfg = MagicMock()
mock_cfg.mcp_enabled_tools = None
with patch("hindsight_api.api.mcp._get_raw_config", return_value=mock_cfg):
mcp_server = create_mcp_server(mock_memory, multi_bank=True)
tools = mcp_server._tool_manager._tools
assert "retain" in tools
assert "recall" in tools
assert "reflect" in tools
assert "list_banks" in tools
assert "create_bank" in tools
def test_global_mcp_enabled_tools_intersects_with_single_bank_mode(mock_memory):
"""Test that global filter intersects with single-bank mode tool set.
list_banks is in the global allowlist but NOT in single-bank mode, so it
should be absent from the final registered set.
"""
from unittest.mock import MagicMock, patch
from hindsight_api.api.mcp import create_mcp_server
mock_cfg = MagicMock()
mock_cfg.mcp_enabled_tools = ["retain", "recall", "list_banks"]
with patch("hindsight_api.api.mcp._get_raw_config", return_value=mock_cfg):
mcp_server = create_mcp_server(mock_memory, multi_bank=False)
tools = mcp_server._tool_manager._tools
assert "retain" in tools
assert "recall" in tools
assert "list_banks" not in tools # single-bank mode excludes it regardless
@pytest.mark.asyncio
async def test_routing_logic_from_url_path():
"""Test that routing correctly selects server based on URL structure.
+111
View File
@@ -1246,3 +1246,114 @@ class TestEmptyListReturns:
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
# =========================================================================
# Bank-Level Tool Filtering Tests
# =========================================================================
@pytest.fixture
def mock_memory_with_resolver():
"""Create a mock MemoryEngine with config resolver for bank filtering tests."""
memory = MagicMock()
memory.retain_batch_async = AsyncMock()
memory.recall_async = AsyncMock(
return_value=MagicMock(
model_dump_json=lambda indent=None: '{"results": []}',
model_dump=lambda: {"results": []},
)
)
memory._config_resolver = MagicMock()
memory._config_resolver.get_bank_config = AsyncMock(return_value={})
return memory
class TestBankToolFiltering:
"""Tests for bank-level mcp_enabled_tools filtering via _apply_bank_tool_filtering."""
@pytest.mark.asyncio
async def test_disallowed_tool_raises_error(self, mock_memory_with_resolver):
"""Tool not in bank's mcp_enabled_tools list is hidden from get_tools()."""
from fastmcp import FastMCP
mock_memory_with_resolver._config_resolver.get_bank_config = AsyncMock(
return_value={"mcp_enabled_tools": ["retain"]}
)
mcp = FastMCP("test")
config = MCPToolsConfig(
bank_id_resolver=lambda: "test-bank",
include_bank_id_param=False,
tools={"retain", "recall"},
)
register_mcp_tools(mcp, mock_memory_with_resolver, config)
# Both tools are registered in the manager's internal dict
assert "recall" in mcp._tool_manager._tools
# But get_tools() (used by tools/list and tools/call) filters it out
visible = await mcp._tool_manager.get_tools()
assert "retain" in visible
assert "recall" not in visible
@pytest.mark.asyncio
async def test_allowed_tool_remains_visible(self, mock_memory_with_resolver):
"""Tool in bank's mcp_enabled_tools list stays visible in get_tools()."""
from fastmcp import FastMCP
mock_memory_with_resolver._config_resolver.get_bank_config = AsyncMock(
return_value={"mcp_enabled_tools": ["retain", "recall"]}
)
mcp = FastMCP("test")
config = MCPToolsConfig(
bank_id_resolver=lambda: "test-bank",
include_bank_id_param=False,
tools={"retain", "recall"},
)
register_mcp_tools(mcp, mock_memory_with_resolver, config)
visible = await mcp._tool_manager.get_tools()
assert "retain" in visible
assert "recall" in visible
@pytest.mark.asyncio
async def test_no_filter_when_mcp_enabled_tools_absent(self, mock_memory_with_resolver):
"""When bank config has no mcp_enabled_tools key, all tools remain visible."""
from fastmcp import FastMCP
mock_memory_with_resolver._config_resolver.get_bank_config = AsyncMock(return_value={})
mcp = FastMCP("test")
config = MCPToolsConfig(
bank_id_resolver=lambda: "test-bank",
include_bank_id_param=False,
tools={"retain", "recall"},
)
register_mcp_tools(mcp, mock_memory_with_resolver, config)
visible = await mcp._tool_manager.get_tools()
assert "retain" in visible
assert "recall" in visible
@pytest.mark.asyncio
async def test_filter_skipped_when_no_bank_id(self, mock_memory_with_resolver):
"""When bank_id resolver returns None, config is not fetched and all tools are visible."""
from fastmcp import FastMCP
mock_memory_with_resolver._config_resolver.get_bank_config = AsyncMock(
return_value={"mcp_enabled_tools": ["retain"]} # Would block recall
)
mcp = FastMCP("test")
config = MCPToolsConfig(
bank_id_resolver=lambda: None, # No bank_id context
include_bank_id_param=False,
tools={"retain", "recall"},
)
register_mcp_tools(mcp, mock_memory_with_resolver, config)
visible = await mcp._tool_manager.get_tools()
# Filter bypassed — config resolver was never consulted, all tools visible
assert "recall" in visible
mock_memory_with_resolver._config_resolver.get_bank_config.assert_not_called()
@@ -38,6 +38,46 @@ type ObservationsEdits = {
observations_mission: string | null;
};
type MCPEdits = {
mcp_enabled_tools: string[] | null;
};
// ─── MCP tool catalogue ───────────────────────────────────────────────────────
const MCP_TOOL_GROUPS: { label: string; tools: string[] }[] = [
{ label: "Core", tools: ["retain", "recall", "reflect"] },
{
label: "Bank management",
tools: [
"list_banks",
"create_bank",
"get_bank",
"get_bank_stats",
"update_bank",
"delete_bank",
"clear_memories",
],
},
{
label: "Mental models",
tools: [
"list_mental_models",
"get_mental_model",
"create_mental_model",
"update_mental_model",
"delete_mental_model",
"refresh_mental_model",
],
},
{ label: "Directives", tools: ["list_directives", "create_directive", "delete_directive"] },
{ label: "Memories", tools: ["list_memories", "get_memory", "delete_memory"] },
{ label: "Documents", tools: ["list_documents", "get_document", "delete_document"] },
{ label: "Operations", tools: ["list_operations", "get_operation", "cancel_operation"] },
{ label: "Tags", tools: ["list_tags"] },
];
const ALL_TOOLS: string[] = MCP_TOOL_GROUPS.flatMap((g) => g.tools);
// ─── Slice helpers ────────────────────────────────────────────────────────────
function retainSlice(config: Record<string, any>): RetainEdits {
@@ -56,6 +96,12 @@ function observationsSlice(config: Record<string, any>): ObservationsEdits {
};
}
function mcpSlice(config: Record<string, any>): MCPEdits {
return {
mcp_enabled_tools: config.mcp_enabled_tools ?? null,
};
}
const DEFAULT_PROFILE: ProfileData = {
reflect_mission: "",
disposition_skepticism: 3,
@@ -79,14 +125,17 @@ export function BankConfigView() {
observationsSlice({})
);
const [reflectEdits, setReflectEdits] = useState<ProfileData>(DEFAULT_PROFILE);
const [mcpEdits, setMcpEdits] = useState<MCPEdits>(mcpSlice({}));
// Per-section saving/error state
const [retainSaving, setRetainSaving] = useState(false);
const [observationsSaving, setObservationsSaving] = useState(false);
const [reflectSaving, setReflectSaving] = useState(false);
const [mcpSaving, setMcpSaving] = useState(false);
const [retainError, setRetainError] = useState<string | null>(null);
const [observationsError, setObservationsError] = useState<string | null>(null);
const [reflectError, setReflectError] = useState<string | null>(null);
const [mcpError, setMcpError] = useState<string | null>(null);
// Reset dialog
@@ -103,6 +152,10 @@ export function BankConfigView() {
() => JSON.stringify(reflectEdits) !== JSON.stringify(baseProfile),
[reflectEdits, baseProfile]
);
const mcpDirty = useMemo(
() => JSON.stringify(mcpEdits) !== JSON.stringify(mcpSlice(baseConfig)),
[mcpEdits, baseConfig]
);
useEffect(() => {
if (bankId) loadAll();
@@ -130,6 +183,7 @@ export function BankConfigView() {
setRetainEdits(retainSlice(cfg));
setObservationsEdits(observationsSlice(cfg));
setReflectEdits(prof);
setMcpEdits(mcpSlice(cfg));
} catch (err) {
console.error("Failed to load bank data:", err);
} finally {
@@ -184,6 +238,20 @@ export function BankConfigView() {
}
};
const saveMCP = async () => {
if (!bankId) return;
setMcpSaving(true);
setMcpError(null);
try {
await client.updateBankConfig(bankId, mcpEdits);
setBaseConfig((prev) => ({ ...prev, ...mcpEdits }));
} catch (err: any) {
setMcpError(err.message || "Failed to save MCP settings");
} finally {
setMcpSaving(false);
}
};
if (!bankId) {
return (
<div className="flex items-center justify-center py-12">
@@ -348,11 +416,139 @@ export function BankConfigView() {
onChange={(v) => setReflectEdits((prev) => ({ ...prev, disposition_empathy: v }))}
/>
</ConfigSection>
{/* MCP Tools Section */}
<ConfigSection
title="MCP Tools"
description="Restrict which MCP tools this bank exposes to agents"
error={mcpError}
dirty={mcpDirty}
saving={mcpSaving}
onSave={saveMCP}
>
<FieldRow
label="Restrict tools"
description="When off, all tools are available. When on, only the selected tools can be invoked for this bank."
>
<div className="flex justify-end">
<Toggle
value={mcpEdits.mcp_enabled_tools !== null}
onChange={(restricted) =>
setMcpEdits({
mcp_enabled_tools: restricted ? [...ALL_TOOLS] : null,
})
}
/>
</div>
</FieldRow>
{mcpEdits.mcp_enabled_tools !== null && (
<ToolSelector
selected={mcpEdits.mcp_enabled_tools}
onChange={(tools) => setMcpEdits({ mcp_enabled_tools: tools })}
/>
)}
</ConfigSection>
</div>
</>
);
}
// ─── ToolSelector ─────────────────────────────────────────────────────────────
function ToolSelector({
selected,
onChange,
}: {
selected: string[];
onChange: (tools: string[]) => void;
}) {
const selectedSet = new Set(selected);
const toggleTool = (tool: string) => {
const next = new Set(selectedSet);
if (next.has(tool)) {
next.delete(tool);
} else {
next.add(tool);
}
onChange(ALL_TOOLS.filter((t) => next.has(t)));
};
const allSelected = ALL_TOOLS.every((t) => selectedSet.has(t));
const noneSelected = selected.length === 0;
const toggleAll = () => {
onChange(allSelected ? [] : [...ALL_TOOLS]);
};
return (
<div className="px-6 py-4 space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
{selected.length} of {ALL_TOOLS.length} tools enabled
</p>
<button type="button" onClick={toggleAll} className="text-xs text-primary hover:underline">
{allSelected ? "Deselect all" : "Select all"}
</button>
</div>
<div className="space-y-4">
{MCP_TOOL_GROUPS.map((group) => {
const groupSelected = group.tools.filter((t) => selectedSet.has(t)).length;
const groupAll = groupSelected === group.tools.length;
return (
<div key={group.label}>
<div className="flex items-center justify-between mb-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{group.label}
</p>
<button
type="button"
onClick={() => {
const next = new Set(selectedSet);
if (groupAll) {
group.tools.forEach((t) => next.delete(t));
} else {
group.tools.forEach((t) => next.add(t));
}
onChange(ALL_TOOLS.filter((t) => next.has(t)));
}}
className="text-xs text-primary hover:underline"
>
{groupAll ? "Deselect" : "Select all"}
</button>
</div>
<div className="flex flex-wrap gap-2">
{group.tools.map((tool) => {
const active = selectedSet.has(tool);
return (
<button
key={tool}
type="button"
onClick={() => toggleTool(tool)}
className={`px-2.5 py-1 rounded text-xs font-mono transition-colors border ${
active
? "bg-primary text-primary-foreground border-primary"
: "bg-muted/30 text-muted-foreground border-border/40 hover:border-primary/40"
}`}
>
{tool}
</button>
);
})}
</div>
</div>
);
})}
</div>
{noneSelected && (
<p className="text-xs text-destructive">
Warning: no tools selected agents will be blocked from all MCP calls for this bank.
</p>
)}
</div>
);
}
// ─── ConfigSection ────────────────────────────────────────────────────────────
function ConfigSection({
@@ -799,10 +799,36 @@ Configuration for MCP server endpoints.
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_MCP_ENABLED` | Enable MCP server at `/mcp/{bank_id}/` | `true` |
| `HINDSIGHT_API_MCP_ENABLED_TOOLS` | Comma-separated allowlist of MCP tools to expose globally (empty = all tools) | - |
| `HINDSIGHT_API_MCP_AUTH_TOKEN` | Bearer token for MCP authentication (optional) | - |
| `HINDSIGHT_API_MCP_LOCAL_BANK_ID` | Memory bank ID for local MCP | `mcp` |
| `HINDSIGHT_API_MCP_INSTRUCTIONS` | Additional instructions appended to retain/recall tool descriptions | - |
**Tool Access Control:**
`HINDSIGHT_API_MCP_ENABLED_TOOLS` restricts which MCP tools are registered at the server level. This is useful for read-only deployments or limiting surface area:
```bash
# Expose only recall (read-only deployment)
export HINDSIGHT_API_MCP_ENABLED_TOOLS=recall
# Expose recall and reflect only
export HINDSIGHT_API_MCP_ENABLED_TOOLS=recall,reflect
```
Available tool names: `retain`, `recall`, `reflect`, `list_banks`, `create_bank`, `list_mental_models`, `get_mental_model`, `create_mental_model`, `update_mental_model`, `delete_mental_model`, `refresh_mental_model`, `list_directives`, `create_directive`, `delete_directive`, `list_memories`, `get_memory`, `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`.
This can also be overridden per bank via the [config API](#hierarchical-configuration):
```bash
# Restrict a specific bank to read-only MCP access
curl -X PATCH http://localhost:8888/v1/default/banks/my-bank/config \
-H "Content-Type: application/json" \
-d '{"updates": {"mcp_enabled_tools": ["recall"]}}'
```
When a bank-level `mcp_enabled_tools` is set, tools not in the list return a clear error when invoked (they still appear in the tools list for MCP protocol compatibility).
**MCP Authentication:**
By default, the MCP endpoint is open. For production deployments, set `HINDSIGHT_API_MCP_AUTH_TOKEN` to require Bearer token authentication:
@@ -983,6 +1009,7 @@ Configuration fields are categorized for security:
1. **Configurable Fields** - Safe behavioral settings that can be customized per-bank:
- Retention: `retain_chunk_size`, `retain_extraction_mode`, `retain_mission`, `retain_custom_instructions`
- Observations: `enable_observations`, `observations_mission`
- MCP access control: `mcp_enabled_tools`
2. **Credential Fields** - NEVER exposed or configurable via API:
- API keys: `*_api_key` (all LLM API keys)