[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:
rishiganesh2002 2025-10-03 10:16:29 -07:00 committed by GitHub
parent 94a89cd7de
commit d36c8d6bbe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 735 additions and 278 deletions

View file

@ -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]],

View file

@ -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)
######################################################## ########################################################

View file

@ -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(

View file

@ -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(

View file

@ -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`.

View file

@ -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?

View file

@ -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?

View file

@ -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.

View file

@ -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__])

View 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">

View file

@ -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

View file

@ -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} />

View file

@ -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>
} }

View file

@ -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

View file

@ -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
} }

View 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,
}
}