Skip to content

Commit c979fc5

Browse files
committed
feat: filter MCP tools by available UI extensions on client
Add available_tool_ui_ids field to LLMRequest so clients can report which console UI extensions are installed. Tools declaring an olsUi.id in their metadata are excluded when the corresponding extension is not available, preventing the LLM from calling tools the client cannot render. Signed-off-by: Tomáš Remeš <tremes@redhat.com> Assisted-by: Claude Code:claude-opus-4-6
1 parent ed8fdcd commit c979fc5

6 files changed

Lines changed: 136 additions & 1 deletion

File tree

ols/app/endpoints/ols.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,7 @@ def generate_response(
540540
client_headers=client_headers,
541541
streaming=streaming,
542542
audit_ctx=audit_ctx,
543+
available_tool_ui_ids=llm_request.available_tool_ui_ids,
543544
)
544545
if streaming:
545546
return docs_summarizer.generate_response(

ols/app/models/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ class LLMRequest(BaseModel):
9292
media_type: Optional[str] = MEDIA_TYPE_TEXT
9393
mcp_headers: Optional[dict[str, dict[str, str]]] = None
9494
mode: QueryMode = QueryMode.ASK
95+
available_tool_ui_ids: Optional[list[str]] = Field(default=None, max_length=256)
9596

9697
# provides examples for /docs endpoint
9798
model_config = {

ols/src/query_helpers/docs_summarizer.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,12 @@
3131
from ols.src.skills.skills_rag import create_skill_support_tool
3232
from ols.src.tools.offloaded_content import OffloadManager
3333
from ols.utils.audit_logger import AuditContext
34-
from ols.utils.mcp_utils import ClientHeaders, build_mcp_config, get_mcp_tools
34+
from ols.utils.mcp_utils import (
35+
ClientHeaders,
36+
build_mcp_config,
37+
filter_tools_by_available_ui,
38+
get_mcp_tools,
39+
)
3540
from ols.utils.token_handler import (
3641
PromptTooLongError,
3742
TokenBudgetTracker,
@@ -63,6 +68,7 @@ def __init__(
6368
client_headers: ClientHeaders | None = None,
6469
streaming: bool = False,
6570
audit_ctx: Optional[AuditContext] = None,
71+
available_tool_ui_ids: Optional[list[str]] = None,
6672
**kwargs: object,
6773
) -> None:
6874
"""Initialize the DocsSummarizer.
@@ -72,6 +78,7 @@ def __init__(
7278
client_headers: Optional client-provided MCP headers for authentication
7379
streaming: Whether this summarizer is used for the streaming endpoint
7480
audit_ctx: Audit context for structured event logging
81+
available_tool_ui_ids: Tool UI extension IDs available on the client
7582
*args: Additional positional arguments passed to the parent class
7683
**kwargs: Additional keyword arguments passed to the parent class
7784
"""
@@ -80,6 +87,7 @@ def __init__(
8087
self._prepare_llm()
8188
self.verbose = config.ols_config.logging_config.app_log_level == logging.DEBUG
8289
self.streaming = streaming
90+
self.available_tool_ui_ids = available_tool_ui_ids
8391
self._cluster_version = (
8492
K8sClientSingleton.get_cluster_version()
8593
if self._mode == constants.QueryMode.TROUBLESHOOTING
@@ -423,6 +431,9 @@ async def generate_response( # noqa: C901 # pylint: disable=too-many-branches,
423431
all_mcp_tools = await get_mcp_tools(
424432
mcp_tools_query, self.user_token, self.client_headers
425433
)
434+
all_mcp_tools = filter_tools_by_available_ui(
435+
all_mcp_tools, self.available_tool_ui_ids
436+
)
426437
if skill is not None and skill_content is not None and has_support_files:
427438
all_mcp_tools.append(create_skill_support_tool(skill))
428439
tool_definitions_text = self._serialized_tool_definitions_text(all_mcp_tools)

ols/utils/mcp_utils.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,48 @@ def _normalize_tool_schema(tool: StructuredTool) -> None:
176176
schema.setdefault("required", [])
177177

178178

179+
def filter_tools_by_available_ui(
180+
tools: list[StructuredTool],
181+
available_ui_ids: list[str] | None,
182+
) -> list[StructuredTool]:
183+
"""Filter out tools whose UI extension is not available on the client.
184+
185+
Tools that declare an ``olsUi.id`` in their ``_meta`` metadata are only
186+
useful when the corresponding console extension is installed. When the
187+
client provides the list of available extension IDs, tools whose
188+
``olsUi.id`` is not in that list are excluded so the LLM never calls them.
189+
190+
Args:
191+
tools: All resolved MCP tools.
192+
available_ui_ids: Extension IDs the client can render, or ``None``
193+
to skip filtering entirely (backward compatibility).
194+
195+
Returns:
196+
Filtered list of tools.
197+
"""
198+
if available_ui_ids is None:
199+
return tools
200+
201+
available_set = set(available_ui_ids)
202+
filtered: list[StructuredTool] = []
203+
for tool in tools:
204+
meta = (
205+
(tool.metadata or {}).get("_meta")
206+
if isinstance(tool.metadata, dict)
207+
else None
208+
)
209+
ols_ui_id = meta.get("olsUi", {}).get("id") if isinstance(meta, dict) else None
210+
if ols_ui_id is not None and ols_ui_id not in available_set:
211+
logger.info(
212+
"Excluding tool '%s': olsUi.id '%s' not in available_tool_ui_ids",
213+
tool.name,
214+
ols_ui_id,
215+
)
216+
continue
217+
filtered.append(tool)
218+
return filtered
219+
220+
179221
async def gather_mcp_tools(
180222
mcp_servers: MCPServersDict, allowed_tool_names: Optional[set[str]] = None
181223
) -> list[StructuredTool]:

tests/unit/app/models/test_models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,18 @@ def test_llm_request_optional_inputs():
6565
assert llm_request.provider == provider
6666
assert llm_request.model == model
6767

68+
@staticmethod
69+
def test_llm_request_available_tool_ui_ids():
70+
"""Test available_tool_ui_ids field on LLMRequest."""
71+
request = LLMRequest(query="test")
72+
assert request.available_tool_ui_ids is None
73+
74+
request = LLMRequest(query="test", available_tool_ui_ids=["perses-dashboard"])
75+
assert request.available_tool_ui_ids == ["perses-dashboard"]
76+
77+
request = LLMRequest(query="test", available_tool_ui_ids=[])
78+
assert request.available_tool_ui_ids == []
79+
6880
@staticmethod
6981
def test_llm_request_provider_and_model():
7082
"""Test the LLMRequest model with provider and model."""

tests/unit/utils/test_mcp_utils.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from ols.app.models.config import MCPServerConfig
1010
from ols.utils.mcp_utils import (
1111
build_mcp_config,
12+
filter_tools_by_available_ui,
1213
gather_mcp_tools,
1314
get_mcp_tools,
1415
get_servers_requiring_client_headers,
@@ -647,3 +648,70 @@ async def test_no_servers_configured(self):
647648
result = await get_mcp_tools("test query")
648649

649650
assert result == []
651+
652+
653+
class TestFilterToolsByAvailableUI:
654+
"""Tests for filter_tools_by_available_ui function."""
655+
656+
@staticmethod
657+
def _make_tool(name: str, metadata: dict | None = None) -> MagicMock:
658+
tool = MagicMock(spec=StructuredTool)
659+
tool.name = name
660+
tool.metadata = metadata
661+
return tool
662+
663+
def test_none_available_ids_returns_all(self):
664+
"""When available_ui_ids is None, all tools pass through."""
665+
tool_with_ui = self._make_tool(
666+
"perses", {"_meta": {"olsUi": {"id": "perses-dashboard"}}}
667+
)
668+
tool_without_ui = self._make_tool("kubectl", {"mcp_server": "k8s"})
669+
result = filter_tools_by_available_ui([tool_with_ui, tool_without_ui], None)
670+
assert len(result) == 2
671+
672+
def test_empty_list_excludes_tools_with_ui_id(self):
673+
"""Empty available list excludes tools that declare olsUi.id."""
674+
tool_with_ui = self._make_tool(
675+
"perses", {"_meta": {"olsUi": {"id": "perses-dashboard"}}}
676+
)
677+
tool_without_ui = self._make_tool("kubectl", {"mcp_server": "k8s"})
678+
result = filter_tools_by_available_ui([tool_with_ui, tool_without_ui], [])
679+
assert len(result) == 1
680+
assert result[0].name == "kubectl"
681+
682+
def test_matching_id_passes(self):
683+
"""Tool with matching olsUi.id passes the filter."""
684+
tool = self._make_tool(
685+
"perses", {"_meta": {"olsUi": {"id": "perses-dashboard"}}}
686+
)
687+
result = filter_tools_by_available_ui([tool], ["perses-dashboard"])
688+
assert len(result) == 1
689+
assert result[0].name == "perses"
690+
691+
def test_non_matching_id_excluded(self):
692+
"""Tool with non-matching olsUi.id is excluded."""
693+
tool = self._make_tool(
694+
"perses", {"_meta": {"olsUi": {"id": "perses-dashboard"}}}
695+
)
696+
result = filter_tools_by_available_ui([tool], ["other-ui"])
697+
assert len(result) == 0
698+
699+
def test_tool_without_meta_passes(self):
700+
"""Tool without _meta always passes."""
701+
tool = self._make_tool("kubectl", {"mcp_server": "k8s"})
702+
result = filter_tools_by_available_ui([tool], [])
703+
assert len(result) == 1
704+
705+
def test_tool_with_none_metadata_passes(self):
706+
"""Tool with None metadata always passes."""
707+
tool = self._make_tool("kubectl", None)
708+
result = filter_tools_by_available_ui([tool], [])
709+
assert len(result) == 1
710+
711+
def test_tool_with_meta_but_no_ols_ui_passes(self):
712+
"""Tool with _meta but no olsUi field always passes."""
713+
tool = self._make_tool(
714+
"kubectl", {"_meta": {"ui": {"resourceUri": "ui://foo"}}}
715+
)
716+
result = filter_tools_by_available_ui([tool], [])
717+
assert len(result) == 1

0 commit comments

Comments
 (0)