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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht

## [Unreleased]

### Added
- MCP proxy: operator-side tool filtering at the perimeter.
`python -m vaara.integrations.mcp_proxy` now accepts repeatable
`--allow-tool NAME` and `--deny-tool NAME` flags. Filtered tools are
dropped from `tools/list` responses before the client sees them, and
any `tools/call` to a filtered tool is rejected at the proxy with an
MCP `isError: true` payload (`decision: "FILTERED"`,
`reason: "Tool filtered by operator policy"`) without forwarding
upstream or invoking the risk pipeline. Denylist wins on overlap with
allowlist. Backward compatible: no flags = current passthrough
behavior. Use case: hide write/delete tools (e.g. `delete_repository`,
`create_branch`) from an MCP client when the upstream server exposes
more capability than the deployment policy allows.

## [0.21.0] - 2026-05-19

**Theme: MCP-aware proxy.** Adds
Expand Down
67 changes: 67 additions & 0 deletions src/vaara/integrations/mcp_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ def __init__(
pipeline: Optional[InterceptionPipeline] = None,
db_path: Optional[Path] = None,
agent_id_default: str = "mcp-proxy-client",
allowlist: Optional[set[str]] = None,
denylist: Optional[set[str]] = None,
) -> None:
if pipeline is not None:
self._pipeline = pipeline
Expand All @@ -50,12 +52,24 @@ def __init__(
trail = AuditTrail(on_record=self._backend.write_record)
self._pipeline = InterceptionPipeline(trail=trail)
self._agent_id_default = agent_id_default
# An empty allowlist means "no restriction" (None and empty set are equivalent
# here); a non-empty allowlist restricts to exactly those names. Denylist
# always subtracts. When both are set, denylist wins on overlap.
self._allowlist: Optional[set[str]] = set(allowlist) if allowlist else None
self._denylist: set[str] = set(denylist) if denylist else set()
self._stdout_lock = threading.Lock()
self._upstream = UpstreamMCPClient(
command=upstream_command,
on_notification=self._forward_notification_to_client,
)

def _tool_filtered(self, name: str) -> bool:
if name in self._denylist:
return True
if self._allowlist is not None and name not in self._allowlist:
return True
return False

def run(self) -> None:
"""Read JSON-RPC from stdin, write to stdout, route through upstream."""
logger.info("Vaara MCP proxy starting on stdio (%s)", self.PROXY_NAME)
Expand Down Expand Up @@ -90,11 +104,38 @@ def _handle_request(self, request: Any) -> dict:
except Exception:
logger.exception("Error in tools/call interception")
return self._error_response(req_id, -32603, "Internal proxy error")
if method == "tools/list":
try:
return self._handle_tools_list(request)
except ProxyError as e:
return self._error_response(req_id, -32603, f"Upstream unavailable: {e}")
try:
return self._upstream.request(request)
except ProxyError as e:
return self._error_response(req_id, -32603, f"Upstream unavailable: {e}")

def _handle_tools_list(self, request: dict) -> dict:
response = self._upstream.request(request)
if not (self._denylist or self._allowlist is not None):
return response
if not isinstance(response, dict) or "result" not in response:
return response
result = response.get("result")
if not isinstance(result, dict):
return response
tools = result.get("tools")
if not isinstance(tools, list):
return response
filtered = [
t for t in tools
if isinstance(t, dict) and not self._tool_filtered(t.get("name", ""))
]
# Mutate a shallow copy so the upstream response object the reader
# parked is not aliased into the client-facing payload.
new_result = dict(result)
new_result["tools"] = filtered
return {**response, "result": new_result}

def _handle_tools_call(self, request: dict) -> dict:
params = request.get("params") or {}
if not isinstance(params, dict):
Expand All @@ -103,6 +144,23 @@ def _handle_tools_call(self, request: dict) -> dict:
arguments = params.get("arguments", {}) or {}
if not isinstance(arguments, dict):
arguments = {}
if self._tool_filtered(tool_name):
logger.warning(
"tools/call rejected at perimeter (operator filter): %s", tool_name,
)
block_payload = {
"vaara_blocked": True,
"reason": "Tool filtered by operator policy",
"decision": "FILTERED",
"tool": tool_name,
}
return {
"jsonrpc": "2.0", "id": request.get("id"),
"result": {
"content": [{"type": "text", "text": strict_json_dumps(block_payload, indent=2)}],
"isError": True,
},
}
# _vaara_agent_id is a proxy-side override for audit attribution;
# strip before forwarding so the upstream never sees Vaara metadata.
agent_id = arguments.pop("_vaara_agent_id", self._agent_id_default)
Expand Down Expand Up @@ -178,13 +236,22 @@ def main(argv: Optional[list[str]] = None) -> None:
help="Audit database path (default: $VAARA_DB or ./vaara_audit.db)")
parser.add_argument("--agent-id", default="mcp-proxy-client",
help="Default agent_id for the audit trail")
parser.add_argument("--allow-tool", action="append", default=[], dest="allow_tools",
help="Only expose this tool name (repeatable). If any --allow-tool "
"is given, all other upstream tools are filtered from tools/list "
"and rejected at tools/call.")
parser.add_argument("--deny-tool", action="append", default=[], dest="deny_tools",
help="Filter this tool name from tools/list and reject any tools/call "
"to it (repeatable). Denylist wins on overlap with --allow-tool.")
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(name)s %(levelname)s %(message)s",
stream=sys.stderr)
proxy = VaaraMCPProxy(
upstream_command=[args.upstream, *args.upstream_args],
db_path=args.db, agent_id_default=args.agent_id,
allowlist=set(args.allow_tools) if args.allow_tools else None,
denylist=set(args.deny_tools) if args.deny_tools else None,
)
try:
proxy.run()
Expand Down
123 changes: 118 additions & 5 deletions tests/test_integrations_mcp_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ def proxy(monkeypatch):
return p, pipeline


def _make_proxy(monkeypatch, **kwargs):
from vaara.integrations import mcp_proxy

monkeypatch.setattr(mcp_proxy, "UpstreamMCPClient", MagicMock())
pipeline = MagicMock()
p = mcp_proxy.VaaraMCPProxy(upstream_command=["echo"], pipeline=pipeline, **kwargs)
p._upstream = MagicMock()
return p, pipeline


def test_blocked_tool_call_returns_mcp_tool_error(proxy):
p, pipeline = proxy
pipeline.intercept.return_value = _StubInterceptResult(
Expand Down Expand Up @@ -119,16 +129,17 @@ def test_vaara_agent_id_override_is_stripped_before_forward(proxy):
assert "_vaara_agent_id" not in forwarded["params"]["arguments"]


def test_non_tools_call_forwards_verbatim(proxy):
def test_non_intercepted_method_forwards_verbatim(proxy):
p, pipeline = proxy
p._upstream.request.return_value = {
"jsonrpc": "2.0", "id": 3, "result": {"tools": [{"name": "sap.adt.read"}]},
upstream_response = {
"jsonrpc": "2.0", "id": 3, "result": {"resources": []},
}
request = {"jsonrpc": "2.0", "id": 3, "method": "tools/list"}
p._upstream.request.return_value = upstream_response
request = {"jsonrpc": "2.0", "id": 3, "method": "resources/list"}
response = p._handle_request(request)
p._upstream.request.assert_called_once_with(request)
pipeline.intercept.assert_not_called()
assert response["result"]["tools"][0]["name"] == "sap.adt.read"
assert response is upstream_response


def test_invalid_request_returns_minus_32600(proxy):
Expand All @@ -137,6 +148,108 @@ def test_invalid_request_returns_minus_32600(proxy):
assert response["error"]["code"] == -32600


def test_tools_list_denylist_drops_named_tools(monkeypatch):
p, pipeline = _make_proxy(monkeypatch, denylist={"delete_repository", "create_branch"})
p._upstream.request.return_value = {
"jsonrpc": "2.0", "id": 11,
"result": {"tools": [
{"name": "search_repositories"},
{"name": "create_branch"},
{"name": "get_pull_request"},
{"name": "delete_repository"},
]},
}
response = p._handle_request({"jsonrpc": "2.0", "id": 11, "method": "tools/list"})
names = [t["name"] for t in response["result"]["tools"]]
assert names == ["search_repositories", "get_pull_request"]
pipeline.intercept.assert_not_called()


def test_tools_list_allowlist_restricts_to_listed_tools(monkeypatch):
p, _ = _make_proxy(monkeypatch, allowlist={"search_repositories", "get_pull_request"})
p._upstream.request.return_value = {
"jsonrpc": "2.0", "id": 12,
"result": {"tools": [
{"name": "search_repositories"},
{"name": "create_branch"},
{"name": "get_pull_request"},
]},
}
response = p._handle_request({"jsonrpc": "2.0", "id": 12, "method": "tools/list"})
names = sorted(t["name"] for t in response["result"]["tools"])
assert names == ["get_pull_request", "search_repositories"]


def test_tools_list_denylist_wins_when_overlapping_with_allowlist(monkeypatch):
p, _ = _make_proxy(
monkeypatch,
allowlist={"search_repositories", "create_branch"},
denylist={"create_branch"},
)
p._upstream.request.return_value = {
"jsonrpc": "2.0", "id": 13,
"result": {"tools": [
{"name": "search_repositories"},
{"name": "create_branch"},
]},
}
response = p._handle_request({"jsonrpc": "2.0", "id": 13, "method": "tools/list"})
names = [t["name"] for t in response["result"]["tools"]]
assert names == ["search_repositories"]


def test_tools_list_no_policy_returns_upstream_response_unchanged(monkeypatch):
p, _ = _make_proxy(monkeypatch)
upstream_response = {
"jsonrpc": "2.0", "id": 14,
"result": {"tools": [{"name": "a"}, {"name": "b"}]},
}
p._upstream.request.return_value = upstream_response
response = p._handle_request({"jsonrpc": "2.0", "id": 14, "method": "tools/list"})
assert response is upstream_response


def test_tools_call_on_denylisted_tool_returns_filter_block(monkeypatch):
p, pipeline = _make_proxy(monkeypatch, denylist={"delete_repository"})
request = {
"jsonrpc": "2.0", "id": 15, "method": "tools/call",
"params": {"name": "delete_repository", "arguments": {"owner": "vaaraio", "repo": "vaara"}},
}
response = p._handle_tools_call(request)
assert response["result"]["isError"] is True
text = response["result"]["content"][0]["text"]
assert "Tool filtered by operator policy" in text
assert "FILTERED" in text
p._upstream.request.assert_not_called()
pipeline.intercept.assert_not_called()
pipeline.report_outcome.assert_not_called()


def test_tools_call_outside_allowlist_returns_filter_block(monkeypatch):
p, pipeline = _make_proxy(monkeypatch, allowlist={"search_repositories"})
request = {
"jsonrpc": "2.0", "id": 16, "method": "tools/call",
"params": {"name": "create_branch", "arguments": {}},
}
response = p._handle_tools_call(request)
assert response["result"]["isError"] is True
assert "Tool filtered by operator policy" in response["result"]["content"][0]["text"]
pipeline.intercept.assert_not_called()


def test_tools_call_inside_allowlist_still_runs_pipeline(monkeypatch):
p, pipeline = _make_proxy(monkeypatch, allowlist={"search_repositories"})
pipeline.intercept.return_value = _StubInterceptResult(allowed=True, action_id="allow-1")
p._upstream.request.return_value = {"jsonrpc": "2.0", "id": 17, "result": {}}
request = {
"jsonrpc": "2.0", "id": 17, "method": "tools/call",
"params": {"name": "search_repositories", "arguments": {"q": "vaara"}},
}
p._handle_tools_call(request)
pipeline.intercept.assert_called_once()
p._upstream.request.assert_called_once_with(request)


def test_upstream_request_raises_proxy_error_when_reader_exits_without_response(monkeypatch):
"""Regression: reader-thread exit during request must raise ProxyError, not AssertionError.

Expand Down