197 lines
6.1 KiB
Python
197 lines
6.1 KiB
Python
"""LLM-based MCP tool routing.
|
|
|
|
The router receives only the user question and the discovered MCP tool
|
|
descriptors. It never receives MCP bearer tokens or provider credentials.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import json
|
|
from typing import Any, Mapping
|
|
|
|
from ai_web_agent_console.oci_genai_sdk import (
|
|
build_oci_genai_completion_client,
|
|
temperature_for_model_profile,
|
|
)
|
|
from ai_web_agent_console.model_registry import resolve_model_profile
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class McpTool:
|
|
name: str
|
|
description: str
|
|
schema: Mapping[str, Any]
|
|
read_only: bool
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RoutedMcpTool:
|
|
server_id: str
|
|
tool: McpTool
|
|
|
|
|
|
class McpToolRouterError(RuntimeError):
|
|
"""Safe routing error. Must not contain secrets or provider traces."""
|
|
|
|
|
|
def route_mcp_tool_across_servers_with_llm(
|
|
tools: list[RoutedMcpTool],
|
|
question: str,
|
|
*,
|
|
router_model_profile: str,
|
|
) -> RoutedMcpTool:
|
|
"""Select one discovered MCP server/tool pair with OCI GenAI."""
|
|
|
|
candidates = list(tools)
|
|
if not candidates:
|
|
raise McpToolRouterError("라우팅 가능한 MCP tool이 없습니다.")
|
|
|
|
by_key = {
|
|
"{}::{}".format(candidate.server_id, candidate.tool.name): candidate
|
|
for candidate in candidates
|
|
}
|
|
route_keys = list(by_key)
|
|
try:
|
|
profile = resolve_model_profile(router_model_profile)
|
|
client = build_oci_genai_completion_client(
|
|
profile.model_id,
|
|
profile.answer_model_region,
|
|
profile.answer_model_endpoint,
|
|
)
|
|
tool_catalog = [
|
|
{
|
|
"route_key": "{}::{}".format(candidate.server_id, candidate.tool.name),
|
|
"server_id": candidate.server_id,
|
|
"tool_name": candidate.tool.name,
|
|
"description": candidate.tool.description[:1000],
|
|
"input_properties": sorted(
|
|
(
|
|
candidate.tool.schema.get("properties", {})
|
|
if isinstance(
|
|
candidate.tool.schema.get("properties"), Mapping
|
|
)
|
|
else {}
|
|
).keys()
|
|
),
|
|
"read_only": candidate.tool.read_only,
|
|
}
|
|
for candidate in candidates
|
|
]
|
|
text = client.complete(
|
|
system_prompt=(
|
|
"You are an MCP server and tool router. Choose exactly one "
|
|
"server/tool route for the user question from the discovered "
|
|
"routes. Return only JSON that matches the schema. Never "
|
|
"request or expose bearer tokens. Do not invent server ids or "
|
|
"tool names."
|
|
),
|
|
user_prompt=json.dumps(
|
|
{
|
|
"question": question,
|
|
"routes": tool_catalog,
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
response_schema={
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": ["route_key"],
|
|
"properties": {
|
|
"route_key": {
|
|
"type": "string",
|
|
"enum": route_keys,
|
|
}
|
|
},
|
|
},
|
|
max_tokens=256,
|
|
temperature=temperature_for_model_profile(profile),
|
|
)
|
|
routed = json.loads(text)
|
|
except Exception:
|
|
raise McpToolRouterError("LLM tool router 호출에 실패했습니다.") from None
|
|
|
|
if not isinstance(routed, Mapping):
|
|
raise McpToolRouterError("LLM tool router 응답 형식이 올바르지 않습니다.")
|
|
selected_key = str(routed.get("route_key") or "").strip()
|
|
selected = by_key.get(selected_key)
|
|
if selected is None:
|
|
raise McpToolRouterError("LLM tool router가 허용되지 않은 route를 선택했습니다.")
|
|
return selected
|
|
|
|
|
|
def route_mcp_tool_with_llm(
|
|
tools: list[McpTool],
|
|
question: str,
|
|
*,
|
|
preferred_tool: str,
|
|
tool_allowlist: tuple[str, ...],
|
|
router_model_profile: str,
|
|
) -> McpTool:
|
|
"""Backward-compatible single-server routing helper."""
|
|
|
|
candidates = [
|
|
tool for tool in tools if not tool_allowlist or tool.name in tool_allowlist
|
|
]
|
|
routed = route_mcp_tool_across_servers_with_llm(
|
|
[RoutedMcpTool(server_id="default", tool=tool) for tool in candidates],
|
|
question,
|
|
router_model_profile=router_model_profile,
|
|
)
|
|
return routed.tool
|
|
|
|
|
|
def build_mcp_tool_arguments(
|
|
tool: McpTool,
|
|
question: str,
|
|
limit: int,
|
|
*,
|
|
preferred_tool: str,
|
|
) -> dict[str, Any]:
|
|
"""Build bounded tool arguments from the selected tool schema."""
|
|
|
|
properties = tool.schema.get("properties")
|
|
if not isinstance(properties, Mapping):
|
|
properties = {}
|
|
|
|
# A server's default/preferred tool still has to obey its discovered schema.
|
|
# HMM tools use `query` and `term`; forcing the legacy `prompt`/`limit` shape
|
|
# makes an otherwise valid tool fail argument validation.
|
|
del preferred_tool
|
|
input_name = next(
|
|
(name for name in ("prompt", "question", "query", "term", "text") if name in properties),
|
|
"",
|
|
)
|
|
if not input_name:
|
|
required = tool.schema.get("required")
|
|
if isinstance(required, list):
|
|
input_name = next(
|
|
(
|
|
str(name)
|
|
for name in required
|
|
if isinstance(properties.get(str(name)), Mapping)
|
|
and properties[str(name)].get("type") == "string"
|
|
),
|
|
"",
|
|
)
|
|
if input_name:
|
|
args: dict[str, Any] = {input_name: question}
|
|
if "max_evidence" in properties:
|
|
args["max_evidence"] = min(limit, 10)
|
|
elif "max_rows" in properties:
|
|
args["max_rows"] = limit
|
|
elif "limit" in properties:
|
|
args["limit"] = limit
|
|
return args
|
|
return {"prompt": question, "limit": limit}
|
|
|
|
|
|
__all__ = [
|
|
"McpTool",
|
|
"McpToolRouterError",
|
|
"RoutedMcpTool",
|
|
"build_mcp_tool_arguments",
|
|
"route_mcp_tool_across_servers_with_llm",
|
|
"route_mcp_tool_with_llm",
|
|
]
|