Skip to content

Commit d90588b

Browse files
authored
feat: improve mcp tools based on endpoint (#318)
* feat: improve mcp tools based on endpoint * feat: improve mcp tools based on endpoint * test: add integration test for MCP endpoint routing - Add test_mcp_endpoint_routing.py to verify single-bank vs multi-bank tool exposure - Verifies /mcp/ exposes all tools with bank_id parameters - Verifies /mcp/{bank_id}/ only exposes scoped tools without bank_id parameters - Regression test for issue #317 Related: #317, #318 * test: use StreamableHTTP client for MCP endpoint routing test Replace httpx AsyncClient SSE parsing with proper MCP StreamableHTTP client. This correctly tests the MCP server using the actual protocol that clients will use. Fixes #317
1 parent d0f67c9 commit d90588b

File tree

4 files changed

+273
-234
lines changed

4 files changed

+273
-234
lines changed

hindsight-api/hindsight_api/api/__init__.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,22 +72,24 @@ def create_app(
7272

7373
# Mount MCP server and chain its lifespan if enabled
7474
if mcp_app is not None:
75-
# Get the MCP app's underlying Starlette app for lifespan access
76-
mcp_starlette_app = mcp_app.mcp_app
75+
# Get both MCP apps' underlying Starlette apps for lifespan access
76+
multi_bank_starlette_app = mcp_app.multi_bank_app
77+
single_bank_starlette_app = mcp_app.single_bank_app
7778

7879
# Store the original lifespan
7980
original_lifespan = app.router.lifespan_context
8081

8182
@asynccontextmanager
8283
async def chained_lifespan(app_instance: FastAPI):
83-
"""Chain the MCP lifespan with the main app lifespan."""
84-
# Start MCP lifespan first
85-
async with mcp_starlette_app.router.lifespan_context(mcp_starlette_app):
86-
logger.info("MCP lifespan started")
87-
# Then start the original app lifespan
88-
async with original_lifespan(app_instance):
89-
yield
90-
logger.info("MCP lifespan stopped")
84+
"""Chain both MCP lifespans with the main app lifespan."""
85+
# Start both MCP lifespans (multi-bank and single-bank)
86+
async with multi_bank_starlette_app.router.lifespan_context(multi_bank_starlette_app):
87+
async with single_bank_starlette_app.router.lifespan_context(single_bank_starlette_app):
88+
logger.info("MCP lifespans started (multi-bank and single-bank)")
89+
# Then start the original app lifespan
90+
async with original_lifespan(app_instance):
91+
yield
92+
logger.info("MCP lifespans stopped")
9193

9294
# Replace the app's lifespan with the chained version
9395
app.router.lifespan_context = chained_lifespan

hindsight-api/hindsight_api/api/mcp.py

Lines changed: 68 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,14 @@ def get_current_api_key() -> str | None:
5454
return _current_api_key.get()
5555

5656

57-
def create_mcp_server(memory: MemoryEngine) -> FastMCP:
57+
def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
5858
"""
5959
Create and configure the Hindsight MCP server.
6060
6161
Args:
6262
memory: MemoryEngine instance (required)
63+
multi_bank: If True, expose all tools with bank_id parameters (default).
64+
If False, only expose bank-scoped tools without bank_id parameters.
6365
6466
Returns:
6567
Configured FastMCP server instance with stateless_http enabled
@@ -71,8 +73,8 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
7173
config = MCPToolsConfig(
7274
bank_id_resolver=get_current_bank_id,
7375
api_key_resolver=get_current_api_key, # Propagate API key for tenant auth
74-
include_bank_id_param=True, # HTTP MCP supports multi-bank via parameter
75-
tools=None, # All tools
76+
include_bank_id_param=multi_bank,
77+
tools=None if multi_bank else {"retain", "recall", "reflect"}, # Scoped tools for single-bank mode
7678
retain_fire_and_forget=False, # HTTP MCP supports sync/async modes
7779
)
7880

@@ -88,20 +90,33 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
8890

8991

9092
class MCPMiddleware:
91-
"""ASGI middleware that handles authentication and extracts bank_id from header or path.
93+
"""ASGI middleware that handles authentication and routes to appropriate MCP server.
9294
9395
Authentication:
9496
1. If HINDSIGHT_API_MCP_AUTH_TOKEN is set (legacy), validates against that token
9597
2. Otherwise, uses TenantExtension.authenticate_mcp() from the MemoryEngine
9698
- DefaultTenantExtension: no auth required (local dev)
9799
- ApiKeyTenantExtension: validates against env var
98100
99-
Bank ID can be provided via:
100-
1. X-Bank-Id header (recommended for Claude Code)
101-
2. URL path: /mcp/{bank_id}/
102-
3. Environment variable HINDSIGHT_MCP_BANK_ID (fallback default)
101+
Two modes based on URL structure:
103102
104-
For Claude Code, configure with:
103+
1. Multi-bank mode (for /mcp/ root endpoint):
104+
- Exposes all tools: retain, recall, reflect, list_banks, create_bank
105+
- All tools include optional bank_id parameter for cross-bank operations
106+
- Bank ID from: X-Bank-Id header or HINDSIGHT_MCP_BANK_ID env var
107+
108+
2. Single-bank mode (for /mcp/{bank_id}/ endpoints):
109+
- Exposes bank-scoped tools only: retain, recall, reflect
110+
- No bank_id parameter (comes from URL)
111+
- No bank management tools (list_banks, create_bank)
112+
- Recommended for agent isolation
113+
114+
Examples:
115+
# Single-bank mode (recommended for agent isolation)
116+
claude mcp add --transport http my-agent http://localhost:8888/mcp/my-agent-bank/ \\
117+
--header "Authorization: Bearer <token>"
118+
119+
# Multi-bank mode (for cross-bank operations)
105120
claude mcp add --transport http hindsight http://localhost:8888/mcp \\
106121
--header "X-Bank-Id: my-bank" --header "Authorization: Bearer <token>"
107122
"""
@@ -110,10 +125,23 @@ def __init__(self, app, memory: MemoryEngine):
110125
self.app = app
111126
self.memory = memory
112127
self.tenant_extension = memory._tenant_extension
113-
self.mcp_server = create_mcp_server(memory)
114-
self.mcp_app = self.mcp_server.http_app(path="/")
115-
# Expose the lifespan for the parent app to chain
116-
self.lifespan = self.mcp_app.lifespan_handler if hasattr(self.mcp_app, "lifespan_handler") else None
128+
129+
# Create two server instances:
130+
# 1. Multi-bank server (for /mcp/ root endpoint)
131+
self.multi_bank_server = create_mcp_server(memory, multi_bank=True)
132+
self.multi_bank_app = self.multi_bank_server.http_app(path="/")
133+
134+
# 2. Single-bank server (for /mcp/{bank_id}/ endpoints)
135+
self.single_bank_server = create_mcp_server(memory, multi_bank=False)
136+
self.single_bank_app = self.single_bank_server.http_app(path="/")
137+
138+
# Backward compatibility: expose multi_bank_app as mcp_app
139+
self.mcp_app = self.multi_bank_app
140+
141+
# Expose the lifespan for the parent app to chain (use multi-bank as default)
142+
self.lifespan = (
143+
self.multi_bank_app.lifespan_handler if hasattr(self.multi_bank_app, "lifespan_handler") else None
144+
)
117145

118146
def _get_header(self, scope: dict, name: str) -> str | None:
119147
"""Extract a header value from ASGI scope."""
@@ -125,7 +153,7 @@ def _get_header(self, scope: dict, name: str) -> str | None:
125153

126154
async def __call__(self, scope, receive, send):
127155
if scope["type"] != "http":
128-
await self.mcp_app(scope, receive, send)
156+
await self.multi_bank_app(scope, receive, send)
129157
return
130158

131159
# Extract auth token from header (for tenant auth propagation)
@@ -173,8 +201,13 @@ async def __call__(self, scope, receive, send):
173201
elif path == "/mcp":
174202
path = "/"
175203

204+
# Ensure path has leading slash (needed after stripping mount path)
205+
if path and not path.startswith("/"):
206+
path = "/" + path
207+
176208
# Try to get bank_id from header first (for Claude Code compatibility)
177209
bank_id = self._get_header(scope, "X-Bank-Id")
210+
bank_id_from_path = False
178211

179212
# MCP endpoint paths that should not be treated as bank_ids
180213
MCP_ENDPOINTS = {"sse", "messages"}
@@ -187,13 +220,19 @@ async def __call__(self, scope, receive, send):
187220
if parts[0] and parts[0] not in MCP_ENDPOINTS:
188221
# First segment looks like a bank_id
189222
bank_id = parts[0]
223+
bank_id_from_path = True
190224
new_path = "/" + parts[1] if len(parts) > 1 else "/"
191225

192226
# Fall back to default bank_id
193227
if not bank_id:
194228
bank_id = DEFAULT_BANK_ID
195229
logger.debug(f"Using default bank_id: {bank_id}")
196230

231+
# Select the appropriate MCP app based on how bank_id was provided:
232+
# - Path-based bank_id → single-bank app (no bank_id param, scoped tools)
233+
# - Header/env bank_id → multi-bank app (bank_id param, all tools)
234+
target_app = self.single_bank_app if bank_id_from_path else self.multi_bank_app
235+
197236
# Set bank_id and api_key context
198237
bank_id_token = _current_bank_id.set(bank_id)
199238
# Store the auth token for tenant extension to validate
@@ -206,15 +245,15 @@ async def __call__(self, scope, receive, send):
206245

207246
# Wrap send to rewrite the SSE endpoint URL to include bank_id if using path-based routing
208247
async def send_wrapper(message):
209-
if message["type"] == "http.response.body":
248+
if message["type"] == "http.response.body" and bank_id_from_path:
210249
body = message.get("body", b"")
211250
if body and b"/messages" in body:
212251
# Rewrite /messages to /{bank_id}/messages in SSE endpoint event
213252
body = body.replace(b"data: /messages", f"data: /{bank_id}/messages".encode())
214253
message = {**message, "body": body}
215254
await send(message)
216255

217-
await self.mcp_app(new_scope, receive, send_wrapper)
256+
await target_app(new_scope, receive, send_wrapper)
218257
finally:
219258
_current_bank_id.reset(bank_id_token)
220259
if api_key_token is not None:
@@ -242,15 +281,23 @@ async def _send_error(self, send, status: int, message: str):
242281

243282
def create_mcp_app(memory: MemoryEngine):
244283
"""
245-
Create an ASGI app that handles MCP requests.
284+
Create an ASGI app that handles MCP requests with dynamic tool exposure.
246285
247286
Authentication:
248287
Uses the TenantExtension from the MemoryEngine (same auth as REST API).
249288
250-
Bank ID can be provided via:
251-
1. X-Bank-Id header: claude mcp add --transport http hindsight http://localhost:8888/mcp --header "X-Bank-Id: my-bank"
252-
2. URL path: /mcp/{bank_id}/
253-
3. Environment variable HINDSIGHT_MCP_BANK_ID (fallback, default: "default")
289+
Two modes based on URL structure:
290+
291+
1. Single-bank mode (recommended for agent isolation):
292+
- URL: /mcp/{bank_id}/
293+
- Tools: retain, recall, reflect (no bank_id parameter)
294+
- Example: claude mcp add --transport http my-agent http://localhost:8888/mcp/my-agent-bank/
295+
296+
2. Multi-bank mode (for cross-bank operations):
297+
- URL: /mcp/
298+
- Tools: retain, recall, reflect, list_banks, create_bank (all with bank_id parameter)
299+
- Bank ID from: X-Bank-Id header or HINDSIGHT_MCP_BANK_ID env var (default: "default")
300+
- Example: claude mcp add --transport http hindsight http://localhost:8888/mcp --header "X-Bank-Id: my-bank"
254301
255302
Args:
256303
memory: MemoryEngine instance
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Integration test for MCP endpoint routing.
2+
3+
This test verifies that /mcp/ and /mcp/{bank_id}/ expose different tool sets.
4+
"""
5+
6+
import httpx
7+
import pytest
8+
from mcp.client.session import ClientSession
9+
from mcp.client.streamable_http import streamable_http_client
10+
11+
12+
@pytest.mark.asyncio
13+
async def test_mcp_endpoint_routing_integration(memory):
14+
"""Test that multi-bank and single-bank endpoints expose different tools using StreamableHTTP.
15+
16+
This is a regression test for issue #317 where /mcp/{bank_id}/ was incorrectly
17+
exposing all tools (including list_banks) and bank_id parameters.
18+
"""
19+
from hindsight_api.api import create_app
20+
21+
# Create app with MCP enabled
22+
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
23+
24+
# Use the app's lifespan context to properly initialize MCP servers
25+
async with app.router.lifespan_context(app):
26+
# Create an HTTPX client that routes to our ASGI app
27+
from httpx import ASGITransport
28+
29+
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
30+
# Test 1: Multi-bank endpoint /mcp/
31+
async with streamable_http_client("http://test/mcp/", http_client=http_client) as (
32+
read_stream,
33+
write_stream,
34+
_,
35+
):
36+
async with ClientSession(read_stream, write_stream) as session:
37+
await session.initialize()
38+
multi_result = await session.list_tools()
39+
40+
multi_tools = {t.name for t in multi_result.tools}
41+
42+
# Multi-bank should have all tools including bank management
43+
assert "retain" in multi_tools
44+
assert "recall" in multi_tools
45+
assert "reflect" in multi_tools
46+
assert "list_banks" in multi_tools, "Multi-bank should expose list_banks"
47+
assert "create_bank" in multi_tools, "Multi-bank should expose create_bank"
48+
49+
# Multi-bank retain should have bank_id parameter
50+
retain_tool = next((t for t in multi_result.tools if t.name == "retain"), None)
51+
assert retain_tool is not None
52+
multi_params = set(retain_tool.inputSchema.get("properties", {}).keys())
53+
assert "bank_id" in multi_params, "Multi-bank retain should have bank_id parameter"
54+
55+
# Test 2: Single-bank endpoint /mcp/test-bank/
56+
async with streamable_http_client("http://test/mcp/test-bank/", http_client=http_client) as (
57+
read_stream,
58+
write_stream,
59+
_,
60+
):
61+
async with ClientSession(read_stream, write_stream) as session:
62+
await session.initialize()
63+
single_result = await session.list_tools()
64+
65+
single_tools = {t.name for t in single_result.tools}
66+
67+
# Single-bank should only have scoped tools (no bank management)
68+
assert "retain" in single_tools
69+
assert "recall" in single_tools
70+
assert "reflect" in single_tools
71+
assert "list_banks" not in single_tools, "Single-bank should NOT expose list_banks"
72+
assert "create_bank" not in single_tools, "Single-bank should NOT expose create_bank"
73+
74+
# Single-bank retain should NOT have bank_id parameter
75+
retain_tool = next((t for t in single_result.tools if t.name == "retain"), None)
76+
assert retain_tool is not None
77+
single_params = set(retain_tool.inputSchema.get("properties", {}).keys())
78+
assert "bank_id" not in single_params, "Single-bank retain should NOT have bank_id parameter"

0 commit comments

Comments
 (0)