Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,40 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht

## [Unreleased]

## [0.21.0] - 2026-05-19

**Theme: MCP-aware proxy.** Adds
`vaara.integrations.mcp_proxy.VaaraMCPProxy`, a transparent MCP proxy
that sits between an MCP client (Claude Code, Cursor, any MCP-capable
host) and an upstream MCP server (SAP ADT MCP, SAP Graph API MCP, SAP
Cloud ALM MCP, any community-built MCP server). Every `tools/call`
request from the client routes through Vaara's interception pipeline
before reaching the upstream. Allowed calls forward transparently and
report the upstream outcome back to the scorer. Blocked calls return
an MCP `isError: true` response with the block reason. Other MCP
methods (initialize, tools/list, resources, notifications) forward
unchanged.

### Added
- `src/vaara/integrations/mcp_proxy.py`: `VaaraMCPProxy` and CLI entry
point. Invoke as `python -m vaara.integrations.mcp_proxy --upstream
<cmd> [--upstream-arg ...]`.
- `src/vaara/integrations/_mcp_upstream.py`: `UpstreamMCPClient`,
subprocess lifecycle plus JSON-RPC id demultiplexing on a background
reader thread. Internal module, not part of the public surface.
- `tests/test_integrations_mcp_proxy.py`: six smoke tests covering
blocked tool calls, allowed forward, severity mapping, the
`_vaara_agent_id` strip, non-tools/call passthrough, and invalid
request handling.

### Strategic frame
The community SAP MCP servers shipped at SAP Sapphire 2026 plus the
Anthropic-SAP partnership announcement put SAP ABAP / Graph / Cloud
ALM behind Claude Code in enterprise developer workflows. None of the
parties (SAP, Anthropic, the community MCP server authors) ships the
runtime governance layer the EU AI Act high-risk obligations require
for those tool calls. The proxy is that layer in OSS today.

## [0.20.0] - 2026-05-18

**Theme: OSS guardrail adapters.** Adds four adapters that take findings
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

<p align="center">
<a href="https://pypi.org/project/vaara/"><img src="https://img.shields.io/pypi/v/vaara.svg" alt="PyPI"></a>
<a href="https://pypi.org/project/vaara/"><img src="https://img.shields.io/pypi/pyversions/vaara.svg" alt="Python"></a>
<a href="https://github.com/vaaraio/vaara/blob/main/LICENSE"><img src="https://img.shields.io/pypi/l/vaara.svg" alt="License"></a>
<a href="https://github.com/vaaraio/vaara/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/vaaraio/vaara/ci.yml?branch=main&label=tests" alt="CI"></a>
<a href="https://scorecard.dev/viewer/?uri=github.com/vaaraio/vaara"><img src="https://api.scorecard.dev/projects/github.com/vaaraio/vaara/badge" alt="OpenSSF Scorecard"></a>
Expand Down Expand Up @@ -113,6 +112,16 @@ Four adapters route findings from NVIDIA NeMo Guardrails, Guardrails AI, LLM Gua

Each adapter returns a `ContentSafetyFinding` the deployer routes into `pipeline.intercept(context=finding.to_audit_context())`. OSS SDKs are optional extras: `pip install 'vaara[nemo-guardrails]'`, `pip install 'vaara[guardrails-ai]'`, `pip install 'vaara[llm-guard]'`, `pip install 'vaara[rebuff]'`. The 41 new mapping rows extend the same table at `src/vaara/integrations/_content_safety_articles.py`. Article-level rationale is in [COMPLIANCE.md](COMPLIANCE.md#oss-guardrail-adapter-pattern).

### MCP proxy (Vaara as a transparent governance layer)

`vaara.integrations.mcp_proxy.VaaraMCPProxy` sits between an MCP client (Claude Code, Cursor, any MCP-capable host) and an upstream MCP server (SAP ADT MCP, SAP Graph API MCP, SAP Cloud ALM MCP, any community-built MCP server). Every `tools/call` request from the client routes through Vaara's interception pipeline before reaching the upstream. Allowed calls forward transparently and report the upstream outcome back to the scorer. Blocked calls return an MCP `isError: true` response with the block reason. Other MCP methods (initialize, tools/list, resources, notifications) forward unchanged.

```bash
python -m vaara.integrations.mcp_proxy --upstream npx --upstream-arg @sap/adt-mcp-server
```

Point your MCP client at the proxy instead of the upstream. The audit chain captures every tool call without changing client or upstream behavior. Distinct from `mcp_server`, which exposes Vaara itself as an MCP server for agents that consult Vaara as a tool.

## HTTP API

The same scorer and audit trail are available over HTTP for non-Python agents and for control planes that prefer a network boundary. Install with the `server` extra:
Expand Down
2 changes: 1 addition & 1 deletion clients/ts/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@vaara/client",
"version": "0.20.0",
"version": "0.21.0",
"description": "TypeScript client for the Vaara HTTP API. Conformal risk scoring, hash-chained audit, policy reload, named detectors.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "vaara"
version = "0.20.0"
version = "0.21.0"
description = "Adaptive AI Agent Execution Layer for risk scoring, audit trails, and regulatory compliance"
requires-python = ">=3.10"
license = "Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion src/vaara/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
oversight.
"""

__version__ = "0.20.0"
__version__ = "0.21.0"

from vaara.pipeline import InterceptionPipeline, InterceptionResult

Expand Down
187 changes: 187 additions & 0 deletions src/vaara/integrations/_mcp_upstream.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
"""Upstream MCP subprocess client for the proxy.

Owns the subprocess lifecycle of an upstream MCP server, demuxes responses
by JSON-RPC id, and routes notifications to a callback for the proxy to
forward downstream.

Internal module. Public surface is :class:`vaara.integrations.mcp_proxy.VaaraMCPProxy`.
"""

from __future__ import annotations

import json
import logging
import os
import subprocess
import sys
import threading
from dataclasses import dataclass
from typing import Any, Callable, Optional

logger = logging.getLogger(__name__)


class ProxyError(Exception):
"""The proxy itself cannot serve a request.

Distinct from upstream-emitted JSON-RPC errors, which are forwarded
verbatim. ProxyError is raised when the proxy-side machinery fails
(upstream subprocess crashed, stdin write failed, response timeout)
and the caller should surface JSON-RPC -32603 Internal error downstream.
"""


def strict_json_dumps(obj: Any, **kwargs: Any) -> str:
"""JSON dump that fails on NaN/Infinity (RFC 8259 strict).

Python's default ``json.dumps`` emits ``NaN``/``Infinity``/``-Infinity``
literals that strict JSON parsers (Go, Rust, browsers, many MCP clients)
reject. Forcing strict output surfaces escaped non-finite values loudly
in tests rather than silently emitting invalid wire format.
"""
return json.dumps(obj, allow_nan=False, **kwargs)


@dataclass
class _UpstreamRequest:
id: Any
event: threading.Event
response: Optional[dict] = None


class UpstreamMCPClient:
"""Spawn an upstream MCP server and communicate over its stdio.

The reader runs on a background thread that parks responses keyed by
JSON-RPC id and routes notifications to ``on_notification``. The main
thread synchronously calls :meth:`request` and waits.
"""

def __init__(
self,
command: list[str],
env: Optional[dict[str, str]] = None,
on_notification: Optional[Callable[[dict], None]] = None,
) -> None:
self._on_notification = on_notification
self._pending: dict[Any, _UpstreamRequest] = {}
self._lock = threading.Lock()
self._closed = False

# stderr passes through so upstream logs surface in the proxy's
# stderr without contaminating the JSON-RPC channel on stdout.
self._proc = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=sys.stderr,
env=env or os.environ.copy(),
bufsize=1,
text=True,
)

self._reader_thread = threading.Thread(
target=self._read_loop, daemon=True, name="upstream-reader",
)
self._reader_thread.start()

def request(self, payload: dict, timeout: float = 30.0) -> dict:
"""Send a request, wait for the matching response by id.

Raises :class:`ProxyError` if the upstream has died or the response
does not arrive within ``timeout``.
"""
if self._closed:
raise ProxyError("Upstream MCP server is closed")
if "id" not in payload:
raise ValueError("request() requires a JSON-RPC id; use notify() for notifications")

pending = _UpstreamRequest(id=payload["id"], event=threading.Event())
with self._lock:
self._pending[payload["id"]] = pending
try:
self._write(payload)
if not pending.event.wait(timeout=timeout):
raise ProxyError(f"Upstream MCP server did not respond within {timeout}s")
# event was set but response stays None when the reader thread
# exited (upstream closed stdout) and woke us as a shutdown signal.
# An assert would either raise AssertionError (escapes the caller's
# ProxyError handler) or be optimized out under -O (return None,
# silently breaking the contract). Raise ProxyError explicitly.
if pending.response is None:
raise ProxyError("Upstream MCP server closed before responding")
if not isinstance(pending.response, dict):
raise ProxyError("Upstream MCP server returned non-object JSON-RPC")
return pending.response
Comment on lines +104 to +115
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Return ProxyError on upstream shutdown instead of asserting.

If the reader thread exits, it wakes pending requests without a response. This path hits the assert at Line 106, which can raise AssertionError (or be removed with -O) and escape caller error handling. Convert this to an explicit ProxyError when no response is available.

💡 Proposed fix
         try:
             self._write(payload)
             if not pending.event.wait(timeout=timeout):
                 raise ProxyError(f"Upstream MCP server did not respond within {timeout}s")
-            assert pending.response is not None
-            return pending.response
+            if pending.response is None:
+                raise ProxyError("Upstream MCP server closed before responding")
+            if not isinstance(pending.response, dict):
+                raise ProxyError("Upstream MCP server returned non-object JSON-RPC")
+            return pending.response
         finally:
             with self._lock:
                 self._pending.pop(payload["id"], None)

Also applies to: 159-164

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vaara/integrations/_mcp_upstream.py` around lines 104 - 107, The code
currently uses "assert pending.response is not None" after "if not
pending.event.wait(timeout=timeout):" which can raise AssertionError and bypass
caller error handling; change this to explicitly raise ProxyError when
pending.response is None (e.g., raise ProxyError("Upstream MCP server shut down
or closed connection before responding")) so callers get a predictable
ProxyError instead of AssertionError; apply the same replacement in the other
similar block that checks pending.response (the second occurrence around the
159-164 area) and include any useful context (timeout value or request id) in
the error message if available.

finally:
with self._lock:
self._pending.pop(payload["id"], None)

def notify(self, payload: dict) -> None:
"""Send a JSON-RPC notification (no response expected)."""
if self._closed:
return
self._write(payload)

def _write(self, payload: dict) -> None:
if self._proc.stdin is None:
raise ProxyError("Upstream MCP server stdin is closed")
try:
self._proc.stdin.write(strict_json_dumps(payload) + "\n")
self._proc.stdin.flush()
except (BrokenPipeError, OSError) as e:
raise ProxyError(f"Upstream MCP server stdin write failed: {e}") from e

def _read_loop(self) -> None:
if self._proc.stdout is None:
return
for line in self._proc.stdout:
line = line.strip()
if not line:
continue
try:
message = json.loads(line)
except json.JSONDecodeError:
logger.warning("Upstream emitted non-JSON line: %r", line[:200])
continue

# Notifications (no id) route to the callback for downstream forward.
if isinstance(message, dict) and "id" not in message:
if self._on_notification is not None:
try:
self._on_notification(message)
except Exception:
logger.exception("Notification handler raised")
continue

# Responses demux by id.
response_id = message.get("id") if isinstance(message, dict) else None
with self._lock:
pending = self._pending.get(response_id)
if pending is None:
logger.warning("Upstream response for unknown id %r", response_id)
continue
pending.response = message
pending.event.set()

# Reader exited: upstream closed stdout. Wake all waiters so they
# fail with ProxyError rather than hanging forever.
self._closed = True
with self._lock:
for pending in self._pending.values():
pending.event.set()

def close(self) -> None:
self._closed = True
try:
if self._proc.stdin is not None:
self._proc.stdin.close()
except Exception:

Check notice

Code scanning / CodeQL

Empty except Note

'except' clause does nothing but pass and there is no explanatory comment.
pass
try:
self._proc.terminate()
self._proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self._proc.kill()
except Exception:

Check notice

Code scanning / CodeQL

Empty except Note

'except' clause does nothing but pass and there is no explanatory comment.
pass
Loading
Loading