Merge branch 'main' into mcp-metadata

This commit is contained in:
Luca Beurer-Kellner
2025-05-16 11:32:48 +02:00
24 changed files with 889 additions and 39 deletions
-6
View File
@@ -1,6 +0,0 @@
# This specifies the Invariant Explorer instance where the gateway will push the traces
# Set this to https://preview-explorer.invariantlabs.ai if you want to push to Preview.
# If you want to push to a local instance of explorer, then specify the app-api docker container name like:
# http://<app-api-docker-container-name>:8000 to push to the local explorer instance.
INVARIANT_API_URL=https://explorer.invariantlabs.ai
GUARDRAILS_API_URL=https://explorer.invariantlabs.ai
+2 -2
View File
@@ -47,8 +47,8 @@ jobs:
- name: build gateway image
uses: docker/build-push-action@v5
with:
context: ./gateway
file: Dockerfile.gateway
context: .
file: ./gateway/Dockerfile.gateway
platforms: linux/amd64
push: true
tags: ${{ env.tags }}
+2 -1
View File
@@ -13,6 +13,7 @@ jobs:
test:
name: Build & Test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -57,4 +58,4 @@ jobs:
if [[ "${{ steps.integration-tests.outcome }}" != "success" ]]; then
echo "Integration tests failed"
exit 1
fi
fi
+2
View File
@@ -0,0 +1,2 @@
include gateway/docker-compose.local.yml
include gateway/Dockerfile.gateway
+8 -1
View File
@@ -1,4 +1,11 @@
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=invariantmonitor
POSTGRES_HOST=database
POSTGRES_HOST=database
# This specifies the Invariant Explorer instance where the gateway will push the traces
# Set this to https://preview-explorer.invariantlabs.ai if you want to push to Preview.
# If you want to push to a local instance of explorer, then specify the app-api docker container name like:
# http://<app-api-docker-container-name>:8000 to push to the local explorer instance.
INVARIANT_API_URL=https://explorer.invariantlabs.ai
GUARDRAILS_API_URL=https://explorer.invariantlabs.ai
@@ -2,11 +2,11 @@ FROM python:3.12
WORKDIR /srv/gateway
COPY pyproject.toml ./
COPY pyproject.toml ./pyproject.toml
COPY gateway/ ./
COPY . .
COPY README.md /srv/gateway/README.md
COPY README.md ./README.md
RUN pip install --no-cache-dir .
+204 -5
View File
@@ -1,12 +1,20 @@
"""Script is used to run actions using the Invariant Gateway."""
import asyncio
import os
import signal
import subprocess
import sys
import time
from typing import Optional
from gateway.mcp import mcp
LOCAL_COMPOSE_FILE = "gateway/docker-compose.local.yml"
# Handle signals to ensure clean shutdown
def signal_handler(sig, frame):
"""Handle signals for graceful shutdown."""
@@ -16,8 +24,14 @@ def signal_handler(sig, frame):
def print_help():
"""Prints the help message."""
actions = {
"mcp": "Runs the Invariant Gateway against MCP (Model Context Protocol) servers with guardrailing and push to Explorer features.",
"llm": "Runs the Invariant Gateway against LLM providers with guardrailing and push to Explorer features. Not implemented yet.",
"mcp": """
Runs the Invariant Gateway against MCP (Model Context Protocol) stdio servers with guardrailing and push to Explorer features.
""",
"server": """
Runs the Invariant Gateway server locally providing guardrailing and push to Explorer features.
Should be called with one of the following subcommands: build, up, down, logs.
A guardrails file can be passed with the flag: --guardrails-file=/path/to/guardrails/file.
""",
"help": "Shows this help message.",
}
@@ -25,6 +39,181 @@ def print_help():
print(f"{verb}: {description}")
def ensure_network_exists(network_name: str = "invariant-explorer-web") -> bool:
"""Ensure the Docker network exists."""
try:
# Check if network exists
result = subprocess.run(
["docker", "network", "inspect", network_name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
if result.returncode != 0:
print(f"Creating Docker network: {network_name}")
subprocess.run(
["docker", "network", "create", network_name],
check=True,
)
return True
except subprocess.CalledProcessError as e:
print(f"Error creating Docker network: {e}")
return False
def setup_guardrails(guardrails_file_path: Optional[str] = None) -> bool:
"""Configure guardrails if specified."""
if not guardrails_file_path:
return True
if not os.path.isfile(guardrails_file_path):
print(
f"Error: Specified guardrails file does not exist: {guardrails_file_path}"
)
return False
# Convert to absolute path
guardrails_file_path = os.path.realpath(guardrails_file_path)
os.environ["GUARDRAILS_FILE_PATH"] = guardrails_file_path
# Check if INVARIANT_API_KEY is set
if not os.environ.get("INVARIANT_API_KEY"):
print(
"Error: A guardrails file is specified, but INVARIANT_API_KEY env var is not set. "
"This is required to validate guardrails."
)
return False
return True
def build():
"""Build Docker containers using docker-compose."""
try:
print(f"Building using docker-compose file: {LOCAL_COMPOSE_FILE}")
subprocess.run(
["docker", "compose", "-f", str(LOCAL_COMPOSE_FILE), "build"],
check=True,
)
print("Build completed successfully")
return True
except subprocess.CalledProcessError as e:
print(f"Error building containers: {e}")
return False
def up(guardrails_file_path: Optional[str] = None):
"""Set up the local server for the Invariant Gateway."""
# Ensure network exists
if not ensure_network_exists():
return 1
# Set up guardrails
if not setup_guardrails(guardrails_file_path=guardrails_file_path):
return 1
# Run the server
try:
# Start containers
print(f"Starting containers using docker-compose file: {LOCAL_COMPOSE_FILE}")
subprocess.run(
["docker", "compose", "-f", str(LOCAL_COMPOSE_FILE), "up", "-d"],
check=True,
)
# Wait for containers to start
time.sleep(2)
# Check if gateway container is running
result = subprocess.run(
["docker", "ps", "-qf", "name=invariant-gateway"],
capture_output=True,
text=True,
check=True,
)
if not result.stdout.strip():
print("The invariant-gateway container failed to start.")
logs = subprocess.run(
["docker", "logs", "invariant-gateway"],
capture_output=True,
text=True,
check=False,
)
print("Last 20 lines of logs:")
print("\n".join(logs.stdout.strip().split("\n")[-20:]))
return False
print("Gateway started at http://localhost:8005/api/v1/gateway/")
print("See http://localhost:8005/api/v1/gateway/docs for API documentation")
if guardrails_file_path:
print(f"Using Guardrails File: {guardrails_file_path}")
return True
except subprocess.CalledProcessError as e:
print(f"Error starting containers: {e}")
return False
def down():
"""Stop the Docker containers."""
try:
print(f"Stopping containers using docker-compose file: {LOCAL_COMPOSE_FILE}")
subprocess.run(
["docker", "compose", "-f", str(LOCAL_COMPOSE_FILE), "down"],
check=True,
)
print("Containers stopped successfully")
return True
except subprocess.CalledProcessError as e:
print(f"Error stopping containers: {e}")
return False
def logs():
"""Show container logs."""
try:
subprocess.run(
["docker", "compose", "-f", str(LOCAL_COMPOSE_FILE), "logs", "-f"],
check=True,
)
return True
except subprocess.CalledProcessError as e:
print(f"Error showing logs: {e}")
return False
except KeyboardInterrupt:
print("\nExiting logs view")
return True
def run_server_command(command, args=None):
"""Run a server command."""
if args is None:
args = []
if command == "build":
return build()
elif command == "up":
# Parse guardrails file from args
guardrails_file = None
for arg in args:
if arg.startswith("--guardrails-file="):
guardrails_file = arg.split("=", 1)[1]
return up(guardrails_file)
elif command == "down":
return down()
elif command == "logs":
return logs()
else:
print(f"Unknown server command: {command}")
print("Available commands: build, up, down, logs")
return False
def main():
"""Entry point for the Invariant Gateway."""
signal.signal(signal.SIGINT, signal_handler)
@@ -37,9 +226,19 @@ def main():
verb = sys.argv[1]
if verb == "mcp":
return asyncio.run(mcp.execute(sys.argv[2:]))
if verb == "llm":
print("[gateway/__main__.py] 'llm' action is not implemented yet.")
return 1
if verb == "server":
if len(sys.argv) < 3:
print(
"Error: Missing command for server. Should be one of: build, up, down, logs"
)
print_help()
return 1
command = sys.argv[2]
args = sys.argv[3:]
if not run_server_command(command, args):
return 1
return 0
if verb == "help":
print_help()
return 0
@@ -2,8 +2,8 @@ services:
invariant-gateway:
container_name: invariant-gateway
build:
context: .
dockerfile: Dockerfile.gateway
context: ..
dockerfile: gateway/Dockerfile.gateway
working_dir: /srv/gateway
env_file:
- .env
@@ -13,7 +13,7 @@ services:
- ${INVARIANT_API_KEY:+INVARIANT_API_KEY=${INVARIANT_API_KEY}}
volumes:
- type: bind
source: ./gateway
source: ../gateway
target: /srv/gateway
- type: bind
source: ${GUARDRAILS_FILE_PATH:-/dev/null}
+5 -2
View File
@@ -167,7 +167,7 @@ async def fetch_guardrails_from_explorer(
)
# Get the user details.
user_info_response = await client.get("/api/v1/user/identity")
user_info_response = await client.get("/api/v1/user/identity", timeout=5)
if user_info_response.status_code == 401:
raise HTTPException(
status_code=401,
@@ -183,7 +183,10 @@ async def fetch_guardrails_from_explorer(
# Get the dataset policies.
policies_response = await client.get(
f"/api/v1/dataset/byuser/{username}/{dataset_name}/policy",
params={"client_name": client_name, "server_name": server_name},
params={
**({"client_name": client_name} if client_name else {}),
**({"server_name": server_name} if server_name else {}),
},
)
if policies_response.status_code != 200:
if policies_response.status_code == 404:
+1
View File
@@ -371,6 +371,7 @@ async def check_guardrails(
"Authorization": context.get_guardrailing_authorization(),
"Accept": "application/json"
},
timeout=5,
)
if not result.is_success:
if result.status_code == 401:
+1 -3
View File
@@ -333,9 +333,7 @@ async def _hook_tool_call(session_id: str, request_json: dict) -> Tuple[dict, bo
},
}, True
# Push trace to the explorer - don't block on its response
asyncio.create_task(
session_store.add_message_to_session(session_id, message, guardrails_result)
)
await session_store.add_message_to_session(session_id, message, guardrails_result)
return request_json, False
+10 -5
View File
@@ -37,7 +37,7 @@ up() {
fi
# Start Docker Compose with the correct environment variable
docker compose -f docker-compose.local.yml up -d
docker compose -f gateway/docker-compose.local.yml up -d
# Get the status of the container
sleep 2
@@ -58,12 +58,12 @@ up() {
build() {
# Build local services
docker compose -f docker-compose.local.yml build
docker compose -f gateway/docker-compose.local.yml build
}
down() {
# Bring down local services
docker compose -f docker-compose.local.yml down
docker compose -f gateway/docker-compose.local.yml down
GATEWAY_ROOT_PATH=$(pwd) docker compose -f tests/integration/docker-compose.test.yml down
}
@@ -146,6 +146,7 @@ integration_tests() {
# Generate latest whl file for the invariant-gateway package.
# This is required to run the integration tests.
pip install build
rm -rf dist
python -m build
WHEEL_FILE=$(ls dist/*.whl | head -n 1)
echo "WHEEL_FILE: $WHEEL_FILE"
@@ -154,9 +155,13 @@ integration_tests() {
docker build -t 'invariant-gateway-tests' -f ./tests/integration/Dockerfile.test ./tests
docker run \
docker rm -f invariant-gateway-integration-tests-container >/dev/null 2>&1 || true
mkdir -p /tmp/docker-logs/.invariant
docker run --name invariant-gateway-integration-tests-container \
--mount type=bind,source=./tests/integration,target=/tests \
--mount type=bind,source=$(realpath $WHEEL_FILE),target=/package/$(basename $WHEEL_FILE) \
--mount type=bind,source=/tmp/docker-logs/.invariant,target=/root/.invariant \
--network invariant-gateway-web-test \
-e OPENAI_API_KEY="$OPENAI_API_KEY" \
-e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY"\
@@ -188,7 +193,7 @@ case "$1" in
down
;;
"logs")
docker compose -f docker-compose.local.yml logs -f
docker compose -f gateway/docker-compose.local.yml logs -f
;;
"unit-tests")
shift
+17 -1
View File
@@ -25,7 +25,7 @@ services:
container_name: invariant-gateway-test
build:
context: ${GATEWAY_ROOT_PATH}
dockerfile: ${GATEWAY_ROOT_PATH}/Dockerfile.gateway
dockerfile: ${GATEWAY_ROOT_PATH}/gateway/Dockerfile.gateway
depends_on:
app-api:
condition: service_healthy
@@ -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
+420
View File
@@ -0,0 +1,420 @@
"""Test MCP gateway via SSE and stdio transports."""
import os
import uuid
from resources.mcp.sse.client.main import run as mcp_sse_client_run
from resources.mcp.stdio.client.main import run as mcp_stdio_client_run
from utils import create_dataset, add_guardrail_to_dataset
import pytest
import requests
from mcp.shared.exceptions import McpError
MCP_SSE_SERVER_HOST = "mcp-messenger-sse-server"
MCP_SSE_SERVER_PORT = 8123
@pytest.mark.asyncio
@pytest.mark.timeout(15)
@pytest.mark.parametrize(
"push_to_explorer, transport",
[
(False, "stdio"),
(False, "sse"),
(True, "stdio"),
(True, "sse"),
],
)
async def test_mcp_with_gateway(
explorer_api_url,
invariant_gateway_package_whl_file,
gateway_url,
push_to_explorer,
transport,
):
"""Test MCP gateway and verify trace is pushed to explorer"""
project_name = "test-mcp-" + str(uuid.uuid4())
# Run the MCP client and make the tool call.
if transport == "sse":
result = await mcp_sse_client_run(
gateway_url + "/api/v1/gateway/mcp/sse",
f"http://{MCP_SSE_SERVER_HOST}:{MCP_SSE_SERVER_PORT}",
project_name,
push_to_explorer=push_to_explorer,
tool_name="get_last_message_from_user",
tool_args={"username": "Alice"}
)
else:
result = await mcp_stdio_client_run(
invariant_gateway_package_whl_file,
project_name,
server_script_path="resources/mcp/stdio/messenger_server/main.py",
push_to_explorer=push_to_explorer,
tool_name="get_last_message_from_user",
tool_args={"username": "Alice"},
metadata_keys={"my-custom-key": "value1", "my-custom-key-2": "value2"},
)
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"][2]["role"] == "assistant"
assert trace["messages"][2]["tool_calls"][0]["function"] == {
"name": "get_last_message_from_user",
"arguments": {"username": "Alice"},
}
assert trace["messages"][3]["role"] == "tool"
assert trace["messages"][3]["content"] == [
{"type": "text", "text": "What is your favorite food?\n"}
]
@pytest.mark.asyncio
@pytest.mark.timeout(15)
@pytest.mark.parametrize("transport", ["stdio", "sse"])
async def test_mcp_with_gateway_and_logging_guardrails(
explorer_api_url, invariant_gateway_package_whl_file, gateway_url, transport
):
"""Test MCP gateway 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.
if transport == "sse":
result = await mcp_sse_client_run(
gateway_url + "/api/v1/gateway/mcp/sse",
f"http://{MCP_SSE_SERVER_HOST}:{MCP_SSE_SERVER_PORT}",
project_name,
push_to_explorer=True,
tool_name="get_last_message_from_user",
tool_args={"username": "Alice"},
)
else:
result = await mcp_stdio_client_run(
invariant_gateway_package_whl_file,
project_name,
server_script_path="resources/mcp/stdio/messenger_server/main.py",
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"][2]["role"] == "assistant"
assert trace["messages"][2]["tool_calls"][0]["function"] == {
"name": "get_last_message_from_user",
"arguments": {"username": "Alice"},
}
assert trace["messages"][3]["role"] == "tool"
assert trace["messages"][3]["content"] == [
{"type": "text", "text": "What is your favorite food?\n"}
]
# Validate the annotations
annotations = trace["annotations"]
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.3.content.0.text:22-26"
):
food_annotation = annotation
elif (
annotation["content"] == "get_last_message_from_user is called"
and annotation["address"] == "messages.2.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)
@pytest.mark.parametrize("transport", ["stdio", "sse"])
async def test_mcp_with_gateway_and_blocking_guardrails(
explorer_api_url, invariant_gateway_package_whl_file, gateway_url, transport
):
"""Test MCP gateway 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:
if transport == "sse":
_ = await mcp_sse_client_run(
gateway_url + "/api/v1/gateway/mcp/sse",
f"http://{MCP_SSE_SERVER_HOST}:{MCP_SSE_SERVER_PORT}",
project_name,
push_to_explorer=True,
tool_name="get_last_message_from_user",
tool_args={"username": "Alice"},
)
else:
_ = await mcp_stdio_client_run(
invariant_gateway_package_whl_file,
project_name,
server_script_path="resources/mcp/stdio/messenger_server/main.py",
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"][2]["role"] == "assistant"
assert trace["messages"][2]["tool_calls"][0]["function"] == {
"name": "get_last_message_from_user",
"arguments": {"username": "Alice"},
}
# Validate the annotations
annotations = trace["annotations"]
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"
@pytest.mark.asyncio
@pytest.mark.timeout(15)
@pytest.mark.parametrize("transport", ["stdio", "sse"])
async def test_mcp_sse_with_gateway_hybrid_guardrails(
explorer_api_url, invariant_gateway_package_whl_file, gateway_url, transport
):
"""Test MCP gateway and verify that logging and blocking guardrails work together"""
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="log",
invariant_authorization="Bearer " + os.getenv("INVARIANT_API_KEY"),
)
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="block",
invariant_authorization="Bearer " + os.getenv("INVARIANT_API_KEY"),
)
# Run the MCP client and make the tool call.
try:
if transport == "sse":
_ = await mcp_sse_client_run(
gateway_url + "/api/v1/gateway/mcp/sse",
f"http://{MCP_SSE_SERVER_HOST}:{MCP_SSE_SERVER_PORT}",
project_name,
push_to_explorer=True,
tool_name="get_last_message_from_user",
tool_args={"username": "Alice"},
)
else:
_ = await mcp_stdio_client_run(
invariant_gateway_package_whl_file,
project_name,
server_script_path="resources/mcp/stdio/messenger_server/main.py",
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 output 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 "food in ToolOutput" 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"][2]["role"] == "assistant"
assert trace["messages"][2]["tool_calls"][0]["function"] == {
"name": "get_last_message_from_user",
"arguments": {"username": "Alice"},
}
assert trace["messages"][3]["role"] == "tool"
assert trace["messages"][3]["content"] == [
{"type": "text", "text": "What is your favorite food?\n"}
]
# Validate the annotations
annotations = trace["annotations"]
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.3.content.0.text:22-26"
):
food_annotation = annotation
elif (
annotation["content"] == "get_last_message_from_user is called"
and annotation["address"] == "messages.2.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"
+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,8 +1,8 @@
"""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
@@ -76,7 +76,9 @@ class MCPClient:
)
self.stdio, self.write = stdio_transport
self.session = await self.exit_stack.enter_async_context(
ClientSession(self.stdio, self.write)
ClientSession(
self.stdio, self.write, read_timeout_seconds=timedelta(seconds=10)
)
)
await self.session.initialize()
@@ -140,5 +142,6 @@ async def run(
finally:
# Sleep for a while to allow the server to process the background tasks
# like pushing traces to the explorer
time.sleep(2)
if push_to_explorer:
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
+1 -1
View File
@@ -4,7 +4,7 @@ import os
import uuid
from typing import Any, Dict, Literal, Optional
from httpx import AsyncClient, Client
from httpx import Client
from openai import OpenAI
from google import genai
from anthropic import Anthropic