Add integration tests for MCP SSE via gateway with guardrails.

This commit is contained in:
Hemang
2025-05-14 14:21:48 +05:30
committed by Hemang Sarkar
parent e2e004b7b1
commit 8eae198eb0
12 changed files with 486 additions and 5 deletions
+16
View File
@@ -106,6 +106,22 @@ services:
timeout: 5s
retries: 5
mcp-messenger-sse-server:
# MCP SSE server used in integration tests
build:
context: ${GATEWAY_ROOT_PATH}
dockerfile: ${GATEWAY_ROOT_PATH}/tests/integration/resources/mcp/sse/messenger_server/Dockerfile.mcp-server
container_name: invariant-gateway-test-mcp-server
networks:
- invariant-gateway-web-test
ports:
- "8123:8123"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8123/sse"]
interval: 3s
timeout: 5s
retries: 5
networks:
invariant-gateway-web-test:
external: true
+261
View File
@@ -0,0 +1,261 @@
"""Test MCP gateway via SSE."""
import os
import uuid
from resources.mcp.sse.client.main import run as mcp_client_run
from utils import create_dataset, add_guardrail_to_dataset
import pytest
import requests
from mcp.shared.exceptions import McpError
MCP_SERVER_HOST = "mcp-messenger-sse-server"
MCP_SERVER_PORT = 8123
@pytest.mark.asyncio
@pytest.mark.timeout(15)
@pytest.mark.parametrize("push_to_explorer", [False, True])
async def test_mcp_sse_with_gateway(explorer_api_url, gateway_url, push_to_explorer):
"""Test MCP gateway via sse and verify trace is pushed to explorer"""
project_name = "test-mcp-" + str(uuid.uuid4())
# Run the MCP client and make the tool call.
result = await mcp_client_run(
gateway_url + "/api/v1/gateway/mcp/sse",
f"http://{MCP_SERVER_HOST}:{MCP_SERVER_PORT}",
project_name,
push_to_explorer=push_to_explorer,
tool_name="get_last_message_from_user",
tool_args={"username": "Alice"},
)
assert result.isError is False
assert (
result.content[0].type == "text"
and result.content[0].text == "What is your favorite food?\n"
)
if push_to_explorer:
# Fetch the trace ids for the dataset
traces_response = requests.get(
f"{explorer_api_url}/api/v1/dataset/byuser/developer/{project_name}/traces",
timeout=5,
)
traces = traces_response.json()
assert len(traces) == 1
trace_id = traces[0]["id"]
# Fetch the trace
trace_response = requests.get(
f"{explorer_api_url}/api/v1/trace/{trace_id}",
timeout=5,
)
trace = trace_response.json()
metadata = trace["extra_metadata"]
assert (
metadata["source"] == "mcp"
and metadata["mcp_client"] == "mcp"
and metadata["mcp_server"] == "messenger_server"
)
assert trace["messages"][0]["role"] == "assistant"
assert trace["messages"][0]["tool_calls"][0]["function"] == {
"name": "get_last_message_from_user",
"arguments": {"username": "Alice"},
}
assert trace["messages"][1]["role"] == "tool"
assert trace["messages"][1]["content"] == [
{"type": "text", "text": "What is your favorite food?\n"}
]
@pytest.mark.asyncio
@pytest.mark.timeout(15)
async def test_mcp_sse_with_gateway_and_logging_guardrails(
explorer_api_url, gateway_url
):
"""Test MCP gateway via sse and verify that logging guardrails work"""
project_name = "test-mcp-" + str(uuid.uuid4())
dataset_creation_response = await create_dataset(
explorer_api_url,
invariant_authorization="Bearer " + os.getenv("INVARIANT_API_KEY"),
dataset_name=project_name,
)
dataset_id = dataset_creation_response["id"]
_ = await add_guardrail_to_dataset(
explorer_api_url,
dataset_id=dataset_id,
policy='raise "food in ToolOutput" if:\n (tool_output: ToolOutput)\n (chunk: str) in text(tool_output.content)\n "food" in chunk',
action="log",
invariant_authorization="Bearer " + os.getenv("INVARIANT_API_KEY"),
)
_ = await add_guardrail_to_dataset(
explorer_api_url,
dataset_id=dataset_id,
policy='raise "get_last_message_from_user is called" if:\n (tool_call: ToolCall)\n tool_call is tool:get_last_message_from_user',
action="log",
invariant_authorization="Bearer " + os.getenv("INVARIANT_API_KEY"),
)
# Run the MCP client and make the tool call.
result = await mcp_client_run(
gateway_url + "/api/v1/gateway/mcp/sse",
f"http://{MCP_SERVER_HOST}:{MCP_SERVER_PORT}",
project_name,
push_to_explorer=True,
tool_name="get_last_message_from_user",
tool_args={"username": "Alice"},
)
assert result.isError is False
assert (
result.content[0].type == "text"
and result.content[0].text == "What is your favorite food?\n"
)
# Fetch the trace ids for the dataset
traces_response = requests.get(
f"{explorer_api_url}/api/v1/dataset/byuser/developer/{project_name}/traces",
timeout=5,
)
traces = traces_response.json()
assert len(traces) == 1
trace_id = traces[0]["id"]
# Fetch the trace
trace_response = requests.get(
f"{explorer_api_url}/api/v1/trace/{trace_id}",
timeout=5,
)
trace = trace_response.json()
metadata = trace["extra_metadata"]
assert (
metadata["source"] == "mcp"
and metadata["mcp_client"] == "mcp"
and metadata["mcp_server"] == "messenger_server"
)
assert trace["messages"][0]["role"] == "assistant"
assert trace["messages"][0]["tool_calls"][0]["function"] == {
"name": "get_last_message_from_user",
"arguments": {"username": "Alice"},
}
assert trace["messages"][1]["role"] == "tool"
assert trace["messages"][1]["content"] == [
{"type": "text", "text": "What is your favorite food?\n"}
]
# Fetch annotations
annotations_response = requests.get(
f"{explorer_api_url}/api/v1/trace/{trace_id}/annotations",
timeout=5,
)
annotations = annotations_response.json()
food_annotation = None
tool_call_annotation = None
assert len(annotations) == 2
for annotation in annotations:
if (
annotation["content"] == "food in ToolOutput"
and annotation["address"] == "messages.1.content.0.text:22-26"
):
food_annotation = annotation
elif (
annotation["content"] == "get_last_message_from_user is called"
and annotation["address"] == "messages.0.tool_calls.0"
):
tool_call_annotation = annotation
assert food_annotation is not None, "Missing 'food in ToolOutput' annotation"
assert (
tool_call_annotation is not None
), "Missing 'get_last_message_from_user is called' annotation"
assert food_annotation["extra_metadata"]["source"] == "guardrails-error"
assert tool_call_annotation["extra_metadata"]["source"] == "guardrails-error"
@pytest.mark.asyncio
@pytest.mark.timeout(15)
async def test_mcp_sse_with_gateway_and_blocking_guardrails(
explorer_api_url, gateway_url
):
"""Test MCP gateway via sse and verify that blocking guardrails work"""
project_name = "test-mcp-" + str(uuid.uuid4())
dataset_creation_response = await create_dataset(
explorer_api_url,
invariant_authorization="Bearer " + os.getenv("INVARIANT_API_KEY"),
dataset_name=project_name,
)
dataset_id = dataset_creation_response["id"]
_ = await add_guardrail_to_dataset(
explorer_api_url,
dataset_id=dataset_id,
policy='raise "get_last_message_from_user is called" if:\n (tool_call: ToolCall)\n tool_call is tool:get_last_message_from_user',
action="block",
invariant_authorization="Bearer " + os.getenv("INVARIANT_API_KEY"),
)
# Run the MCP client and make the tool call.
try:
_ = await mcp_client_run(
gateway_url + "/api/v1/gateway/mcp/sse",
f"http://{MCP_SERVER_HOST}:{MCP_SERVER_PORT}",
project_name,
push_to_explorer=True,
tool_name="get_last_message_from_user",
tool_args={"username": "Alice"},
)
# If we get here, the tool call was not blocked
pytest.fail("Expected McpError to be raised")
# The tool call should be blocked by the guardrail
# and an error should be raised.
except McpError as e:
assert (
"[Invariant Guardrails] The MCP tool call was blocked for security reasons"
in e.error.message
)
assert "get_last_message_from_user is called" in e.error.message
assert e.error.code == -32600
# Fetch the trace ids for the dataset
traces_response = requests.get(
f"{explorer_api_url}/api/v1/dataset/byuser/developer/{project_name}/traces",
timeout=5,
)
traces = traces_response.json()
assert len(traces) == 1
trace_id = traces[0]["id"]
# Fetch the trace
trace_response = requests.get(
f"{explorer_api_url}/api/v1/trace/{trace_id}",
timeout=5,
)
trace = trace_response.json()
metadata = trace["extra_metadata"]
assert (
metadata["source"] == "mcp"
and metadata["mcp_client"] == "mcp"
and metadata["mcp_server"] == "messenger_server"
)
assert trace["messages"][0]["role"] == "assistant"
assert trace["messages"][0]["tool_calls"][0]["function"] == {
"name": "get_last_message_from_user",
"arguments": {"username": "Alice"},
}
# Fetch annotations
annotations_response = requests.get(
f"{explorer_api_url}/api/v1/trace/{trace_id}/annotations",
timeout=5,
)
annotations = annotations_response.json()
assert len(annotations) == 1
assert (
annotations[0]["content"] == "get_last_message_from_user is called"
and annotations[0]["address"] == "messages.0.tool_calls.0"
)
assert annotations[0]["extra_metadata"]["source"] == "guardrails-error"
+3
View File
@@ -13,6 +13,7 @@ from resources.mcp.stdio.client.main import run as mcp_client_run
@pytest.mark.asyncio
@pytest.mark.timeout(15)
@pytest.mark.parametrize("push_to_explorer", [False, True])
async def test_mcp_stdio_with_gateway(
explorer_api_url, invariant_gateway_package_whl_file, push_to_explorer
@@ -70,6 +71,7 @@ async def test_mcp_stdio_with_gateway(
@pytest.mark.asyncio
@pytest.mark.timeout(15)
async def test_mcp_stdio_with_gateway_and_logging_guardrails(
explorer_api_url, invariant_gateway_package_whl_file
):
@@ -174,6 +176,7 @@ async def test_mcp_stdio_with_gateway_and_logging_guardrails(
@pytest.mark.asyncio
@pytest.mark.timeout(15)
async def test_mcp_stdio_with_gateway_and_blocking_guardrails(
explorer_api_url, invariant_gateway_package_whl_file
):
+1
View File
@@ -6,5 +6,6 @@ openai
pillow
pytest
pytest-asyncio
pytest-timeout
tavily-python
uv
@@ -0,0 +1,106 @@
"""This is a simple example of how to use the MCP client with SSE transport."""
# pylint: disable=E1101
# pylint: disable=W0201
import asyncio
from datetime import timedelta
from typing import Any, Optional
from contextlib import AsyncExitStack
from mcp import ClientSession
from mcp.client.sse import sse_client
class MCPClient:
"""MCP Client for interacting with a MCP SSE server and processing queries"""
def __init__(self):
# Initialize session and client objects
self.session: Optional[ClientSession] = None
self.exit_stack = AsyncExitStack()
self._streams_context = None # Initialize these to None
self._session_context = None # so they always exist
async def connect_to_sse_server(
self, server_url: str, headers: Optional[dict] = None
):
"""
Connect to an MCP server running with SSE transport
Args:
server_url: URL of the MCP server
headers: Optional headers to include in the request
"""
# Store the context managers so they stay alive
self._streams_context = sse_client(
url=server_url,
timeout=5,
headers=headers or {},
sse_read_timeout=10,
)
streams = await self._streams_context.__aenter__()
self._session_context = ClientSession(*streams)
# pylint: disable=C2801
self.session: ClientSession = await self._session_context.__aenter__()
# Initialize
await self.session.initialize()
async def cleanup(self):
"""Clean up the session and streams"""
# Check if the session context exists before trying to exit it
if hasattr(self, "_session_context") and self._session_context is not None:
await self._session_context.__aexit__(None, None, None)
# Check if the streams context exists before trying to exit it
if hasattr(self, "_streams_context") and self._streams_context is not None:
await self._streams_context.__aexit__(None, None, None)
async def process_query(self, tool_name: str, tool_args: dict) -> str:
"""Process a query using MCP server"""
result = await self.session.call_tool(
tool_name, tool_args, read_timeout_seconds=timedelta(seconds=10)
)
return result
async def run(
gateway_url: str,
mcp_server_base_url: str,
project_name: str,
push_to_explorer: bool,
tool_name: str,
tool_args: dict[str, Any],
):
"""
Run the MCP client with the given parameters.
Args:
gateway_url: URL of the Invariant Gateway
mcp_server_base_url: Base URL of the MCP server
project_name: Name of the project in Invariant Explorer
push_to_explorer: Whether to push traces to the Invariant Explorer
tool_name: Name of the tool to call
tool_args: Arguments for the tool call
"""
client = MCPClient()
try:
await client.connect_to_sse_server(
server_url=gateway_url,
headers={
"MCP-SERVER-BASE-URL": mcp_server_base_url,
"INVARIANT-PROJECT-NAME": project_name,
"PUSH-INVARIANT-EXPLORER": str(push_to_explorer),
},
)
return await client.process_query(tool_name, tool_args)
finally:
# Sleep for a while to allow the server to process the background tasks
# like pushing traces to the explorer
if push_to_explorer:
await asyncio.sleep(2)
await client.cleanup()
@@ -0,0 +1,11 @@
FROM python:3.12-slim
WORKDIR /app
# Copy the messenger server code
COPY tests/integration/resources/mcp/sse/messenger_server /app/messenger_server
# Install dependencies
RUN pip install --no-cache-dir "uvicorn[standard]" "httpx" "mcp[cli]" "starlette"
CMD ["python", "messenger_server/main.py", "--host", "0.0.0.0", "--port", "8123"]
@@ -0,0 +1,84 @@
"""This is a messenger server implementation that returns a few messages based on the username."""
import argparse
import hashlib
import uvicorn
from mcp.server.fastmcp import FastMCP
from mcp.server import Server
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.routing import Mount, Route
# Initialize FastMCP server
mcp = FastMCP("messenger_server")
MESSAGES = [
"What about you?",
"What are you doing?",
"What is your name?",
"What is your favorite color?",
"What is your favorite food?",
"What is your favorite movie?",
"What is your favorite book?",
]
def _deterministic_index_from_username(username: str, limit: int) -> int:
"""Deterministically calculate the index of messages to return based on the username."""
hash_val = int(hashlib.sha256(username.encode()).hexdigest(), 16)
return hash_val % limit + 1
@mcp.tool()
async def get_last_message_from_user(username: str) -> str:
"""Get the last message sent by the username."""
return MESSAGES[_deterministic_index_from_username(username, len(MESSAGES))] + "\n"
@mcp.tool()
async def send_message(username: str, message: str) -> str:
"""Send a message to the username."""
return f"Message '{message}' sent to {username}."
def create_starlette_app(server: Server, *, debug: bool = False) -> Starlette:
"""Create a Starlette application that can server the provied mcp server with SSE."""
sse = SseServerTransport("/messages/")
async def handle_sse(request: Request) -> None:
async with sse.connect_sse(
request.scope,
request.receive,
request._send, # pylint: disable=W0212
) as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options(),
)
return Starlette(
debug=debug,
routes=[
Route("/sse", endpoint=handle_sse),
Mount("/messages/", app=sse.handle_post_message),
],
)
if __name__ == "__main__":
mcp_server = mcp._mcp_server # pylint: disable=W0212
parser = argparse.ArgumentParser(description="Run MCP SSE-based server")
parser.add_argument("--host", help="Host to bind to", required=True)
parser.add_argument("--port", help="Port to listen on", required=True, type=int)
args = parser.parse_args()
# Bind SSE request handling to MCP server
starlette_app = create_starlette_app(mcp_server, debug=True)
uvicorn.run(starlette_app, host=args.host, port=args.port)
@@ -1,7 +1,7 @@
"""A MCP client implementation that interacts with MCP server to make tool calls."""
import asyncio
import os
import time
from datetime import timedelta
from contextlib import AsyncExitStack
from typing import Any, Optional
@@ -11,7 +11,7 @@ from mcp.client.stdio import stdio_client
class MCPClient:
"""MCP Client for interacting with a MCP server and processing queries"""
"""MCP Client for interacting with a MCP stdio server and processing queries"""
def __init__(self):
self.session: Optional[ClientSession] = None
@@ -69,7 +69,7 @@ class MCPClient:
self.stdio, self.write = stdio_transport
self.session = await self.exit_stack.enter_async_context(
ClientSession(
self.stdio, self.write, read_timeout_seconds=timedelta(minutes=0.5)
self.stdio, self.write, read_timeout_seconds=timedelta(seconds=10)
)
)
@@ -133,5 +133,5 @@ async def run(
# Sleep for a while to allow the server to process the background tasks
# like pushing traces to the explorer
if push_to_explorer:
time.sleep(2)
await asyncio.sleep(2)
await client.cleanup()
@@ -1,6 +1,5 @@
"""This is a messenger server implementation that returns a few messages based on the username."""
import random
import hashlib
from mcp.server.fastmcp import FastMCP