diff --git a/backend/routes/config_routes.py b/backend/routes/config_routes.py index 749c965..c5ebd67 100644 --- a/backend/routes/config_routes.py +++ b/backend/routes/config_routes.py @@ -13,6 +13,15 @@ router = APIRouter(prefix="/api", tags=["config"]) +# Canvas tool description constant +CANVAS_TOOL_DESCRIPTION = ( + "Display final rendered content in a visual canvas panel. " + "Use this for: 1) Complete code (not code discussions), " + "2) Final reports/documents (not report discussions), " + "3) Data visualizations, 4) Any polished content that should be " + "viewed separately from the conversation." +) + @router.get("/banners") async def get_banners(current_user: str = Depends(get_current_user)): @@ -126,6 +135,20 @@ async def get_config( tools_info.append({ 'server': 'canvas', 'tools': ['canvas'], + 'tools_detailed': [{ + 'name': 'canvas', + 'description': CANVAS_TOOL_DESCRIPTION, + 'inputSchema': { + 'type': 'object', + 'properties': { + 'content': { + 'type': 'string', + 'description': 'The content to display in the canvas. Can be markdown, code, or plain text.' + } + }, + 'required': ['content'] + } + }], 'tool_count': 1, 'description': 'Canvas for showing final rendered content: complete code, reports, and polished documents. Use this to finalize your work. Most code and reports will be shown here.', 'is_exclusive': False, @@ -140,9 +163,20 @@ async def get_config( # Only include servers that have tools and user has access to if server_tools: # Only show servers with actual tools + # Build detailed tool information including descriptions and input schemas + tools_detailed = [] + for tool in server_tools: + tool_detail = { + 'name': tool.name, + 'description': tool.description or '', + 'inputSchema': getattr(tool, 'inputSchema', {}) or {} + } + tools_detailed.append(tool_detail) + tools_info.append({ 'server': server_name, 'tools': [tool.name for tool in server_tools], + 'tools_detailed': tools_detailed, 'tool_count': len(server_tools), 'description': server_config.get('description', f'{server_name} tools'), 'is_exclusive': server_config.get('is_exclusive', False), diff --git a/backend/tests/test_tool_details_in_config.py b/backend/tests/test_tool_details_in_config.py new file mode 100644 index 0000000..d2febf4 --- /dev/null +++ b/backend/tests/test_tool_details_in_config.py @@ -0,0 +1,110 @@ +"""Test that tool details (description and inputSchema) are included in config API response.""" + +import pytest +import sys +import os + +# Ensure backend is on path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from modules.mcp_tools.client import MCPToolManager + + +class FakeTool: + """Mock tool object for testing.""" + def __init__(self, name, description="", inputSchema=None): + self.name = name + self.description = description + self.inputSchema = inputSchema or {"type": "object", "properties": {}} + + +@pytest.fixture +def mock_mcp_manager(monkeypatch): + """Create a mock MCP manager with test data.""" + manager = MCPToolManager() + + # Mock available_tools with detailed tool information + manager.available_tools = { + "test_server": { + "tools": [ + FakeTool( + "test_tool", + "This is a test tool description", + { + "type": "object", + "properties": { + "arg1": { + "type": "string", + "description": "First argument" + }, + "arg2": { + "type": "number", + "description": "Second argument" + } + }, + "required": ["arg1"] + } + ) + ], + "config": { + "description": "Test server", + "is_exclusive": False, + "author": "Test Author" + } + } + } + + manager.available_prompts = {} + return manager + + +def test_tools_detailed_includes_description_and_schema(mock_mcp_manager): + """Test that tools_detailed field contains description and inputSchema.""" + server_tools = mock_mcp_manager.available_tools["test_server"]["tools"] + server_config = mock_mcp_manager.available_tools["test_server"]["config"] + + # Simulate what the config endpoint does + tools_detailed = [] + for tool in server_tools: + tool_detail = { + 'name': tool.name, + 'description': tool.description or '', + 'inputSchema': getattr(tool, 'inputSchema', {}) or {} + } + tools_detailed.append(tool_detail) + + # Verify the structure + assert len(tools_detailed) == 1 + assert tools_detailed[0]['name'] == 'test_tool' + assert tools_detailed[0]['description'] == 'This is a test tool description' + assert 'inputSchema' in tools_detailed[0] + assert 'properties' in tools_detailed[0]['inputSchema'] + assert 'arg1' in tools_detailed[0]['inputSchema']['properties'] + assert tools_detailed[0]['inputSchema']['properties']['arg1']['type'] == 'string' + assert tools_detailed[0]['inputSchema']['properties']['arg1']['description'] == 'First argument' + + +def test_canvas_tool_has_detailed_info(): + """Test that canvas pseudo-tool has detailed information.""" + canvas_tools_detailed = [{ + 'name': 'canvas', + 'description': 'Display final rendered content in a visual canvas panel. Use this for: 1) Complete code (not code discussions), 2) Final reports/documents (not report discussions), 3) Data visualizations, 4) Any polished content that should be viewed separately from the conversation.', + 'inputSchema': { + 'type': 'object', + 'properties': { + 'content': { + 'type': 'string', + 'description': 'The content to display in the canvas. Can be markdown, code, or plain text.' + } + }, + 'required': ['content'] + } + }] + + # Verify canvas tool structure + assert len(canvas_tools_detailed) == 1 + assert canvas_tools_detailed[0]['name'] == 'canvas' + assert 'description' in canvas_tools_detailed[0] + assert len(canvas_tools_detailed[0]['description']) > 0 + assert 'inputSchema' in canvas_tools_detailed[0] + assert 'content' in canvas_tools_detailed[0]['inputSchema']['properties'] diff --git a/frontend/src/components/ToolsPanel.jsx b/frontend/src/components/ToolsPanel.jsx index f4b7725..a3b6bc5 100644 --- a/frontend/src/components/ToolsPanel.jsx +++ b/frontend/src/components/ToolsPanel.jsx @@ -1,12 +1,15 @@ -import { X, Trash2, Search, Plus, Wrench, ChevronDown, ChevronUp, Shield } from 'lucide-react' +import { X, Trash2, Search, Plus, Wrench, Shield, Info } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { useState, useEffect } from 'react' import { useChat } from '../contexts/ChatContext' import { useMarketplace } from '../contexts/MarketplaceContext' +// Default type for schema properties without explicit type +const DEFAULT_PARAM_TYPE = 'any' + const ToolsPanel = ({ isOpen, onClose }) => { const [searchTerm, setSearchTerm] = useState('') - const [expandedServers, setExpandedServers] = useState(new Set()) + const [expandedTools, setExpandedTools] = useState(new Set()) const navigate = useNavigate() const { selectedTools, @@ -58,6 +61,7 @@ const ToolsPanel = ({ isOpen, onClose }) => { is_exclusive: toolServer.is_exclusive, compliance_level: toolServer.compliance_level, tools: toolServer.tools || [], + tools_detailed: toolServer.tools_detailed || [], tool_count: toolServer.tool_count || 0, prompts: [], prompt_count: 0 @@ -73,6 +77,7 @@ const ToolsPanel = ({ isOpen, onClose }) => { description: promptServer.description, is_exclusive: false, tools: [], + tools_detailed: [], tool_count: 0, prompts: promptServer.prompts || [], prompt_count: promptServer.prompt_count || 0 @@ -168,23 +173,6 @@ const ToolsPanel = ({ isOpen, onClose }) => { return allToolsSelected && promptSatisfied } - const ensureSinglePrompt = (promptKey) => { - // Deselect all other prompts - Array.from(selectedPrompts).forEach(existing => { - if (existing !== promptKey) togglePrompt(existing) - }) - if (!selectedPrompts.has(promptKey)) togglePrompt(promptKey) - } - - const handlePromptCheckbox = (promptKey) => { - if (selectedPrompts.has(promptKey)) { - // Deselect current prompt - togglePrompt(promptKey) - } else { - ensureSinglePrompt(promptKey) - } - } - // Backward compat helper retained but now references "all selected" semantics const isServerSelected = (serverName) => isServerAllSelected(serverName) @@ -285,14 +273,47 @@ const ToolsPanel = ({ isOpen, onClose }) => { } - const toggleServerExpansion = (serverName) => { - const newExpanded = new Set(expandedServers) - if (newExpanded.has(serverName)) { - newExpanded.delete(serverName) + const toggleToolExpansion = (toolKey) => { + const newExpanded = new Set(expandedTools) + if (newExpanded.has(toolKey)) { + newExpanded.delete(toolKey) } else { - newExpanded.add(serverName) + newExpanded.add(toolKey) + } + setExpandedTools(newExpanded) + } + + /** + * Renders the input schema parameters for a tool. + * @param {Object} schema - The JSON schema object containing properties and required fields + * @param {Object} schema.properties - Object mapping parameter names to their definitions + * @param {Array} [schema.required] - Array of required parameter names + * @returns {JSX.Element} Formatted display of input parameters with types and descriptions + */ + const renderInputSchema = (schema) => { + if (!schema || !schema.properties) { + return

No input parameters

} - setExpandedServers(newExpanded) + + const properties = schema.properties + const required = schema.required || [] + + return ( +
+ {Object.entries(properties).map(([paramName, paramDef]) => ( +
+ {paramName} + {required.includes(paramName) && ( + * + )} + ({paramDef.type || DEFAULT_PARAM_TYPE}) + {paramDef.description && ( +

{paramDef.description}

+ )} +
+ ))} +
+ ) } // (Legacy isServerSelected removed; new implementation above.) @@ -429,9 +450,6 @@ const ToolsPanel = ({ isOpen, onClose }) => { ) : (
{filteredServers.map(server => { - const isExpanded = expandedServers.has(server.server) - const hasIndividualItems = server.tools.length > 0 || server.prompts.length > 0 - return (
{/* Main Server Row */} @@ -468,20 +486,50 @@ const ToolsPanel = ({ isOpen, onClose }) => { {server.tools.map(tool => { const toolKey = `${server.server}_${tool}` const isSelected = selectedTools.has(toolKey) + const isToolExpanded = expandedTools.has(toolKey) + // Find detailed tool info + const toolDetail = server.tools_detailed?.find(t => t.name === tool) + return ( - + }} + className={`px-2 py-0.5 text-xs rounded text-white transition-colors hover:opacity-80 ${ + isSelected ? 'bg-blue-600' : 'bg-gray-600 hover:bg-blue-600' + }`} + title={`Click to ${isSelected ? 'disable' : 'enable'} ${tool}`} + > + {tool} + + {toolDetail && ( + + )} +
+ {isToolExpanded && toolDetail && ( +
+ {toolDetail.description && ( +
+

Description:

+

{toolDetail.description}

+
+ )} +
+

Input Arguments:

+ {renderInputSchema(toolDetail.inputSchema)} +
+
+ )} +
) })} @@ -559,98 +607,8 @@ const ToolsPanel = ({ isOpen, onClose }) => { {isServerAllSelected(server.server) ? 'All On' : 'Enable All'} - - {/* Expand Button */} - {hasIndividualItems && ( - - )} - - {/* Expanded Individual Tools Section */} - {isExpanded && hasIndividualItems && ( -
-
-

- Select individual tools and prompts: -

- - {/* Tools */} - {server.tools.length > 0 && ( -
-

Tools

-
- {server.tools.map(tool => { - const toolKey = `${server.server}_${tool}` - const isSelected = selectedTools.has(toolKey) - - return ( - - ) - })} -
-
- )} - - {/* Prompts */} - {server.prompts.length > 0 && ( -
-

Prompts

-
- {server.prompts.map(prompt => { - const promptKey = `${server.server}_${prompt.name}` - const isSelected = selectedPrompts.has(promptKey) - - return ( - - ) - })} -
-
- )} -
-
- )} ) })}