Files
invariant-gateway/gateway/mcp/mcp.py
T

650 lines
22 KiB
Python

"""Gateway for MCP (Model Context Protocol) integration with Invariant."""
import asyncio
import getpass
import json
import os
import platform
import select
import socket
import subprocess
import sys
from invariant_sdk.async_client import AsyncClient
from invariant_sdk.types.append_messages import AppendMessagesRequest
from invariant_sdk.types.push_traces import PushTracesRequest
from gateway.common.constants import (
INVARIANT_GUARDRAILS_BLOCKED_MESSAGE,
INVARIANT_GUARDRAILS_BLOCKED_TOOLS_MESSAGE,
MCP_METHOD,
MCP_CLIENT_INFO,
MCP_PARAMS,
MCP_SERVER_INFO,
MCP_TOOL_CALL,
MCP_LIST_TOOLS,
UTF_8,
)
from gateway.common.guardrails import GuardrailAction
from gateway.common.request_context import RequestContext
from gateway.integrations.explorer import create_annotations_from_guardrails_errors
from gateway.integrations.guardrails import check_guardrails
from gateway.mcp.log import mcp_log, MCP_LOG_FILE, format_errors_in_response
from gateway.mcp.mcp_context import McpContext
from gateway.mcp.task_utils import run_task_sync
DEFAULT_API_URL = "https://explorer.invariantlabs.ai"
STATUS_EOF = "eof"
STATUS_DATA = "data"
STATUS_WAIT = "wait"
def user_and_host() -> str:
"""Get the current user and hostname."""
username = getpass.getuser()
hostname = socket.gethostname()
return f"{username}@{hostname}"
def session_metadata(ctx: McpContext) -> dict:
"""Generate metadata for the current session."""
return {
"session_id": ctx.local_session_id,
"system_user": user_and_host(),
"mcp_client": ctx.mcp_client_name,
"mcp_server": ctx.mcp_server_name,
"tools": ctx.tools,
**(ctx.extra_metadata or {}),
}
def write_as_utf8_bytes(data: dict) -> bytes:
"""Serializes dict to bytes using UTF-8 encoding."""
return json.dumps(data).encode(UTF_8) + b"\n"
def deduplicate_annotations(ctx: McpContext, new_annotations: list) -> list:
"""Deduplicate new_annotations using the annotations in the context."""
deduped_annotations = []
for annotation in new_annotations:
if annotation not in ctx.annotations:
deduped_annotations.append(annotation)
return deduped_annotations
def check_if_new_errors(ctx: McpContext, guardrails_result: dict) -> bool:
"""Checks if there are new errors in the guardrails result."""
annotations = create_annotations_from_guardrails_errors(
guardrails_result.get("errors", [])
)
for annotation in annotations:
if annotation not in ctx.annotations:
return True
return False
async def append_and_push_trace(
ctx: McpContext, message: dict, guardrails_result: dict
) -> None:
"""
Append a message to the trace if it exists or create a new one
and push it to the Invariant Explorer.
"""
annotations = []
if guardrails_result and guardrails_result.get("errors", []):
annotations = create_annotations_from_guardrails_errors(
guardrails_result["errors"]
)
if ctx.guardrails.logging_guardrails:
logging_guardrails_check_result = await get_guardrails_check_result(
ctx, message, action=GuardrailAction.LOG
)
if logging_guardrails_check_result and logging_guardrails_check_result.get(
"errors", []
):
annotations.extend(
create_annotations_from_guardrails_errors(
logging_guardrails_check_result["errors"]
)
)
deduplicated_annotations = deduplicate_annotations(ctx, annotations)
try:
# If the trace_id is None, create a new trace with the messages.
# Otherwise, append the message to the existing trace.
client = AsyncClient(
api_url=os.getenv("INVARIANT_API_URL", DEFAULT_API_URL),
)
if ctx.trace_id is None:
ctx.trace.append(message)
# default metadata
metadata = {"source": "mcp"}
# include MCP session metadata
metadata.update(session_metadata(ctx))
response = await client.push_trace(
PushTracesRequest(
messages=[ctx.trace],
dataset=ctx.explorer_dataset,
metadata=[metadata],
annotations=[deduplicated_annotations],
)
)
ctx.trace_id = response.id[0]
ctx.last_trace_length = len(ctx.trace)
ctx.annotations.extend(deduplicated_annotations)
else:
ctx.trace.append(message)
response = await client.append_messages(
AppendMessagesRequest(
trace_id=ctx.trace_id,
messages=ctx.trace[ctx.last_trace_length :],
annotations=deduplicated_annotations,
)
)
ctx.last_trace_length = len(ctx.trace)
ctx.annotations.extend(deduplicated_annotations)
except Exception as e: # pylint: disable=broad-except
mcp_log("[ERROR] Error pushing trace in append_and_push_trace:", e)
async def get_guardrails_check_result(
ctx: McpContext,
message: dict,
action: GuardrailAction = GuardrailAction.BLOCK,
) -> dict:
"""
Check against guardrails of type action.
Works in both sync and async contexts by always using a dedicated thread.
"""
# Skip if no guardrails are configured for this action
if not (
(ctx.guardrails.blocking_guardrails and action == GuardrailAction.BLOCK)
or (ctx.guardrails.logging_guardrails and action == GuardrailAction.LOG)
):
return {}
# Prepare context and select appropriate guardrails
context = RequestContext.create(
request_json={},
dataset_name=ctx.explorer_dataset,
invariant_authorization="Bearer " + os.getenv("INVARIANT_API_KEY"),
guardrails=ctx.guardrails,
guardrails_parameters={"metadata": session_metadata(ctx), "action": action},
)
guardrails_to_check = (
ctx.guardrails.blocking_guardrails
if action == GuardrailAction.BLOCK
else ctx.guardrails.logging_guardrails
)
return run_task_sync(
check_guardrails,
messages=ctx.trace + [message],
guardrails=guardrails_to_check,
context=context,
)
def json_rpc_error_response(
id_value: str | int, error_message: str, response_type: str = "error"
) -> dict:
"""
Create a JSON-RPC error response with either error object or content format.
Args:
id_value: The ID of the JSON-RPC request
error_message: The error message to include
response_type: Either "error" or "content" to determine response format
Returns:
A properly formatted JSON-RPC response dictionary
"""
base_response = {
"jsonrpc": "2.0",
"id": id_value,
}
if response_type == "error":
base_response["error"] = {
"code": -32600,
"message": error_message,
}
else:
base_response["result"] = {
"content": [
{
"type": "text",
"text": error_message,
}
]
}
return base_response
async def hook_tool_call(ctx: McpContext, request: dict) -> tuple[dict, bool]:
"""
Hook function to intercept tool calls.
If the request is blocked, it returns a tuple with a message explaining the block
and a flag indicating the request was blocked.
Otherwise it returns the original request and a flag indicating it was not blocked.
"""
tool_call = {
"id": f"call_{request.get('id')}",
"type": "function",
"function": {
"name": request["params"]["name"],
"arguments": request["params"]["arguments"],
},
}
message = {"role": "assistant", "content": "", "tool_calls": [tool_call]}
# Check for blocking guardrails
guardrailing_result = await get_guardrails_check_result(
ctx, message, action=GuardrailAction.BLOCK
)
# If the request is blocked, return a message indicating the block reason.
if (
guardrailing_result
and guardrailing_result.get("errors", [])
and check_if_new_errors(ctx, guardrailing_result)
):
if ctx.push_explorer:
await append_and_push_trace(ctx, message, guardrailing_result)
else:
ctx.trace.append(message)
return json_rpc_error_response(
request.get("id"),
INVARIANT_GUARDRAILS_BLOCKED_MESSAGE % guardrailing_result["errors"],
response_type=ctx.failure_response_format,
), True
# Add the message to the trace
ctx.trace.append(message)
return request, False
async def hook_tool_result(ctx: McpContext, result: dict) -> dict:
"""
Hook function to intercept tool results.
Returns the potentially modified result.
"""
method = ctx.id_to_method_mapping.get(result.get("id"))
call_id = f"call_{result.get('id')}"
# Safely handle result object
result_obj = result.get("result", {})
if result_obj.get(MCP_SERVER_INFO):
ctx.mcp_server_name = result_obj.get(MCP_SERVER_INFO, {}).get("name", "")
if not method:
return result
elif method == MCP_TOOL_CALL:
message = {
"role": "tool",
"content": result_obj.get("content"),
"error": result_obj.get("error"),
"tool_call_id": call_id,
}
# Check for blocking guardrails
guardrailing_result = await get_guardrails_check_result(
ctx, message, action=GuardrailAction.BLOCK
)
if guardrailing_result and guardrailing_result.get("errors", []):
result = json_rpc_error_response(
result.get("id"),
INVARIANT_GUARDRAILS_BLOCKED_MESSAGE
% format_errors_in_response(guardrailing_result["errors"]),
response_type=ctx.failure_response_format, # Using content type as that's what the original code used
)
if ctx.push_explorer:
await append_and_push_trace(ctx, message, guardrailing_result)
else:
ctx.trace.append(message)
return result
elif method == MCP_LIST_TOOLS:
ctx.tools = result_obj.get("tools")
message = {
"role": "tool",
"content": json.dumps(result.get("result").get("tools")),
"tool_call_id": call_id,
}
# next validate it with guardrails
guardrailing_result = await get_guardrails_check_result(
ctx, message, action=GuardrailAction.BLOCK
)
if guardrailing_result and guardrailing_result.get("errors", []):
result["result"]["tools"] = [
{
"name": "blocked_" + tool["name"],
"description": INVARIANT_GUARDRAILS_BLOCKED_TOOLS_MESSAGE
% format_errors_in_response(guardrailing_result["errors"]),
# no parameters
"inputSchema": {
"properties": {},
"required": [],
"title": "invariant_mcp_server_blockedArguments",
"type": "object",
},
"annotations": {
"title": "This tool was blocked by security guardrails.",
},
}
for tool in result["result"]["tools"]
]
# add it to the session trace (and run logging guardrails)
if ctx.push_explorer:
await append_and_push_trace(ctx, message, guardrailing_result)
else:
ctx.trace.append(message)
return result
else:
return result
async def stream_and_forward_stdout(
mcp_process: subprocess.Popen, ctx: McpContext
) -> None:
"""Read from the mcp_process stdout, apply guardrails and forward to sys.stdout"""
loop = asyncio.get_event_loop()
while True:
line = await loop.run_in_executor(None, mcp_process.stdout.readline)
if not line:
break
try:
# Process complete JSON lines
line_str = line.decode(UTF_8).strip()
if not line_str:
continue
if ctx.verbose:
mcp_log(f"[INFO] server -> client: {line_str}")
parsed_json = json.loads(line_str)
processed_json = await hook_tool_result(ctx, parsed_json)
# Write and flush immediately
sys.stdout.buffer.write(write_as_utf8_bytes(processed_json))
sys.stdout.buffer.flush()
except Exception as e: # pylint: disable=broad-except
mcp_log(f"[ERROR] Error in stream_and_forward_stdout: {str(e)}")
if line:
mcp_log(f"[ERROR] Problematic line causing error: {line[:200]}...")
async def stream_and_forward_stderr(
mcp_process: subprocess.Popen, read_chunk_size: int = 10
) -> None:
"""Read from the mcp_process stderr and write to sys.stderr"""
loop = asyncio.get_event_loop()
while True:
# Read chunks asynchronously
chunk = await loop.run_in_executor(
None, lambda: mcp_process.stderr.read(read_chunk_size)
)
MCP_LOG_FILE.buffer.write(chunk)
MCP_LOG_FILE.buffer.flush()
async def process_line(
ctx: McpContext, mcp_process: subprocess.Popen, line: bytes
) -> None:
"""Process a line of input from stdin, decode it, and forward to mcp_process."""
if ctx.verbose:
mcp_log(f"[INFO] client -> server: {line}")
# Try to decode and parse as JSON to check for tool calls
try:
text = line.decode(UTF_8)
parsed_json = json.loads(text)
except json.JSONDecodeError as je:
mcp_log(f"[ERROR] JSON decode error in run_stdio_input_loop: {str(je)}")
mcp_log(f"[ERROR] Problematic line: {line[:200]}...")
return
if parsed_json.get(MCP_METHOD) is not None:
ctx.id_to_method_mapping[parsed_json.get("id")] = parsed_json.get(MCP_METHOD)
if parsed_json.get(MCP_PARAMS) and parsed_json.get(MCP_PARAMS).get(MCP_CLIENT_INFO):
ctx.mcp_client_name = (
parsed_json.get(MCP_PARAMS).get(MCP_CLIENT_INFO).get("name", "")
)
# Check if this is a tool call request
if parsed_json.get(MCP_METHOD) == MCP_TOOL_CALL:
# Refresh guardrails
run_task_sync(ctx.load_guardrails)
# Intercept and potentially block modify the request
hook_tool_call_result, is_blocked = await hook_tool_call(ctx, parsed_json)
if not is_blocked:
# If blocked, hook_tool_call_result contains the original request.
# Forward the request to the MCP process.
# It will handle the request and return a response.
mcp_process.stdin.write(write_as_utf8_bytes(hook_tool_call_result))
mcp_process.stdin.flush()
else:
# If blocked, hook_tool_call_result contains the block message.
# Forward the block message result back to the caller.
# The original request is not passed to the MCP process.
sys.stdout.buffer.write(write_as_utf8_bytes(hook_tool_call_result))
sys.stdout.buffer.flush()
return
else:
# pass through the request to the MCP process
# for list_tools, extend the trace by a tool call
if parsed_json.get(MCP_METHOD) == MCP_LIST_TOOLS:
# Refresh guardrails
run_task_sync(ctx.load_guardrails)
ctx.trace.append(
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": f"call_{parsed_json.get('id')}",
"type": "function",
"function": {
"name": "tools/list",
"arguments": {},
},
}
],
}
)
mcp_process.stdin.write(write_as_utf8_bytes(parsed_json))
mcp_process.stdin.flush()
async def wait_for_stdin_input(
loop: asyncio.AbstractEventLoop, stdin_fd: int
) -> tuple[bytes | None, str]:
"""
Platform-specific implementation to wait for and read input from stdin.
Args:
loop: The asyncio event loop
stdin_fd: The file descriptor for stdin
Returns:
tuple[bytes | None, str]: A tuple containing:
- The data read from stdin or None
- Status: 'eof' if EOF detected, 'data' if data available, 'wait' if no data yet
"""
if platform.system() == "Windows":
# On Windows, we can't use select for stdin
# Instead, we'll use a brief sleep and then try to read
await asyncio.sleep(0.01)
try:
chunk = await loop.run_in_executor(None, lambda: os.read(stdin_fd, 4096))
if not chunk: # Empty bytes means EOF
return None, STATUS_EOF
return chunk, STATUS_DATA
except (BlockingIOError, OSError):
# No data available yet
return None, STATUS_WAIT
else:
# On Unix-like systems, use select
ready, _, _ = await loop.run_in_executor(
None, lambda: select.select([stdin_fd], [], [], 0.1)
)
if not ready:
# No input available, yield to other tasks
await asyncio.sleep(0.01)
return None, STATUS_WAIT
# Read available data
chunk = await loop.run_in_executor(None, lambda: os.read(stdin_fd, 4096))
if not chunk: # Empty bytes means EOF
return None, STATUS_EOF
return chunk, STATUS_DATA
async def run_stdio_input_loop(
ctx: McpContext,
mcp_process: subprocess.Popen,
stdout_task: asyncio.Task,
stderr_task: asyncio.Task,
) -> None:
"""Handle standard input, intercept call and forward requests to mcp_process stdin."""
loop = asyncio.get_event_loop()
stdin_fd = sys.stdin.fileno()
buffer = b""
# Set stdin to non-blocking mode
os.set_blocking(stdin_fd, False)
try:
while True:
# Get input using platform-specific method
chunk, status = await wait_for_stdin_input(loop, stdin_fd)
if status == STATUS_EOF:
# EOF detected, break the loop
break
elif status == STATUS_WAIT:
# No data available yet, continue polling
continue
elif status == STATUS_DATA:
# We got some data, process it
buffer += chunk
# Process complete lines
while b"\n" in buffer:
line, buffer = buffer.split(b"\n", 1)
if not line:
continue
await process_line(ctx, mcp_process, line)
except (BrokenPipeError, KeyboardInterrupt):
# Broken pipe = client disappeared, just start shutdown
mcp_log("Client disconnected or keyboard interrupt")
finally:
# Close stdin
if mcp_process.stdin:
mcp_process.stdin.close()
# Process any remaining data
while b"\n" in buffer:
line, buffer = buffer.split(b"\n", 1)
if line:
await process_line(ctx, mcp_process, line)
# Terminate process if needed
if mcp_process.poll() is None:
mcp_process.terminate()
try:
await asyncio.wait_for(
loop.run_in_executor(None, mcp_process.wait), timeout=2
)
except asyncio.TimeoutError:
mcp_process.kill()
# Cancel I/O tasks
stdout_task.cancel()
stderr_task.cancel()
# Final flush
sys.stdout.flush()
def split_args(args: list[str] = None) -> tuple[list[str], list[str]]:
"""
Splits CLI arguments into two parts:
1. Arguments intended for the MCP gateway (everything before `--exec`)
2. Arguments for the underlying MCP server (everything after `--exec`)
Parameters:
args (list[str]): The list of CLI arguments.
Returns:
Tuple[list[str], list[str]]: A tuple containing (mcp_gateway_args, mcp_server_command_args)
"""
if not args:
mcp_log("[ERROR] No arguments provided.")
sys.exit(1)
try:
exec_index = args.index("--exec")
except ValueError:
mcp_log("[ERROR] '--exec' flag not found in arguments.")
sys.exit(1)
mcp_gateway_args = args[:exec_index]
mcp_server_command_args = args[exec_index + 1 :]
if not mcp_server_command_args:
mcp_log("[ERROR] No arguments provided after '--exec'.")
sys.exit(1)
return mcp_gateway_args, mcp_server_command_args
async def execute(args: list[str] = None):
"""Main function to execute the MCP gateway."""
if "INVARIANT_API_KEY" not in os.environ:
mcp_log("[ERROR] INVARIANT_API_KEY environment variable is not set.")
sys.exit(1)
mcp_log("[INFO] Running with Python version:", sys.version)
mcp_gateway_args, mcp_server_command_args = split_args(args)
ctx = McpContext(mcp_gateway_args)
mcp_process = subprocess.Popen(
mcp_server_command_args,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0,
)
# Start async tasks for stdout and stderr
stdout_task = asyncio.create_task(stream_and_forward_stdout(mcp_process, ctx))
stderr_task = asyncio.create_task(stream_and_forward_stderr(mcp_process))
# Handle forwarding stdin and intercept tool calls
await run_stdio_input_loop(ctx, mcp_process, stdout_task, stderr_task)