fleet-memory/hindsight-api-slim/tests/test_mcp_endpoint_routing.py
DK09876 8364b9c5d5
fix: MCP tool calls fail when MCP_AUTH_TOKEN and TENANT_API_KEY differ (#635)
* fix: MCP tool calls fail when MCP_AUTH_TOKEN and TENANT_API_KEY differ

When both HINDSIGHT_API_MCP_AUTH_TOKEN and ApiKeyTenantExtension are
configured with different values, MCP transport auth passes but tool
execution fails because the MCP token gets re-validated against the
tenant API key in the engine layer.

Add mcp_authenticated flag to RequestContext so the engine skips tenant
re-validation when MCP transport auth already succeeded.

Fixes #627

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: strengthen assertion to verify no auth error in tool response

The original test only checked that "banks" key existed in the response,
which was true even for error responses like {"error": "...", "banks": []}.
Now asserts "error" not in parsed to properly catch auth failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 18:36:05 +01:00

373 lines
17 KiB
Python

"""Integration test for MCP endpoint routing.
This test verifies that /mcp/ and /mcp/{bank_id}/ expose different tool sets,
and that URLs with or without trailing slashes both work (no 307 redirect).
"""
import json
from unittest.mock import patch
import httpx
import pytest
from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamable_http_client
@pytest.mark.asyncio
async def test_mcp_endpoint_routing_integration(memory):
"""Test that multi-bank and single-bank endpoints expose different tools using StreamableHTTP.
This is a regression test for issue #317 where /mcp/{bank_id}/ was incorrectly
exposing all tools (including list_banks) and bank_id parameters.
"""
from hindsight_api.api import create_app
# Create app with MCP enabled
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
# Use the app's lifespan context to properly initialize MCP servers
async with app.router.lifespan_context(app):
# Create an HTTPX client that routes to our ASGI app
from httpx import ASGITransport
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
# Test 1: Multi-bank endpoint /mcp/
async with streamable_http_client("http://test/mcp/", http_client=http_client) as (
read_stream,
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
multi_result = await session.list_tools()
multi_tools = {t.name for t in multi_result.tools}
# Multi-bank should have all tools including bank management and mental models
assert "retain" in multi_tools
assert "recall" in multi_tools
assert "reflect" in multi_tools
assert "list_banks" in multi_tools, "Multi-bank should expose list_banks"
assert "create_bank" in multi_tools, "Multi-bank should expose create_bank"
assert "list_mental_models" in multi_tools, "Multi-bank should expose list_mental_models"
assert "create_mental_model" in multi_tools, "Multi-bank should expose create_mental_model"
assert "get_mental_model" in multi_tools, "Multi-bank should expose get_mental_model"
assert "update_mental_model" in multi_tools, "Multi-bank should expose update_mental_model"
assert "delete_mental_model" in multi_tools, "Multi-bank should expose delete_mental_model"
assert "refresh_mental_model" in multi_tools, "Multi-bank should expose refresh_mental_model"
# Multi-bank retain should have bank_id parameter
retain_tool = next((t for t in multi_result.tools if t.name == "retain"), None)
assert retain_tool is not None
multi_params = set(retain_tool.inputSchema.get("properties", {}).keys())
assert "bank_id" in multi_params, "Multi-bank retain should have bank_id parameter"
# Test 2: Single-bank endpoint /mcp/test-bank/
async with streamable_http_client("http://test/mcp/test-bank/", http_client=http_client) as (
read_stream,
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
single_result = await session.list_tools()
single_tools = {t.name for t in single_result.tools}
# Single-bank should have scoped tools including mental models (no bank management)
assert "retain" in single_tools
assert "recall" in single_tools
assert "reflect" in single_tools
assert "list_mental_models" in single_tools, "Single-bank should expose list_mental_models"
assert "create_mental_model" in single_tools, "Single-bank should expose create_mental_model"
assert "list_banks" not in single_tools, "Single-bank should NOT expose list_banks"
assert "create_bank" not in single_tools, "Single-bank should NOT expose create_bank"
# Single-bank retain should NOT have bank_id parameter
retain_tool = next((t for t in single_result.tools if t.name == "retain"), None)
assert retain_tool is not None
single_params = set(retain_tool.inputSchema.get("properties", {}).keys())
assert "bank_id" not in single_params, "Single-bank retain should NOT have bank_id parameter"
@pytest.mark.asyncio
async def test_mcp_no_trailing_slash_works(memory):
"""Test that /mcp (no trailing slash) discovers tools without 307 redirect.
Starlette's Mount class redirects /mcp to /mcp/ with a 307 Temporary Redirect.
Many MCP clients don't follow POST redirects, causing 0 tools to be discovered.
MCPMiddleware wraps the app directly (no Mount), so the redirect never happens.
"""
from hindsight_api.api import create_app
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
async with app.router.lifespan_context(app):
from httpx import ASGITransport
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
# /mcp (no slash) should work the same as /mcp/
async with streamable_http_client("http://test/mcp", http_client=http_client) as (
read_stream,
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
result = await session.list_tools()
tools = {t.name for t in result.tools}
assert len(tools) >= 11, f"Expected at least 11 tools from /mcp, got {len(tools)}: {tools}"
assert "retain" in tools
assert "recall" in tools
assert "list_banks" in tools
# /mcp/my-bank (single-bank, no slash) should also work
async with streamable_http_client("http://test/mcp/my-bank", http_client=http_client) as (
read_stream,
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
result = await session.list_tools()
tools = {t.name for t in result.tools}
assert "retain" in tools
assert "list_banks" not in tools, "Single-bank /mcp/my-bank should NOT expose list_banks"
@pytest.mark.asyncio
async def test_mcp_tool_execution_through_client(memory):
"""Test that tools can be called (not just discovered) through the MCP client.
This verifies the full pipeline: HTTP → middleware → FastMCP → tool → engine → response.
Previous tests only checked tool discovery (list_tools), not actual execution.
"""
from httpx import ASGITransport
from hindsight_api.api import create_app
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
async with streamable_http_client("http://test/mcp/", http_client=http_client) as (
read_stream,
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
# Execute list_banks tool
result = await session.call_tool("list_banks", arguments={})
assert result is not None
assert len(result.content) > 0
# The result text should be valid JSON with a "banks" key
import json
response_text = result.content[0].text
parsed = json.loads(response_text)
assert "banks" in parsed
@pytest.mark.asyncio
async def test_mcp_mental_model_validation_through_client(memory):
"""Test that input validation works through the real MCP transport.
Verifies that invalid inputs return error messages without crashing,
and that the engine is never called with invalid data.
"""
from httpx import ASGITransport
from hindsight_api.api import create_app
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
async with streamable_http_client("http://test/mcp/", http_client=http_client) as (
read_stream,
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
# Test: empty name should return validation error
import json
result = await session.call_tool(
"create_mental_model",
arguments={"name": "", "source_query": "test query"},
)
assert result is not None
parsed = json.loads(result.content[0].text)
assert "error" in parsed
assert "name cannot be empty" in parsed["error"]
# Test: max_tokens out of range should return validation error
result = await session.call_tool(
"create_mental_model",
arguments={"name": "Test", "source_query": "test query", "max_tokens": 0},
)
parsed = json.loads(result.content[0].text)
assert "error" in parsed
assert "max_tokens must be between 256 and 8192" in parsed["error"]
@pytest.mark.asyncio
async def test_mcp_bank_named_sse_routes_to_single_bank(memory):
"""Test that a bank named 'sse' routes to single-bank mode.
Regression test: the old MCP_ENDPOINTS blocklist prevented banks named 'sse'
or 'messages' from being accessed via path routing. They fell through to
multi-bank mode instead.
"""
from httpx import ASGITransport
from hindsight_api.api import create_app
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
async with streamable_http_client("http://test/mcp/sse/", http_client=http_client) as (
read_stream,
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
result = await session.list_tools()
tools = {t.name for t in result.tools}
# Should be single-bank mode (no bank management tools)
assert "retain" in tools
assert "recall" in tools
assert "list_banks" not in tools, "Bank 'sse' should route to single-bank mode"
assert "create_bank" not in tools
# retain should NOT have bank_id parameter (single-bank mode)
retain_tool = next(t for t in result.tools if t.name == "retain")
params = set(retain_tool.inputSchema.get("properties", {}).keys())
assert "bank_id" not in params
@pytest.mark.asyncio
async def test_mcp_bank_named_messages_routes_to_single_bank(memory):
"""Test that a bank named 'messages' routes to single-bank mode.
Same regression test as test_mcp_bank_named_sse_routes_to_single_bank but for 'messages'.
"""
from httpx import ASGITransport
from hindsight_api.api import create_app
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
async with streamable_http_client("http://test/mcp/messages/", http_client=http_client) as (
read_stream,
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
result = await session.list_tools()
tools = {t.name for t in result.tools}
assert "retain" in tools
assert "list_banks" not in tools, "Bank 'messages' should route to single-bank mode"
@pytest.mark.asyncio
async def test_mcp_tool_execution_with_different_mcp_and_tenant_tokens(memory):
"""Test that MCP tool calls work when MCP_AUTH_TOKEN and TENANT_API_KEY differ.
Regression test for https://github.com/vectorize-io/hindsight/issues/627
When both HINDSIGHT_API_MCP_AUTH_TOKEN and ApiKeyTenantExtension are configured
with different values, tool calls should succeed because MCP transport auth
already validated the token — the tenant extension should not re-validate.
"""
from httpx import ASGITransport
from hindsight_api.api import create_app
from hindsight_api.extensions import ApiKeyTenantExtension
mcp_token = "mcp-secret-token"
tenant_key = "tenant-secret-key"
# Configure ApiKeyTenantExtension with a different key than the MCP token
tenant_ext = ApiKeyTenantExtension({"api_key": tenant_key})
memory._tenant_extension = tenant_ext
# Patch MCP_AUTH_TOKEN so the MCP middleware uses legacy auth
with patch("hindsight_api.api.mcp.MCP_AUTH_TOKEN", mcp_token):
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
async with app.router.lifespan_context(app):
# Pass auth header via the httpx client (streamable_http_client doesn't accept headers)
async with httpx.AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": f"Bearer {mcp_token}"},
) as http_client:
async with streamable_http_client("http://test/mcp/", http_client=http_client) as (
read_stream,
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
# list_tools should work
tools_result = await session.list_tools()
tool_names = {t.name for t in tools_result.tools}
assert "get_bank" in tool_names
# Tool execution should work (this was failing before the fix)
result = await session.call_tool("list_banks", arguments={})
assert result is not None
assert len(result.content) > 0
parsed = json.loads(result.content[0].text)
assert "banks" in parsed
assert "error" not in parsed, f"Tool call failed with: {parsed.get('error')}"
@pytest.mark.asyncio
async def test_mcp_rejects_wrong_mcp_token_even_if_matches_tenant_key(memory):
"""Test that an invalid MCP token is rejected even if it matches the tenant key.
When MCP_AUTH_TOKEN is set, the MCP middleware should validate against that token,
not the tenant API key.
"""
from httpx import ASGITransport
from hindsight_api.api import create_app
from hindsight_api.extensions import ApiKeyTenantExtension
mcp_token = "mcp-secret-token"
tenant_key = "tenant-secret-key"
tenant_ext = ApiKeyTenantExtension({"api_key": tenant_key})
memory._tenant_extension = tenant_ext
with patch("hindsight_api.api.mcp.MCP_AUTH_TOKEN", mcp_token):
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
# Try connecting with the tenant key (wrong for MCP auth)
response = await http_client.post(
"http://test/mcp/",
headers={
"Authorization": f"Bearer {tenant_key}",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
)
assert response.status_code == 401