Add mcp.py

This commit is contained in:
Hemang
2025-04-11 06:09:44 +02:00
committed by Hemang Sarkar
parent 15210997a7
commit 66c6dcb8f9
6 changed files with 364 additions and 9 deletions
+5 -7
View File
@@ -2,6 +2,8 @@
import sys
from gateway.mcp import mcp
def main():
"""Entry point for the Invariant Gateway."""
@@ -12,8 +14,7 @@ def main():
}
def _help():
"""Prints the help message."""
print("\nSupported Commands by invariant-gateway:\n")
"""_prints the help message."""
for verb, description in actions.items():
print(f"{verb}: {description}")
@@ -23,14 +24,11 @@ def main():
verb = sys.argv[1]
if verb == "mcp":
# Use sys.argv[2:] to pass arguments to the MCP gateway
return 0
return mcp.execute(sys.argv[2:])
if verb == "llm":
# Use sys.argv[2:] to pass arguments to the LLM gateway
print("LLM gateway via the invariant-gateway command is not implemented yet.")
return 1
if verb == "help":
_help()
return 0
print(f"Unknown action: {verb}")
print(f"[gateway/__main__.py] Unknown action: {verb}")
return 1
+1 -1
View File
@@ -82,7 +82,7 @@ class RequestContext:
# if additionally provided, extract separate API key to use with guardrailing service
guardrail_service_authorization = None
if (
if request and (
guardrail_authorization
:= extract_guardrail_service_authorization_from_headers(request)
):
View File
+302
View File
@@ -0,0 +1,302 @@
"""Gateway for MCP (Model Context Protocol) integration with Invariant."""
import argparse
import asyncio
import sys
import subprocess
import json
import os
import threading
import signal
from builtins import print as builtins_print
from contextlib import redirect_stdout
from gateway.common.request_context import RequestContext
from gateway.integrations.guardrails import check_guardrails
from gateway.integrations.explorer import (
fetch_guardrails_from_explorer,
)
from gateway.mcp.mcp_context import McpContext
def custom_print(ctx, *args, **kwargs):
"""Custom print function to redirect output to log_out."""
builtins_print(*args, **kwargs, file=ctx.log_out, flush=True)
def append_and_push_trace(ctx, message):
"""
Append a message to the trace if it exists or create a new one
and push it to the Invariant Explorer.
"""
try:
if ctx.trace_id is None:
ctx.trace.append(message)
response = ctx.client.create_request_and_push_trace(
messages=[ctx.trace],
dataset=ctx.explorer_dataset,
metadata=[{"source": "mcp", "tools": ctx.tools}],
)
ctx.trace_id = response.id[0]
ctx.last_trace_length = len(ctx.trace)
else:
ctx.trace.append(message)
ctx.client.create_request_and_append_messages(
trace_id=ctx.trace_id, messages=ctx.trace[ctx.last_trace_length :]
)
ctx.last_trace_length = len(ctx.trace)
except Exception as e:
custom_print(ctx, "Error pushing trace:", e)
def fetch_guardrails(ctx, dataset):
"""Fetch guardrails from the Invariant Explorer."""
# Use async fetch_guardrails_from_explorer in a thread
return asyncio.run(
fetch_guardrails_from_explorer(
dataset, "Bearer " + os.getenv("INVARIANT_API_KEY")
)
)
def check_blocking_guardrails(ctx, message, request):
"""Check against blocking guardrails."""
try:
guardrails = fetch_guardrails(ctx, ctx.explorer_dataset)
custom_print(ctx, "Here are the guardrails: ", guardrails)
context = RequestContext.create(
request_json=request,
dataset_name=ctx.explorer_dataset,
invariant_authorization="Bearer " + os.getenv("INVARIANT_API_KEY"),
guardrails=guardrails,
)
if guardrails.blocking_guardrails:
with redirect_stdout(ctx.log_out):
return asyncio.run(
check_guardrails(
messages=ctx.trace + [message],
guardrails=guardrails.blocking_guardrails,
context=context,
)
)
else:
return {}
except Exception as e:
custom_print(ctx, "Error checking blocking guardrails:", e)
def hook_tool_call(ctx, request):
"""
Hook function to intercept tool calls.
Modify this function to change behavior for tool calls.
Returns the potentially modified request.
"""
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
result = check_blocking_guardrails(ctx, message, request)
append_and_push_trace(ctx, message)
return request
def hook_tool_result(ctx, result):
"""
Hook function to intercept tool results.
Modify this function to change behavior for tool results.
Returns the potentially modified result.
"""
method = ctx.id_to_method_mapping.get(result.get("id"))
call_id = f"call_{result.get('id')}"
if method is None:
return result
elif method == "tools/call":
message = {
"role": "tool",
"content": {"result": result.get("result").get("content")},
"error": result.get("result").get("error"),
"tool_call_id": call_id,
}
# Check for blocking guardrails
guardrailing_result = check_blocking_guardrails(ctx, message, result)
if guardrailing_result and guardrailing_result.get("errors", []):
result["result"]["content"] = [
{
"type": "text",
"text": "[Invariant] Your MCP tool call was blocked for security reasons. Do not attempt to circumvent this block, rather explain to the user based on the following output what went wrong: \n"
+ json.dumps(guardrailing_result["errors"]),
}
]
append_and_push_trace(ctx, message)
return result
elif method == "tools/list":
ctx.tools = result.get("result").get("tools")
return result
else:
return result
def forward_stdout(process, ctx, buffer_size=1):
"""Read from the process stdout, parse JSON chunks, and forward to sys.stdout"""
buffer = b""
while True:
chunk = process.stdout.read(buffer_size)
if not chunk:
break
buffer += chunk
try:
# Try parsing full JSON object from buffer
text = buffer.decode("utf-8")
obj = json.loads(text)
obj = hook_tool_result(ctx, obj)
# clear the buffer
buffer = b""
# Forward the original JSON to stdout
json_output = json.dumps(obj).encode("utf-8") + b"\n"
sys.stdout.buffer.write(json_output)
sys.stdout.buffer.flush()
except (json.JSONDecodeError, UnicodeDecodeError):
# Wait for more data
continue
def forward_stderr(process, ctx, buffer_size=1):
"""Read from the process stderr and write to sys.stderr"""
for line in iter(lambda: process.stderr.read(buffer_size), b""):
ctx.log_out.buffer.write(line)
ctx.log_out.buffer.flush()
def execute(args=None):
"""Main function to execute the MCP gateway."""
if "INVARIANT_API_KEY" not in os.environ:
print("[ERROR] INVARIANT_API_KEY environment variable is not set.")
sys.exit(1)
# Split args at the "--exec" boundary
if args and "--exec" in args:
exec_index = args.index("--exec")
pre_exec_args = args[:exec_index]
post_exec_args = args[exec_index + 1 :]
else:
pre_exec_args = args or []
post_exec_args = []
if not post_exec_args:
print("[ERROR] No command provided after --exec.")
sys.exit(1)
# Parse pre-exec args using argparse
parser = argparse.ArgumentParser(description="MCP Gateway")
parser.add_argument("--directory", help="Working directory")
parser.add_argument("--verbose", action="store_true", help="Enable verbose output")
config = parser.parse_args(pre_exec_args)
# Initialize the singleton context using config
ctx = McpContext()
# Can now use post_exec_args as your cmd
cmd = post_exec_args
process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0, # No buffering
)
# Start threads to forward stdout and stderr
stdout_thread = threading.Thread(
target=forward_stdout, args=(process, ctx), daemon=True
)
stderr_thread = threading.Thread(
target=forward_stderr, args=(process, ctx), daemon=True
)
stdout_thread.start()
stderr_thread.start()
# Handle forwarding stdin and intercept tool calls
try:
current_chunk = b""
while True:
data = sys.stdin.buffer.read(1)
current_chunk += data
if not data:
break
# Try to decode and parse as JSON to check for tool calls
try:
text = current_chunk.decode("utf-8")
obj = json.loads(text)
# clear the current chunk
current_chunk = b""
if obj.get("method") is not None:
ctx.id_to_method_mapping[obj.get("id")] = obj.get("method")
# Check if this is a tool call request
if obj.get("method") == "tools/call":
# Intercept and potentially modify the request
obj = hook_tool_call(ctx, obj)
# Convert back to bytes
data = json.dumps(obj).encode("utf-8")
# Forward to the process
process.stdin.write(data + b"\n")
process.stdin.flush()
continue
else:
process.stdin.write(json.dumps(obj).encode("utf-8") + b"\n")
process.stdin.flush()
continue
except Exception:
# Not a complete or valid JSON, just pass through
pass
except BrokenPipeError:
pass
except KeyboardInterrupt:
process.terminate()
# Wait for process to terminate
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
# Handle signals to ensure clean shutdown
def signal_handler(sig, frame):
"""Handle signals for graceful shutdown."""
ctx = McpContext()
custom_print(ctx, f"Received signal {sig}, shutting down...")
sys.exit(0)
if __name__ == "__main__":
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
execute(sys.argv)
+54
View File
@@ -0,0 +1,54 @@
"""Context manager for MCP (Model Context Protocol) gateway."""
import atexit
import os
import sys
from invariant_sdk.client import Client
class McpContext:
"""Singleton class to manage MCP context and state."""
_instance = None
def __new__(cls):
"""Control instance creation to ensure only one instance exists."""
if cls._instance is None:
cls._instance = super(McpContext, cls).__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
"""Initialize the singleton instance with default values (only once)."""
# Define _initialized attribute explicitly at the beginning to avoid warnings
# This is redundant but prevents warnings about accessing before definition
if not hasattr(self, "_initialized"):
self._initialized = False
if self._initialized:
return
def setup_logging(self):
"""Set up logging to a file in the user's home directory.
Uses proper resource management to ensure the file is closed on program exit.
"""
os.makedirs(
os.path.join(os.path.expanduser("~"), ".invariant"), exist_ok=True
)
log_path = os.path.join(os.path.expanduser("~"), ".invariant", "mcp.log")
self.log_out = open(log_path, "a", buffering=1, encoding="utf-8")
atexit.register(self.log_out.close)
sys.stderr = self.log_out
self.client = Client()
self.explorer_dataset = "mcp-capture"
self.trace = []
self.tools = []
self.trace_id = None
self.last_trace_length = 0
self.guardrails = None
self.id_to_method_mapping = {}
setup_logging(self)
# Mark as initialized
self._initialized = True
+2 -1
View File
@@ -8,13 +8,14 @@ dependencies = [
"fastapi==0.115.7",
"httpx==0.28.1",
"invariant-ai>=0.2.1",
"invariant-sdk>=0.0.10",
"invariant-sdk>=0.0.11",
"starlette-compress==1.4.0",
"uvicorn==0.34.0"
]
[tool.setuptools.packages.find]
where = ["."]
exclude = ["tests", "tests.*"]
[project.scripts]
invariant-gateway = "gateway.__main__:main"