mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
[Feat] MCP Gateway Fine-grained Tools Addition (#15153)
* feat: UI to add specific tools under creating MCP connection * chore: pydantic + prisma changes * feat: adding specific MCP tools now works * fix: allowed tools filtering * chore: filtered list to mcp server cost config * chore: update Readme * chore: refactor the filtering * test: Added tests When the allowed_tests is null, empty list or populated * chore: resolve the proxy issue * feat: updating MCP tool filtering
This commit is contained in:
parent
94a89cd7de
commit
d36c8d6bbe
16 changed files with 735 additions and 278 deletions
|
|
@ -1197,6 +1197,11 @@ class MCPServerManager:
|
||||||
if server.mcp_access_groups is not None
|
if server.mcp_access_groups is not None
|
||||||
else []
|
else []
|
||||||
),
|
),
|
||||||
|
allowed_tools=(
|
||||||
|
server.allowed_tools
|
||||||
|
if server.allowed_tools is not None
|
||||||
|
else []
|
||||||
|
),
|
||||||
mcp_info=server.mcp_info,
|
mcp_info=server.mcp_info,
|
||||||
teams=cast(
|
teams=cast(
|
||||||
List[Dict[str, str | None]],
|
List[Dict[str, str | None]],
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ if MCP_AVAILABLE:
|
||||||
from litellm.proxy._experimental.mcp_server.server import (
|
from litellm.proxy._experimental.mcp_server.server import (
|
||||||
ListMCPToolsRestAPIResponseObject,
|
ListMCPToolsRestAPIResponseObject,
|
||||||
call_mcp_tool,
|
call_mcp_tool,
|
||||||
|
filter_tools_by_allowed_tools,
|
||||||
)
|
)
|
||||||
|
|
||||||
########################################################
|
########################################################
|
||||||
|
|
@ -75,6 +76,12 @@ if MCP_AVAILABLE:
|
||||||
mcp_auth_header=server_auth_header,
|
mcp_auth_header=server_auth_header,
|
||||||
add_prefix=False,
|
add_prefix=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Filter tools based on allowed_tools configuration
|
||||||
|
# Only filter if allowed_tools is explicitly configured (not None and not empty)
|
||||||
|
if server.allowed_tools is not None and len(server.allowed_tools) > 0:
|
||||||
|
tools = filter_tools_by_allowed_tools(tools, server)
|
||||||
|
|
||||||
return _create_tool_response_objects(tools, server.mcp_info)
|
return _create_tool_response_objects(tools, server.mcp_info)
|
||||||
|
|
||||||
########################################################
|
########################################################
|
||||||
|
|
|
||||||
|
|
@ -361,22 +361,66 @@ if MCP_AVAILABLE:
|
||||||
|
|
||||||
return allowed_mcp_servers
|
return allowed_mcp_servers
|
||||||
|
|
||||||
|
def _tool_name_matches(tool_name: str, filter_list: List[str]) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a tool name matches any name in the filter list.
|
||||||
|
|
||||||
|
Checks both the full tool name and unprefixed version (without server prefix).
|
||||||
|
This allows users to configure simple tool names regardless of prefixing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: The tool name to check (may be prefixed like "server-tool_name")
|
||||||
|
filter_list: List of tool names to match against
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the tool name (prefixed or unprefixed) is in the filter list
|
||||||
|
"""
|
||||||
|
from litellm.proxy._experimental.mcp_server.utils import (
|
||||||
|
get_server_name_prefix_tool_mcp,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if the full name is in the list
|
||||||
|
if tool_name in filter_list:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check if the unprefixed name is in the list
|
||||||
|
unprefixed_name, _ = get_server_name_prefix_tool_mcp(tool_name)
|
||||||
|
return unprefixed_name in filter_list
|
||||||
|
|
||||||
def filter_tools_by_allowed_tools(
|
def filter_tools_by_allowed_tools(
|
||||||
tools: List[MCPTool],
|
tools: List[MCPTool],
|
||||||
mcp_server: MCPServer,
|
mcp_server: MCPServer,
|
||||||
) -> List[MCPTool]:
|
) -> List[MCPTool]:
|
||||||
"""
|
"""
|
||||||
Filter tools by allowed tools
|
Filter tools by allowed/disallowed tools configuration.
|
||||||
|
|
||||||
|
If allowed_tools is set, only tools in that list are returned.
|
||||||
|
If disallowed_tools is set, tools in that list are excluded.
|
||||||
|
Tool names are matched with and without server prefixes for flexibility.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tools: List of tools to filter
|
||||||
|
mcp_server: Server configuration with allowed_tools/disallowed_tools
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered list of tools
|
||||||
"""
|
"""
|
||||||
tools_to_return = tools
|
tools_to_return = tools
|
||||||
|
|
||||||
|
# Filter by allowed_tools (whitelist)
|
||||||
if mcp_server.allowed_tools:
|
if mcp_server.allowed_tools:
|
||||||
tools_to_return = [
|
tools_to_return = [
|
||||||
tool for tool in tools if tool.name in mcp_server.allowed_tools
|
tool for tool in tools
|
||||||
|
if _tool_name_matches(tool.name, mcp_server.allowed_tools)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Filter by disallowed_tools (blacklist)
|
||||||
if mcp_server.disallowed_tools:
|
if mcp_server.disallowed_tools:
|
||||||
tools_to_return = [
|
tools_to_return = [
|
||||||
tool for tool in tools if tool.name not in mcp_server.disallowed_tools
|
tool for tool in tools_to_return
|
||||||
|
if not _tool_name_matches(tool.name, mcp_server.disallowed_tools)
|
||||||
]
|
]
|
||||||
|
|
||||||
return tools_to_return
|
return tools_to_return
|
||||||
|
|
||||||
async def _get_tools_from_mcp_servers(
|
async def _get_tools_from_mcp_servers(
|
||||||
|
|
@ -453,9 +497,12 @@ if MCP_AVAILABLE:
|
||||||
extra_headers=extra_headers,
|
extra_headers=extra_headers,
|
||||||
add_prefix=add_prefix,
|
add_prefix=add_prefix,
|
||||||
)
|
)
|
||||||
all_tools.extend(filter_tools_by_allowed_tools(tools, server))
|
|
||||||
|
filtered_tools = filter_tools_by_allowed_tools(tools, server)
|
||||||
|
all_tools.extend(filtered_tools)
|
||||||
|
|
||||||
verbose_logger.debug(
|
verbose_logger.debug(
|
||||||
f"Successfully fetched {len(tools)} tools from server {server.name}"
|
f"Successfully fetched {len(tools)} tools from server {server.name}, {len(filtered_tools)} after filtering"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
verbose_logger.exception(
|
verbose_logger.exception(
|
||||||
|
|
|
||||||
|
|
@ -917,6 +917,7 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
|
||||||
url: Optional[str] = None
|
url: Optional[str] = None
|
||||||
mcp_info: Optional[MCPInfo] = None
|
mcp_info: Optional[MCPInfo] = None
|
||||||
mcp_access_groups: List[str] = Field(default_factory=list)
|
mcp_access_groups: List[str] = Field(default_factory=list)
|
||||||
|
allowed_tools: Optional[List[str]] = None
|
||||||
# Stdio-specific fields
|
# Stdio-specific fields
|
||||||
command: Optional[str] = None
|
command: Optional[str] = None
|
||||||
args: List[str] = Field(default_factory=list)
|
args: List[str] = Field(default_factory=list)
|
||||||
|
|
@ -985,6 +986,7 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase):
|
||||||
updated_by: Optional[str] = None
|
updated_by: Optional[str] = None
|
||||||
teams: List[Dict[str, Optional[str]]] = Field(default_factory=list)
|
teams: List[Dict[str, Optional[str]]] = Field(default_factory=list)
|
||||||
mcp_access_groups: List[str] = Field(default_factory=list)
|
mcp_access_groups: List[str] = Field(default_factory=list)
|
||||||
|
allowed_tools: List[str] = Field(default_factory=list)
|
||||||
mcp_info: Optional[MCPInfo] = None
|
mcp_info: Optional[MCPInfo] = None
|
||||||
# Health check status
|
# Health check status
|
||||||
status: Optional[str] = Field(
|
status: Optional[str] = Field(
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
Endpoints here:
|
Endpoints here:
|
||||||
- GET `/v1/mcp/server` - Returns all of the configured mcp servers in the db filtered by requestor's access
|
- GET `/v1/mcp/server` - Returns all of the configured mcp servers in the db filtered by requestor's access
|
||||||
- GET `/v1/mcp/server/{server_id}` - Returns the the specific mcp server in the db given `server_id` filtered by requestor's access
|
- GET `/v1/mcp/server/{server_id}` - Returns the the specific mcp server in the db given `server_id` filtered by requestor's access
|
||||||
- GET `/v1/mcp/server/{server_id}/tools` - Get all the tools from the mcp server specified by the `server_id`
|
|
||||||
- POST `/v1/mcp/server` - Add a new external mcp server.
|
- POST `/v1/mcp/server` - Add a new external mcp server.
|
||||||
- PUT `/v1/mcp/server` - Edits an existing mcp server.
|
- PUT `/v1/mcp/server` - Edits an existing mcp server.
|
||||||
- DELETE `/v1/mcp/server/{server_id}` - Deletes the mcp server given `server_id`.
|
- DELETE `/v1/mcp/server/{server_id}` - Deletes the mcp server given `server_id`.
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,7 @@ model LiteLLM_MCPServerTable {
|
||||||
updated_by String?
|
updated_by String?
|
||||||
mcp_info Json? @default("{}")
|
mcp_info Json? @default("{}")
|
||||||
mcp_access_groups String[]
|
mcp_access_groups String[]
|
||||||
|
allowed_tools String[] @default([])
|
||||||
// Health check status
|
// Health check status
|
||||||
status String? @default("unknown")
|
status String? @default("unknown")
|
||||||
last_health_check DateTime?
|
last_health_check DateTime?
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,7 @@ model LiteLLM_MCPServerTable {
|
||||||
updated_by String?
|
updated_by String?
|
||||||
mcp_info Json? @default("{}")
|
mcp_info Json? @default("{}")
|
||||||
mcp_access_groups String[]
|
mcp_access_groups String[]
|
||||||
|
allowed_tools String[] @default([])
|
||||||
// Health check status
|
// Health check status
|
||||||
status String? @default("unknown")
|
status String? @default("unknown")
|
||||||
last_health_check DateTime?
|
last_health_check DateTime?
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
**In total litellm runs 1000+ tests**
|
**In total litellm runs 1000+ tests**
|
||||||
|
|
||||||
[02/20/2025] Update:
|
[02/20/2025] Update:
|
||||||
|
|
||||||
To make it easier to contribute and map what behavior is tested,
|
To make it easier to contribute and map what behavior is tested,
|
||||||
|
|
||||||
we've started mapping the litellm directory in `tests/litellm`
|
we've started mapping the litellm directory in `tests/test_litellm`
|
||||||
|
|
||||||
This folder can only run mock tests.
|
This folder can only run mock tests.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
@ -759,6 +759,156 @@ class TestMCPServerManager:
|
||||||
assert resolved_server_pref is not None
|
assert resolved_server_pref is not None
|
||||||
assert resolved_server_pref.server_id == server.server_id
|
assert resolved_server_pref.server_id == server.server_id
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rest_endpoint_filters_by_allowed_tools(self):
|
||||||
|
"""Test that REST endpoint _get_tools_for_single_server respects allowed_tools configuration"""
|
||||||
|
from litellm.proxy._experimental.mcp_server.rest_endpoints import (
|
||||||
|
_get_tools_for_single_server,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create server with allowed_tools configured
|
||||||
|
server = MCPServer(
|
||||||
|
server_id="test-server",
|
||||||
|
name="test-server",
|
||||||
|
transport=MCPTransport.http,
|
||||||
|
allowed_tools=["allowed_tool_1", "allowed_tool_2"],
|
||||||
|
)
|
||||||
|
server.mcp_info = {"server_name": "test-server"}
|
||||||
|
|
||||||
|
# Mock tools returned from manager (3 tools, but only 2 are allowed)
|
||||||
|
tool1 = MagicMock()
|
||||||
|
tool1.name = "allowed_tool_1"
|
||||||
|
tool1.description = "This tool is allowed"
|
||||||
|
tool1.inputSchema = {}
|
||||||
|
|
||||||
|
tool2 = MagicMock()
|
||||||
|
tool2.name = "blocked_tool"
|
||||||
|
tool2.description = "This tool is not allowed"
|
||||||
|
tool2.inputSchema = {}
|
||||||
|
|
||||||
|
tool3 = MagicMock()
|
||||||
|
tool3.name = "allowed_tool_2"
|
||||||
|
tool3.description = "This tool is also allowed"
|
||||||
|
tool3.inputSchema = {}
|
||||||
|
|
||||||
|
# Mock the global_mcp_server_manager._get_tools_from_server
|
||||||
|
from litellm.proxy._experimental.mcp_server import rest_endpoints
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
rest_endpoints.global_mcp_server_manager,
|
||||||
|
"_get_tools_from_server",
|
||||||
|
new=AsyncMock(return_value=[tool1, tool2, tool3]),
|
||||||
|
):
|
||||||
|
# Call the REST endpoint helper
|
||||||
|
filtered_response = await _get_tools_for_single_server(
|
||||||
|
server, server_auth_header=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify only allowed tools are in the response
|
||||||
|
assert len(filtered_response) == 2
|
||||||
|
tool_names = [t.name for t in filtered_response]
|
||||||
|
assert "allowed_tool_1" in tool_names
|
||||||
|
assert "allowed_tool_2" in tool_names
|
||||||
|
assert "blocked_tool" not in tool_names
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rest_endpoint_shows_all_when_allowed_tools_is_none(self):
|
||||||
|
"""Test that REST endpoint shows all tools when allowed_tools is None (backwards compatibility)"""
|
||||||
|
from litellm.proxy._experimental.mcp_server.rest_endpoints import (
|
||||||
|
_get_tools_for_single_server,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create server with allowed_tools as None
|
||||||
|
server = MCPServer(
|
||||||
|
server_id="test-server",
|
||||||
|
name="test-server",
|
||||||
|
transport=MCPTransport.http,
|
||||||
|
allowed_tools=None, # No filtering
|
||||||
|
)
|
||||||
|
server.mcp_info = {"server_name": "test-server"}
|
||||||
|
|
||||||
|
# Mock tools returned from manager
|
||||||
|
tool1 = MagicMock()
|
||||||
|
tool1.name = "tool_1"
|
||||||
|
tool1.description = "Tool 1"
|
||||||
|
tool1.inputSchema = {}
|
||||||
|
|
||||||
|
tool2 = MagicMock()
|
||||||
|
tool2.name = "tool_2"
|
||||||
|
tool2.description = "Tool 2"
|
||||||
|
tool2.inputSchema = {}
|
||||||
|
|
||||||
|
tool3 = MagicMock()
|
||||||
|
tool3.name = "tool_3"
|
||||||
|
tool3.description = "Tool 3"
|
||||||
|
tool3.inputSchema = {}
|
||||||
|
|
||||||
|
# Mock the global_mcp_server_manager._get_tools_from_server
|
||||||
|
from litellm.proxy._experimental.mcp_server import rest_endpoints
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
rest_endpoints.global_mcp_server_manager,
|
||||||
|
"_get_tools_from_server",
|
||||||
|
new=AsyncMock(return_value=[tool1, tool2, tool3]),
|
||||||
|
):
|
||||||
|
# Call the REST endpoint helper
|
||||||
|
all_tools_response = await _get_tools_for_single_server(
|
||||||
|
server, server_auth_header=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify all tools are returned (no filtering)
|
||||||
|
assert len(all_tools_response) == 3
|
||||||
|
tool_names = [t.name for t in all_tools_response]
|
||||||
|
assert "tool_1" in tool_names
|
||||||
|
assert "tool_2" in tool_names
|
||||||
|
assert "tool_3" in tool_names
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rest_endpoint_shows_all_when_allowed_tools_is_empty_list(self):
|
||||||
|
"""Test that REST endpoint shows all tools when allowed_tools is empty list (backwards compatibility)"""
|
||||||
|
from litellm.proxy._experimental.mcp_server.rest_endpoints import (
|
||||||
|
_get_tools_for_single_server,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create server with allowed_tools as empty list
|
||||||
|
server = MCPServer(
|
||||||
|
server_id="test-server",
|
||||||
|
name="test-server",
|
||||||
|
transport=MCPTransport.http,
|
||||||
|
allowed_tools=[], # Empty list means no filtering
|
||||||
|
)
|
||||||
|
server.mcp_info = {"server_name": "test-server"}
|
||||||
|
|
||||||
|
# Mock tools returned from manager
|
||||||
|
tool1 = MagicMock()
|
||||||
|
tool1.name = "tool_1"
|
||||||
|
tool1.description = "Tool 1"
|
||||||
|
tool1.inputSchema = {}
|
||||||
|
|
||||||
|
tool2 = MagicMock()
|
||||||
|
tool2.name = "tool_2"
|
||||||
|
tool2.description = "Tool 2"
|
||||||
|
tool2.inputSchema = {}
|
||||||
|
|
||||||
|
# Mock the global_mcp_server_manager._get_tools_from_server
|
||||||
|
from litellm.proxy._experimental.mcp_server import rest_endpoints
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
rest_endpoints.global_mcp_server_manager,
|
||||||
|
"_get_tools_from_server",
|
||||||
|
new=AsyncMock(return_value=[tool1, tool2]),
|
||||||
|
):
|
||||||
|
# Call the REST endpoint helper
|
||||||
|
all_tools_response = await _get_tools_for_single_server(
|
||||||
|
server, server_auth_header=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify all tools are returned (no filtering)
|
||||||
|
assert len(all_tools_response) == 2
|
||||||
|
tool_names = [t.name for t in all_tools_response]
|
||||||
|
assert "tool_1" in tool_names
|
||||||
|
assert "tool_2" in tool_names
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
pytest.main([__file__])
|
pytest.main([__file__])
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { createMCPServer } from "../networking"
|
||||||
import { MCPServer, MCPServerCostInfo } from "./types"
|
import { MCPServer, MCPServerCostInfo } from "./types"
|
||||||
import MCPServerCostConfig from "./mcp_server_cost_config"
|
import MCPServerCostConfig from "./mcp_server_cost_config"
|
||||||
import MCPConnectionStatus from "./mcp_connection_status"
|
import MCPConnectionStatus from "./mcp_connection_status"
|
||||||
|
import MCPToolConfiguration from "./mcp_tool_configuration"
|
||||||
import StdioConfiguration from "./StdioConfiguration"
|
import StdioConfiguration from "./StdioConfiguration"
|
||||||
import { isAdminRole } from "@/utils/roles"
|
import { isAdminRole } from "@/utils/roles"
|
||||||
import { validateMCPServerUrl, validateMCPServerName } from "./utils"
|
import { validateMCPServerUrl, validateMCPServerName } from "./utils"
|
||||||
|
|
@ -37,7 +38,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||||
const [formValues, setFormValues] = useState<Record<string, any>>({})
|
const [formValues, setFormValues] = useState<Record<string, any>>({})
|
||||||
const [aliasManuallyEdited, setAliasManuallyEdited] = useState(false)
|
const [aliasManuallyEdited, setAliasManuallyEdited] = useState(false)
|
||||||
const [tools, setTools] = useState<any[]>([])
|
const [tools, setTools] = useState<any[]>([])
|
||||||
const [transportType, setTransportType] = useState<string>("sse")
|
const [allowedTools, setAllowedTools] = useState<string[]>([])
|
||||||
|
const [transportType, setTransportType] = useState<string>("")
|
||||||
const [searchValue, setSearchValue] = useState<string>("")
|
const [searchValue, setSearchValue] = useState<string>("")
|
||||||
const [urlWarning, setUrlWarning] = useState<string>("")
|
const [urlWarning, setUrlWarning] = useState<string>("")
|
||||||
|
|
||||||
|
|
@ -103,7 +105,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare the payload with cost configuration
|
// Prepare the payload with cost configuration and allowed tools
|
||||||
const payload = {
|
const payload = {
|
||||||
...formValues,
|
...formValues,
|
||||||
...stdioFields,
|
...stdioFields,
|
||||||
|
|
@ -116,6 +118,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||||
},
|
},
|
||||||
mcp_access_groups: accessGroups,
|
mcp_access_groups: accessGroups,
|
||||||
alias: formValues.alias,
|
alias: formValues.alias,
|
||||||
|
allowed_tools: allowedTools.length > 0 ? allowedTools : null,
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Payload: ${JSON.stringify(payload)}`)
|
console.log(`Payload: ${JSON.stringify(payload)}`)
|
||||||
|
|
@ -127,7 +130,9 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
setCostConfig({})
|
setCostConfig({})
|
||||||
setTools([])
|
setTools([])
|
||||||
|
setAllowedTools([])
|
||||||
setUrlWarning("")
|
setUrlWarning("")
|
||||||
|
setAliasManuallyEdited(false)
|
||||||
setModalVisible(false)
|
setModalVisible(false)
|
||||||
onCreateSuccess(response)
|
onCreateSuccess(response)
|
||||||
}
|
}
|
||||||
|
|
@ -143,7 +148,9 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
setCostConfig({})
|
setCostConfig({})
|
||||||
setTools([])
|
setTools([])
|
||||||
|
setAllowedTools([])
|
||||||
setUrlWarning("")
|
setUrlWarning("")
|
||||||
|
setAliasManuallyEdited(false)
|
||||||
setModalVisible(false)
|
setModalVisible(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,7 +183,10 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// If search value doesn't match any existing group and is not empty, add "create new group" option
|
// If search value doesn't match any existing group and is not empty, add "create new group" option
|
||||||
if (searchValue && !availableAccessGroups.some(group => group.toLowerCase().includes(searchValue.toLowerCase()))) {
|
if (
|
||||||
|
searchValue &&
|
||||||
|
!availableAccessGroups.some((group) => group.toLowerCase().includes(searchValue.toLowerCase()))
|
||||||
|
) {
|
||||||
existingOptions.push({
|
existingOptions.push({
|
||||||
value: searchValue,
|
value: searchValue,
|
||||||
label: (
|
label: (
|
||||||
|
|
@ -201,6 +211,13 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||||
}
|
}
|
||||||
}, [formValues.server_name])
|
}, [formValues.server_name])
|
||||||
|
|
||||||
|
// Clear formValues when modal closes to reset child components
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isModalVisible) {
|
||||||
|
setFormValues({})
|
||||||
|
}
|
||||||
|
}, [isModalVisible])
|
||||||
|
|
||||||
// rendering
|
// rendering
|
||||||
if (!isAdminRole(userRole)) {
|
if (!isAdminRole(userRole)) {
|
||||||
return null
|
return null
|
||||||
|
|
@ -297,7 +314,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
required: false,
|
required: false,
|
||||||
message: "Please enter a server description",
|
message: "Please enter a server description!!!!!!!!!",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
|
@ -341,11 +358,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||||
onChange={(e) => checkUrlFormat(e.target.value, transportType)}
|
onChange={(e) => checkUrlFormat(e.target.value, transportType)}
|
||||||
/>
|
/>
|
||||||
{urlWarning && (
|
{urlWarning && <div className="mt-1 text-red-500 text-sm font-medium">{urlWarning}</div>}
|
||||||
<div className="mt-1 text-red-500 text-sm font-medium">
|
|
||||||
{urlWarning}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
|
@ -386,9 +399,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||||
showSearch
|
showSearch
|
||||||
placeholder="Select existing groups or type to create new ones"
|
placeholder="Select existing groups or type to create new ones"
|
||||||
optionFilterProp="value"
|
optionFilterProp="value"
|
||||||
filterOption={(input, option) =>
|
filterOption={(input, option) => (option?.value ?? "").toLowerCase().includes(input.toLowerCase())}
|
||||||
(option?.value ?? '').toLowerCase().includes(input.toLowerCase())
|
|
||||||
}
|
|
||||||
onSearch={(value) => setSearchValue(value)}
|
onSearch={(value) => setSearchValue(value)}
|
||||||
tokenSeparators={[","]}
|
tokenSeparators={[","]}
|
||||||
options={getAccessGroupOptions()}
|
options={getAccessGroupOptions()}
|
||||||
|
|
@ -403,9 +414,24 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||||
<MCPConnectionStatus accessToken={accessToken} formValues={formValues} onToolsLoaded={setTools} />
|
<MCPConnectionStatus accessToken={accessToken} formValues={formValues} onToolsLoaded={setTools} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tool Configuration Section */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<MCPToolConfiguration
|
||||||
|
accessToken={accessToken}
|
||||||
|
formValues={formValues}
|
||||||
|
allowedTools={allowedTools}
|
||||||
|
onAllowedToolsChange={setAllowedTools}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Cost Configuration Section */}
|
{/* Cost Configuration Section */}
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<MCPServerCostConfig value={costConfig} onChange={setCostConfig} tools={tools} disabled={false} />
|
<MCPServerCostConfig
|
||||||
|
value={costConfig}
|
||||||
|
onChange={setCostConfig}
|
||||||
|
tools={tools.filter((tool) => allowedTools.includes(tool.name))}
|
||||||
|
disabled={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-100">
|
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-100">
|
||||||
|
|
|
||||||
|
|
@ -1,92 +1,30 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useEffect } from "react"
|
||||||
import { Button, message, Spin, Alert, Collapse, Badge } from "antd";
|
import { Button, Spin, Alert } from "antd"
|
||||||
import { CheckCircleOutlined, ExclamationCircleOutlined, ReloadOutlined, ToolOutlined, InfoCircleOutlined } from "@ant-design/icons";
|
import { CheckCircleOutlined, ExclamationCircleOutlined, ReloadOutlined, ToolOutlined } from "@ant-design/icons"
|
||||||
import { Card, Title, Text } from "@tremor/react";
|
import { Card, Title, Text } from "@tremor/react"
|
||||||
import { testMCPToolsListRequest } from "../networking";
|
import { useTestMCPConnection } from "../../hooks/useTestMCPConnection"
|
||||||
|
|
||||||
const { Panel } = Collapse;
|
|
||||||
|
|
||||||
interface MCPConnectionStatusProps {
|
interface MCPConnectionStatusProps {
|
||||||
accessToken: string | null;
|
accessToken: string | null
|
||||||
formValues: Record<string, any>;
|
formValues: Record<string, any>
|
||||||
onToolsLoaded?: (tools: any[]) => void;
|
onToolsLoaded?: (tools: any[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({
|
const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({ accessToken, formValues, onToolsLoaded }) => {
|
||||||
accessToken,
|
const { tools, isLoadingTools, toolsError, canFetchTools, fetchTools } = useTestMCPConnection({
|
||||||
formValues,
|
accessToken,
|
||||||
onToolsLoaded
|
formValues,
|
||||||
}) => {
|
enabled: true, // Auto-fetch when required fields are available
|
||||||
const [tools, setTools] = useState<any[]>([]);
|
})
|
||||||
const [isLoadingTools, setIsLoadingTools] = useState(false);
|
|
||||||
const [toolsError, setToolsError] = useState<string | null>(null);
|
|
||||||
const [hasShownSuccessMessage, setHasShownSuccessMessage] = useState(false);
|
|
||||||
|
|
||||||
// Check if we have the minimum required fields to fetch tools
|
// Notify parent component when tools change
|
||||||
const canFetchTools = formValues.url && formValues.transport && formValues.auth_type && accessToken;
|
|
||||||
|
|
||||||
const fetchTools = async () => {
|
|
||||||
if (!accessToken || !formValues.url) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoadingTools(true);
|
|
||||||
setToolsError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Prepare the MCP server config from form values
|
|
||||||
const mcpServerConfig = {
|
|
||||||
server_id: formValues.server_id || "",
|
|
||||||
server_name: formValues.server_name || "",
|
|
||||||
url: formValues.url,
|
|
||||||
transport: formValues.transport,
|
|
||||||
auth_type: formValues.auth_type,
|
|
||||||
mcp_info: formValues.mcp_info,
|
|
||||||
};
|
|
||||||
|
|
||||||
const toolsResponse = await testMCPToolsListRequest(accessToken, mcpServerConfig);
|
|
||||||
|
|
||||||
if (toolsResponse.tools && !toolsResponse.error) {
|
|
||||||
setTools(toolsResponse.tools);
|
|
||||||
setToolsError(null);
|
|
||||||
onToolsLoaded?.(toolsResponse.tools);
|
|
||||||
if (toolsResponse.tools.length > 0 && !hasShownSuccessMessage) {
|
|
||||||
setHasShownSuccessMessage(true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const errorMessage = toolsResponse.message || "Failed to retrieve tools list";
|
|
||||||
setToolsError(errorMessage);
|
|
||||||
setTools([]);
|
|
||||||
onToolsLoaded?.([]);
|
|
||||||
setHasShownSuccessMessage(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Tools fetch error:", error);
|
|
||||||
setToolsError(error instanceof Error ? error.message : String(error));
|
|
||||||
setTools([]);
|
|
||||||
onToolsLoaded?.([]);
|
|
||||||
setHasShownSuccessMessage(false);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingTools(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Auto-fetch tools when form values change and required fields are available
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (canFetchTools) {
|
onToolsLoaded?.(tools)
|
||||||
fetchTools();
|
}, [tools, onToolsLoaded])
|
||||||
} else {
|
|
||||||
// Clear tools if required fields are missing
|
|
||||||
setTools([]);
|
|
||||||
setToolsError(null);
|
|
||||||
setHasShownSuccessMessage(false);
|
|
||||||
onToolsLoaded?.([]);
|
|
||||||
}
|
|
||||||
}, [formValues.url, formValues.transport, formValues.auth_type, accessToken]);
|
|
||||||
|
|
||||||
// Don't show anything if required fields aren't filled
|
// Don't show anything if required fields aren't filled
|
||||||
if (!canFetchTools && !formValues.url) {
|
if (!canFetchTools && !formValues.url) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -102,9 +40,7 @@ const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({
|
||||||
<ToolOutlined className="text-2xl mb-2" />
|
<ToolOutlined className="text-2xl mb-2" />
|
||||||
<Text>Complete required fields to test connection</Text>
|
<Text>Complete required fields to test connection</Text>
|
||||||
<br />
|
<br />
|
||||||
<Text className="text-sm">
|
<Text className="text-sm">Fill in URL, Transport, and Authentication to test MCP server connection</Text>
|
||||||
Fill in URL, Transport, and Authentication to test MCP server connection
|
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -113,34 +49,32 @@ const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<Text className="text-gray-700 font-medium">
|
<Text className="text-gray-700 font-medium">
|
||||||
{isLoadingTools
|
{isLoadingTools
|
||||||
? "Testing connection to MCP server..."
|
? "Testing connection to MCP server..."
|
||||||
: tools.length > 0
|
: tools.length > 0
|
||||||
? "Connection successful"
|
? "Connection successful"
|
||||||
: toolsError
|
: toolsError
|
||||||
? "Connection failed"
|
? "Connection failed"
|
||||||
: "Ready to test connection"}
|
: "Ready to test connection"}
|
||||||
</Text>
|
</Text>
|
||||||
<br />
|
<br />
|
||||||
<Text className="text-gray-500 text-sm">
|
<Text className="text-gray-500 text-sm">Server: {formValues.url}</Text>
|
||||||
Server: {formValues.url}
|
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoadingTools && (
|
{isLoadingTools && (
|
||||||
<div className="flex items-center text-blue-600">
|
<div className="flex items-center text-blue-600">
|
||||||
<Spin size="small" className="mr-2" />
|
<Spin size="small" className="mr-2" />
|
||||||
<Text className="text-blue-600">Connecting...</Text>
|
<Text className="text-blue-600">Connecting...</Text>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoadingTools && !toolsError && tools.length > 0 && (
|
{!isLoadingTools && !toolsError && tools.length > 0 && (
|
||||||
<div className="flex items-center text-green-600">
|
<div className="flex items-center text-green-600">
|
||||||
<CheckCircleOutlined className="mr-1" />
|
<CheckCircleOutlined className="mr-1" />
|
||||||
<Text className="text-green-600 font-medium">Connected</Text>
|
<Text className="text-green-600 font-medium">Connected</Text>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{toolsError && (
|
{toolsError && (
|
||||||
<div className="flex items-center text-red-600">
|
<div className="flex items-center text-red-600">
|
||||||
<ExclamationCircleOutlined className="mr-1" />
|
<ExclamationCircleOutlined className="mr-1" />
|
||||||
|
|
@ -163,54 +97,13 @@ const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({
|
||||||
type="error"
|
type="error"
|
||||||
showIcon
|
showIcon
|
||||||
action={
|
action={
|
||||||
<Button
|
<Button icon={<ReloadOutlined />} onClick={fetchTools} size="small">
|
||||||
icon={<ReloadOutlined />}
|
|
||||||
onClick={fetchTools}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
Retry
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoadingTools && tools.length > 0 && (
|
|
||||||
<Collapse
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
key: '1',
|
|
||||||
label: (
|
|
||||||
<div className="flex items-center">
|
|
||||||
<ToolOutlined className="mr-2 text-green-500" />
|
|
||||||
<span className="font-medium">Available Tools</span>
|
|
||||||
<Badge
|
|
||||||
count={tools.length}
|
|
||||||
style={{
|
|
||||||
backgroundColor: '#52c41a',
|
|
||||||
marginLeft: '8px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
children: (
|
|
||||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
|
||||||
{tools.map((tool, index) => (
|
|
||||||
<div key={index} className="p-3 bg-gray-50 rounded-lg">
|
|
||||||
<Text className="font-medium text-gray-900">{tool.name}</Text>
|
|
||||||
{tool.description && (
|
|
||||||
<Text className="text-gray-500 text-sm block mt-1">
|
|
||||||
{tool.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoadingTools && tools.length === 0 && !toolsError && (
|
{!isLoadingTools && tools.length === 0 && !toolsError && (
|
||||||
<div className="text-center py-6 text-gray-500 border rounded-lg border-dashed">
|
<div className="text-center py-6 text-gray-500 border rounded-lg border-dashed">
|
||||||
<CheckCircleOutlined className="text-2xl mb-2 text-green-500" />
|
<CheckCircleOutlined className="text-2xl mb-2 text-green-500" />
|
||||||
|
|
@ -223,7 +116,7 @@ const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default MCPConnectionStatus;
|
export default MCPConnectionStatus
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
||||||
? "text-green-600 bg-green-50 border-green-200"
|
? "text-green-600 bg-green-50 border-green-200"
|
||||||
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
|
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -241,6 +241,25 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text className="font-medium">Allowed Tools</Text>
|
||||||
|
<div>
|
||||||
|
{mcpServer.allowed_tools && mcpServer.allowed_tools.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{mcpServer.allowed_tools.map((tool: string, index: number) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="px-2 py-1 bg-blue-50 border border-blue-200 rounded-md text-sm"
|
||||||
|
>
|
||||||
|
{tool}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Text className="text-gray-500">All tools enabled</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Text className="font-medium">Cost Configuration</Text>
|
<Text className="font-medium">Cost Configuration</Text>
|
||||||
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
|
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,17 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
||||||
enabled: !!accessToken,
|
enabled: !!accessToken,
|
||||||
}) as { data: MCPServer[]; isLoading: boolean; refetch: () => void; dataUpdatedAt: number }
|
}) as { data: MCPServer[]; isLoading: boolean; refetch: () => void; dataUpdatedAt: number }
|
||||||
|
|
||||||
|
// Log allowed_tools from fetched servers
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (mcpServers) {
|
||||||
|
console.log("MCP Servers fetched:", mcpServers)
|
||||||
|
mcpServers.forEach((server) => {
|
||||||
|
console.log(`Server: ${server.server_name || server.server_id}`)
|
||||||
|
console.log(` allowed_tools:`, server.allowed_tools)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [mcpServers])
|
||||||
|
|
||||||
// state
|
// state
|
||||||
const [serverIdToDelete, setServerToDelete] = useState<string | null>(null)
|
const [serverIdToDelete, setServerToDelete] = useState<string | null>(null)
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||||
|
|
@ -60,7 +71,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
||||||
const [filteredServers, setFilteredServers] = useState<MCPServer[]>([])
|
const [filteredServers, setFilteredServers] = useState<MCPServer[]>([])
|
||||||
const [isModalVisible, setModalVisible] = useState(false)
|
const [isModalVisible, setModalVisible] = useState(false)
|
||||||
|
|
||||||
const isInternalUser = userRole === "Internal User";
|
const isInternalUser = userRole === "Internal User"
|
||||||
|
|
||||||
// Get unique teams from all servers
|
// Get unique teams from all servers
|
||||||
const uniqueTeams = React.useMemo(() => {
|
const uniqueTeams = React.useMemo(() => {
|
||||||
|
|
@ -84,7 +95,11 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
||||||
// Get unique MCP access groups from all servers
|
// Get unique MCP access groups from all servers
|
||||||
const uniqueMcpAccessGroups = React.useMemo(() => {
|
const uniqueMcpAccessGroups = React.useMemo(() => {
|
||||||
if (!mcpServers) return []
|
if (!mcpServers) return []
|
||||||
return Array.from(new Set(mcpServers.flatMap((server) => server.mcp_access_groups).filter((group): group is string => group != null)))
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
mcpServers.flatMap((server) => server.mcp_access_groups).filter((group): group is string => group != null),
|
||||||
|
),
|
||||||
|
)
|
||||||
}, [mcpServers])
|
}, [mcpServers])
|
||||||
|
|
||||||
// Handle team filter change
|
// Handle team filter change
|
||||||
|
|
@ -171,7 +186,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accessToken || !userRole || !userID) {
|
if (!accessToken || !userRole || !userID) {
|
||||||
console.log("Missing required authentication parameters", { accessToken, userRole, userID });
|
console.log("Missing required authentication parameters", { accessToken, userRole, userID })
|
||||||
return <div className="p-6 text-center text-gray-500">Missing required authentication parameters.</div>
|
return <div className="p-6 text-center text-gray-500">Missing required authentication parameters.</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
import React, { useEffect, useRef } from "react"
|
||||||
|
import { Card, Title, Text } from "@tremor/react"
|
||||||
|
import { ToolOutlined, CheckCircleOutlined } from "@ant-design/icons"
|
||||||
|
import { Badge, Spin, Checkbox } from "antd"
|
||||||
|
import { useTestMCPConnection } from "../../hooks/useTestMCPConnection"
|
||||||
|
|
||||||
|
interface MCPToolConfigurationProps {
|
||||||
|
accessToken: string | null
|
||||||
|
formValues: Record<string, any>
|
||||||
|
allowedTools: string[]
|
||||||
|
onAllowedToolsChange: (tools: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
||||||
|
accessToken,
|
||||||
|
formValues,
|
||||||
|
allowedTools,
|
||||||
|
onAllowedToolsChange,
|
||||||
|
}) => {
|
||||||
|
const previousToolsLengthRef = useRef(0)
|
||||||
|
|
||||||
|
const { tools, isLoadingTools, toolsError, canFetchTools } = useTestMCPConnection({
|
||||||
|
accessToken,
|
||||||
|
formValues,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-select all tools when tools are first loaded
|
||||||
|
useEffect(() => {
|
||||||
|
// Only auto-select if:
|
||||||
|
// 1. We have tools
|
||||||
|
// 2. Tools length changed (new tools loaded)
|
||||||
|
// 3. No tools are currently selected (initial state)
|
||||||
|
if (tools.length > 0 && tools.length !== previousToolsLengthRef.current && allowedTools.length === 0) {
|
||||||
|
const allToolNames = tools.map((tool) => tool.name)
|
||||||
|
onAllowedToolsChange(allToolNames)
|
||||||
|
}
|
||||||
|
// Update ref to track tools length (will be 0 when tools clear)
|
||||||
|
previousToolsLengthRef.current = tools.length
|
||||||
|
}, [tools, allowedTools.length, onAllowedToolsChange])
|
||||||
|
|
||||||
|
const handleToolToggle = (toolName: string) => {
|
||||||
|
if (allowedTools.includes(toolName)) {
|
||||||
|
onAllowedToolsChange(allowedTools.filter((name) => name !== toolName))
|
||||||
|
} else {
|
||||||
|
onAllowedToolsChange([...allowedTools, toolName])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
const allToolNames = tools.map((tool) => tool.name)
|
||||||
|
onAllowedToolsChange(allToolNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeselectAll = () => {
|
||||||
|
onAllowedToolsChange([])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't show anything if required fields aren't filled
|
||||||
|
if (!canFetchTools && !formValues.url) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ToolOutlined className="text-blue-600" />
|
||||||
|
<Title>Tool Configuration</Title>
|
||||||
|
{tools.length > 0 && (
|
||||||
|
<Badge
|
||||||
|
count={tools.length}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#52c41a",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoadingTools && (
|
||||||
|
<div className="flex items-center justify-center py-6">
|
||||||
|
<Spin size="large" />
|
||||||
|
<Text className="ml-3">Loading tools...</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{toolsError && !isLoadingTools && (
|
||||||
|
<div className="text-center py-6 text-red-500 border rounded-lg border-dashed border-red-300 bg-red-50">
|
||||||
|
<ToolOutlined className="text-2xl mb-2" />
|
||||||
|
<Text className="text-red-600 font-medium">Unable to load tools</Text>
|
||||||
|
<br />
|
||||||
|
<Text className="text-sm text-red-500">{toolsError}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No tools state */}
|
||||||
|
{!isLoadingTools && !toolsError && tools.length === 0 && canFetchTools && (
|
||||||
|
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed">
|
||||||
|
<ToolOutlined className="text-2xl mb-2" />
|
||||||
|
<Text>No tools available for configuration</Text>
|
||||||
|
<br />
|
||||||
|
<Text className="text-sm">Connect to an MCP server with tools to configure them</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Incomplete form state */}
|
||||||
|
{!canFetchTools && formValues.url && (
|
||||||
|
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed">
|
||||||
|
<ToolOutlined className="text-2xl mb-2" />
|
||||||
|
<Text>Complete required fields to configure tools</Text>
|
||||||
|
<br />
|
||||||
|
<Text className="text-sm">Fill in URL, Transport, and Authentication to load available tools</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tools loaded successfully */}
|
||||||
|
{!isLoadingTools && !toolsError && tools.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-green-50 rounded-lg border border-green-200 flex-1">
|
||||||
|
<CheckCircleOutlined className="text-green-600" />
|
||||||
|
<Text className="text-green-700 font-medium">
|
||||||
|
{allowedTools.length} of {tools.length} {tools.length === 1 ? "tool" : "tools"} selected
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 ml-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
className="px-3 py-1.5 text-sm text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Select All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeselectAll}
|
||||||
|
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-700 hover:bg-gray-100 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Deselect All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tool list with checkboxes */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tools.map((tool, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`p-4 rounded-lg border transition-colors cursor-pointer ${
|
||||||
|
allowedTools.includes(tool.name)
|
||||||
|
? "bg-blue-50 border-blue-300 hover:border-blue-400"
|
||||||
|
: "bg-gray-50 border-gray-200 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
onClick={() => handleToolToggle(tool.name)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Checkbox checked={allowedTools.includes(tool.name)} onChange={() => handleToolToggle(tool.name)} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Text className="font-medium text-gray-900">{tool.name}</Text>
|
||||||
|
{tool.description && <Text className="text-gray-500 text-sm block mt-1">{tool.description}</Text>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MCPToolConfiguration
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export interface Team {
|
export interface Team {
|
||||||
team_id: string;
|
team_id: string
|
||||||
team_alias?: string;
|
team_alias?: string
|
||||||
organization_id?: string | null;
|
organization_id?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default no auth value
|
// Default no auth value
|
||||||
|
|
@ -10,140 +10,141 @@ export const AUTH_TYPE = {
|
||||||
API_KEY: "api_key",
|
API_KEY: "api_key",
|
||||||
BEARER_TOKEN: "bearer_token",
|
BEARER_TOKEN: "bearer_token",
|
||||||
BASIC: "basic",
|
BASIC: "basic",
|
||||||
};
|
}
|
||||||
|
|
||||||
export const TRANSPORT = {
|
export const TRANSPORT = {
|
||||||
SSE: "sse",
|
SSE: "sse",
|
||||||
HTTP: "http",
|
HTTP: "http",
|
||||||
};
|
}
|
||||||
|
|
||||||
export const handleTransport = (transport?: string | null): string => {
|
export const handleTransport = (transport?: string | null): string => {
|
||||||
console.log(transport)
|
console.log(transport)
|
||||||
if (transport === null || transport === undefined) {
|
if (transport === null || transport === undefined) {
|
||||||
return TRANSPORT.SSE;
|
return TRANSPORT.SSE
|
||||||
}
|
}
|
||||||
|
|
||||||
return transport;
|
return transport
|
||||||
};
|
}
|
||||||
|
|
||||||
export const handleAuth = (authType?: string | null): string => {
|
export const handleAuth = (authType?: string | null): string => {
|
||||||
if (authType === null || authType === undefined) {
|
if (authType === null || authType === undefined) {
|
||||||
return AUTH_TYPE.NONE;
|
return AUTH_TYPE.NONE
|
||||||
}
|
}
|
||||||
|
|
||||||
return authType;
|
return authType
|
||||||
};
|
}
|
||||||
|
|
||||||
export const mcpServerHasAuth = (authType?: string | null): boolean => {
|
export const mcpServerHasAuth = (authType?: string | null): boolean => {
|
||||||
return handleAuth(authType) !== AUTH_TYPE.NONE;
|
return handleAuth(authType) !== AUTH_TYPE.NONE
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define the structure for tool input schema properties
|
// Define the structure for tool input schema properties
|
||||||
export interface InputSchemaProperty {
|
export interface InputSchemaProperty {
|
||||||
type: string;
|
type: string
|
||||||
description?: string;
|
description?: string
|
||||||
properties?: Record<string, InputSchemaProperty>; // For nested object properties
|
properties?: Record<string, InputSchemaProperty> // For nested object properties
|
||||||
required?: string[]; // For required fields in nested objects
|
required?: string[] // For required fields in nested objects
|
||||||
enum?: string[]; // For enum values
|
enum?: string[] // For enum values
|
||||||
default?: any; // For default values
|
default?: any // For default values
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define the structure for the input schema of a tool
|
|
||||||
export interface InputSchema {
|
|
||||||
type: "object";
|
|
||||||
properties: Record<string, InputSchemaProperty>;
|
|
||||||
required?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define MCPServerCostInfo for cost tracking
|
|
||||||
export interface MCPServerCostInfo {
|
|
||||||
default_cost_per_query?: number | null;
|
|
||||||
tool_name_to_cost_per_query?: Record<string, number | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define MCP provider info
|
// Define the structure for the input schema of a tool
|
||||||
export interface MCPInfo {
|
export interface InputSchema {
|
||||||
server_name: string;
|
type: "object"
|
||||||
description?: string;
|
properties: Record<string, InputSchemaProperty>
|
||||||
logo_url?: string;
|
required?: string[]
|
||||||
mcp_server_cost_info?: MCPServerCostInfo | null;
|
}
|
||||||
}
|
|
||||||
|
// Define MCPServerCostInfo for cost tracking
|
||||||
// Define the structure for a single MCP tool
|
export interface MCPServerCostInfo {
|
||||||
export interface MCPTool {
|
default_cost_per_query?: number | null
|
||||||
name: string;
|
tool_name_to_cost_per_query?: Record<string, number | null>
|
||||||
description?: string;
|
}
|
||||||
inputSchema: InputSchema | string; // API returns string "tool_input_schema" or the actual schema
|
|
||||||
mcp_info: MCPInfo;
|
// Define MCP provider info
|
||||||
// Function to select a tool (added in the component)
|
export interface MCPInfo {
|
||||||
onToolSelect?: (tool: MCPTool) => void;
|
server_name: string
|
||||||
}
|
description?: string
|
||||||
|
logo_url?: string
|
||||||
// Define the response structure for the listMCPTools endpoint - now a flat array
|
mcp_server_cost_info?: MCPServerCostInfo | null
|
||||||
export type ListMCPToolsResponse = MCPTool[];
|
}
|
||||||
|
|
||||||
// Define the argument structure for calling an MCP tool
|
// Define the structure for a single MCP tool
|
||||||
export interface CallMCPToolArgs {
|
export interface MCPTool {
|
||||||
name: string;
|
name: string
|
||||||
arguments: Record<string, any> | null;
|
description?: string
|
||||||
server_name?: string; // Now using server_name from mcp_info
|
inputSchema: InputSchema | string // API returns string "tool_input_schema" or the actual schema
|
||||||
}
|
mcp_info: MCPInfo
|
||||||
|
// Function to select a tool (added in the component)
|
||||||
// Define the possible content types in the response
|
onToolSelect?: (tool: MCPTool) => void
|
||||||
export interface MCPTextContent {
|
}
|
||||||
type: "text";
|
|
||||||
text: string;
|
// Define the response structure for the listMCPTools endpoint - now a flat array
|
||||||
annotations?: any;
|
export type ListMCPToolsResponse = MCPTool[]
|
||||||
}
|
|
||||||
|
// Define the argument structure for calling an MCP tool
|
||||||
export interface MCPImageContent {
|
export interface CallMCPToolArgs {
|
||||||
type: "image";
|
name: string
|
||||||
url?: string;
|
arguments: Record<string, any> | null
|
||||||
data?: string;
|
server_name?: string // Now using server_name from mcp_info
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MCPEmbeddedResource {
|
// Define the possible content types in the response
|
||||||
type: "embedded_resource";
|
export interface MCPTextContent {
|
||||||
resource_type?: string;
|
type: "text"
|
||||||
url?: string;
|
text: string
|
||||||
data?: any;
|
annotations?: any
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define the union type for the content array in the response
|
export interface MCPImageContent {
|
||||||
export type MCPContent = MCPTextContent | MCPImageContent | MCPEmbeddedResource;
|
type: "image"
|
||||||
|
url?: string
|
||||||
// Define the response structure for the callMCPTool endpoint
|
data?: string
|
||||||
export type CallMCPToolResponse = MCPContent[];
|
}
|
||||||
|
|
||||||
// Props for the main component
|
export interface MCPEmbeddedResource {
|
||||||
export interface MCPToolsViewerProps {
|
type: "embedded_resource"
|
||||||
serverId: string;
|
resource_type?: string
|
||||||
accessToken: string | null;
|
url?: string
|
||||||
auth_type?: string | null;
|
data?: any
|
||||||
userRole: string | null;
|
}
|
||||||
userID: string | null;
|
|
||||||
serverAlias?: string | null;
|
// Define the union type for the content array in the response
|
||||||
}
|
export type MCPContent = MCPTextContent | MCPImageContent | MCPEmbeddedResource
|
||||||
|
|
||||||
|
// Define the response structure for the callMCPTool endpoint
|
||||||
|
export type CallMCPToolResponse = MCPContent[]
|
||||||
|
|
||||||
|
// Props for the main component
|
||||||
|
export interface MCPToolsViewerProps {
|
||||||
|
serverId: string
|
||||||
|
accessToken: string | null
|
||||||
|
auth_type?: string | null
|
||||||
|
userRole: string | null
|
||||||
|
userID: string | null
|
||||||
|
serverAlias?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface MCPServer {
|
export interface MCPServer {
|
||||||
server_id: string;
|
server_id: string
|
||||||
server_name?: string | null;
|
server_name?: string | null
|
||||||
alias?: string | null;
|
alias?: string | null
|
||||||
description?: string | null;
|
description?: string | null
|
||||||
url: string;
|
url: string
|
||||||
transport?: string | null;
|
transport?: string | null
|
||||||
auth_type?: string | null;
|
auth_type?: string | null
|
||||||
mcp_info?: MCPInfo | null;
|
mcp_info?: MCPInfo | null
|
||||||
created_at: string;
|
created_at: string
|
||||||
created_by: string;
|
created_by: string
|
||||||
updated_at: string;
|
updated_at: string
|
||||||
updated_by: string;
|
updated_by: string
|
||||||
teams?: Team[];
|
teams?: Team[]
|
||||||
mcp_access_groups?: string[];
|
mcp_access_groups?: string[]
|
||||||
|
allowed_tools?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MCPServerProps {
|
export interface MCPServerProps {
|
||||||
accessToken: string | null;
|
accessToken: string | null
|
||||||
userRole: string | null;
|
userRole: string | null
|
||||||
userID: string | null;
|
userID: string | null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
114
ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx
Normal file
114
ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { testMCPToolsListRequest } from "../components/networking"
|
||||||
|
|
||||||
|
interface MCPServerConfig {
|
||||||
|
server_id?: string
|
||||||
|
server_name?: string
|
||||||
|
url?: string
|
||||||
|
transport?: string
|
||||||
|
auth_type?: string
|
||||||
|
mcp_info?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseTestMCPConnectionProps {
|
||||||
|
accessToken: string | null
|
||||||
|
formValues: Record<string, any>
|
||||||
|
enabled?: boolean // Optional flag to enable/disable auto-fetching
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseTestMCPConnectionReturn {
|
||||||
|
tools: any[]
|
||||||
|
isLoadingTools: boolean
|
||||||
|
toolsError: string | null
|
||||||
|
hasShownSuccessMessage: boolean
|
||||||
|
canFetchTools: boolean
|
||||||
|
fetchTools: () => Promise<void>
|
||||||
|
clearTools: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTestMCPConnection = ({
|
||||||
|
accessToken,
|
||||||
|
formValues,
|
||||||
|
enabled = true,
|
||||||
|
}: UseTestMCPConnectionProps): UseTestMCPConnectionReturn => {
|
||||||
|
const [tools, setTools] = useState<any[]>([])
|
||||||
|
const [isLoadingTools, setIsLoadingTools] = useState(false)
|
||||||
|
const [toolsError, setToolsError] = useState<string | null>(null)
|
||||||
|
const [hasShownSuccessMessage, setHasShownSuccessMessage] = useState(false)
|
||||||
|
|
||||||
|
// Check if we have the minimum required fields to fetch tools
|
||||||
|
const canFetchTools = !!(formValues.url && formValues.transport && formValues.auth_type && accessToken)
|
||||||
|
|
||||||
|
const fetchTools = async () => {
|
||||||
|
if (!accessToken || !formValues.url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoadingTools(true)
|
||||||
|
setToolsError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prepare the MCP server config from form values
|
||||||
|
const mcpServerConfig: MCPServerConfig = {
|
||||||
|
server_id: formValues.server_id || "",
|
||||||
|
server_name: formValues.server_name || "",
|
||||||
|
url: formValues.url,
|
||||||
|
transport: formValues.transport,
|
||||||
|
auth_type: formValues.auth_type,
|
||||||
|
mcp_info: formValues.mcp_info,
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolsResponse = await testMCPToolsListRequest(accessToken, mcpServerConfig)
|
||||||
|
|
||||||
|
if (toolsResponse.tools && !toolsResponse.error) {
|
||||||
|
setTools(toolsResponse.tools)
|
||||||
|
setToolsError(null)
|
||||||
|
if (toolsResponse.tools.length > 0 && !hasShownSuccessMessage) {
|
||||||
|
setHasShownSuccessMessage(true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const errorMessage = toolsResponse.message || "Failed to retrieve tools list"
|
||||||
|
setToolsError(errorMessage)
|
||||||
|
setTools([])
|
||||||
|
setHasShownSuccessMessage(false)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Tools fetch error:", error)
|
||||||
|
setToolsError(error instanceof Error ? error.message : String(error))
|
||||||
|
setTools([])
|
||||||
|
setHasShownSuccessMessage(false)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingTools(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearTools = () => {
|
||||||
|
setTools([])
|
||||||
|
setToolsError(null)
|
||||||
|
setHasShownSuccessMessage(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-fetch tools when form values change and required fields are available
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canFetchTools) {
|
||||||
|
fetchTools()
|
||||||
|
} else {
|
||||||
|
clearTools()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [formValues.url, formValues.transport, formValues.auth_type, accessToken, enabled, canFetchTools])
|
||||||
|
|
||||||
|
return {
|
||||||
|
tools,
|
||||||
|
isLoadingTools,
|
||||||
|
toolsError,
|
||||||
|
hasShownSuccessMessage,
|
||||||
|
canFetchTools,
|
||||||
|
fetchTools,
|
||||||
|
clearTools,
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue