diff --git a/CHANGELOG.md b/CHANGELOG.md index 50a71e3..ca0be1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/vaara/integrations/mcp_proxy.py b/src/vaara/integrations/mcp_proxy.py index a511abf..71288f0 100644 --- a/src/vaara/integrations/mcp_proxy.py +++ b/src/vaara/integrations/mcp_proxy.py @@ -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 @@ -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) @@ -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): @@ -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) @@ -178,6 +236,13 @@ 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", @@ -185,6 +250,8 @@ def main(argv: Optional[list[str]] = None) -> None: 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() diff --git a/tests/test_integrations_mcp_proxy.py b/tests/test_integrations_mcp_proxy.py index 54e9f89..d934d51 100644 --- a/tests/test_integrations_mcp_proxy.py +++ b/tests/test_integrations_mcp_proxy.py @@ -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( @@ -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): @@ -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.