diff --git a/AGENTS.md b/AGENTS.md index 6942da5..289147d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1007,3 +1007,198 @@ Each registered tool must include: - **Conflict Resolution**: Handles tool name conflicts across apps - **Dependency Management**: Ensures app dependencies are properly loaded - **Version Compatibility**: Supports tool versioning and compatibility checks + +## MCP Client Integration + +HUF supports the Model Context Protocol (MCP) for connecting to external tool providers. This allows agents to use tools from external MCP servers like Gmail, GitHub, Slack, databases, and more. + +### Overview + +HUF acts as an **MCP client only** - it connects to external MCP servers to consume their tools, but does not expose itself as an MCP server or gateway. + +``` +HUF Agent + └── Tool Registry + ├── Native Tools (Frappe CRUD, Custom Functions, App Provided) + └── MCP Tools (External) + ├── Gmail MCP + ├── GitHub MCP + ├── Frappe MCP + └── Any MCP-compatible server +``` + +### MCP DocTypes + +#### MCP Server + +Stores connection configuration for external MCP servers. + +- **Python Class**: `MCPServer(Document)` +- **File**: `huf/huf/doctype/mcp_server/mcp_server.py` + +**Fields:** + +| Label | Fieldname | Type | Description | +|:------|:----------|:-----|:------------| +| **Server Name** | `server_name` | Data | Unique identifier (e.g., "gmail", "github") | +| **Description** | `description` | Small Text | What this MCP server provides | +| **Enabled** | `enabled` | Check | Whether this server is active | +| **Transport Type** | `transport_type` | Select | `http` or `sse` | +| **Server URL** | `server_url` | Data | MCP server endpoint URL | +| **Auth Type** | `auth_type` | Select | `none`, `api_key`, `bearer_token`, `custom_header` | +| **Auth Header Name** | `auth_header_name` | Data | Header name for authentication | +| **Auth Header Value** | `auth_header_value` | Password | Encrypted auth token/key | +| **Tool Namespace** | `tool_namespace` | Data | Optional prefix for tool names | +| **Timeout** | `timeout_seconds` | Int | Request timeout (default: 30s) | +| **Custom Headers** | `custom_headers` | Table | Additional HTTP headers | +| **Available Tools** | `available_tools` | JSON | Cached tools from server (read-only) | +| **Last Sync** | `last_sync` | Datetime | Last tool sync timestamp | + +**Server Actions:** +- `sync_tools()`: Fetch and cache available tools from the MCP server + +#### Agent MCP Server + +Child table linking agents to MCP servers. + +- **File**: `huf/huf/doctype/agent_mcp_server/agent_mcp_server.py` + +**Fields:** + +| Label | Fieldname | Type | Description | +|:------|:----------|:-----|:------------| +| **MCP Server** | `mcp_server` | Link | Link to `MCP Server` DocType | +| **Enabled** | `enabled` | Check | Whether enabled for this agent | +| **Tool Count** | `tool_count` | Int | Number of tools available (read-only) | + +### MCP Client Module + +#### `mcp_client.py` + +Core MCP client adapter located at `huf/ai/mcp_client.py`. + +**Key Functions:** + +- **`create_mcp_tools(agent_doc)`** + - Creates FunctionTool objects for all MCP tools available to an agent + - Called from `sdk_tools.create_agent_tools()` + - Returns list of `FunctionTool` objects + +- **`execute_mcp_tool(server_name, tool_name, arguments)`** + - Executes a tool call on an MCP server + - Uses LiteLLM's experimental MCP client when available + - Falls back to direct HTTP/JSON-RPC if needed + +- **`sync_mcp_server_tools(server_name)` (Whitelisted)** + - Fetches and caches available tools from an MCP server + - Uses LiteLLM `load_mcp_tools()` with OpenAI format conversion + +- **`test_mcp_connection(server_name)` (Whitelisted)** + - Tests connectivity to an MCP server + +- **`get_agent_mcp_servers(agent_name)` (Whitelisted)** + - Gets MCP servers linked to an agent with full details + +- **`get_available_mcp_servers()` (Whitelisted)** + - Gets all enabled MCP servers + +### Tool Loading Flow + +When an agent runs, tools are loaded in this order: + +1. **MCP Tools**: From linked MCP servers via `agent_mcp_server` child table +2. **Native Tools**: From `Agent Tool Function` documents via `agent_tool` child table + +```python +# In sdk_tools.py +def create_agent_tools(agent) -> list[FunctionTool]: + tools = [] + + # 1. Load MCP tools from linked MCP servers + if hasattr(agent, "agent_mcp_server") and agent.agent_mcp_server: + from huf.ai.mcp_client import create_mcp_tools + mcp_tools = create_mcp_tools(agent) + tools.extend(mcp_tools) + + # 2. Load native tools from Agent Tool Function documents + if hasattr(agent, "agent_tool") and agent.agent_tool: + # ... existing native tool loading ... + + return tools +``` + +### MCP Tool Execution Flow + +When the LLM calls an MCP tool during agent execution: + +1. LLM returns tool call with tool name and arguments +2. `litellm.py` finds the tool by name in the agent's tools list +3. Tool's `on_invoke_tool()` is called (same as native tools) +4. For MCP tools, this triggers `execute_mcp_tool()` which: + - Builds authentication headers from MCP Server config + - Calls the MCP server via HTTP/JSON-RPC or LiteLLM MCP client + - Returns the result in HUF tool-result format +5. Result is fed back to LLM for next iteration + +### Authentication + +MCP servers support multiple authentication methods: + +- **None**: No authentication required +- **API Key**: Custom header with API key value +- **Bearer Token**: Standard `Authorization: Bearer ` header +- **Custom Header**: Any custom header name/value pair + +Auth credentials are stored encrypted using Frappe's Password field type. + +### Tool Namespacing + +MCP tools can be namespaced to avoid conflicts: + +- If `tool_namespace` is set on MCP Server (e.g., "gmail") +- Tool names are prefixed: `send_email` → `gmail.send_email` +- Tool descriptions include source: `[MCP:gmail] Send an email...` + +### Dependencies + +The MCP client uses: + +- **LiteLLM's experimental MCP client** (`litellm.experimental_mcp_client`) + - `load_mcp_tools()`: Fetch tools in OpenAI format + - `call_openai_tool()`: Execute tool calls +- **Direct HTTP fallback**: If LiteLLM MCP client unavailable + - Uses JSON-RPC 2.0 format for MCP protocol + +### Frontend Integration + +The React frontend supports MCP server management: + +- **ToolsTab Component** (`frontend/src/components/agent/ToolsTab.tsx`) + - Displays linked MCP servers with status badges + - Supports add/remove/toggle/sync actions + - Shows tool count and last sync time + +- **MCP API Service** (`frontend/src/services/mcpApi.ts`) + - `getMCPServers()`: List all MCP servers + - `getAgentMCPServers()`: Get MCP servers for an agent + - `testMCPConnection()`: Test server connectivity + - `syncMCPTools()`: Sync tools from server + +### Example Usage + +1. **Create MCP Server** in Frappe: + - Name: "github" + - URL: "https://mcp.github.example.com/mcp" + - Auth: Bearer Token with GitHub PAT + +2. **Sync Tools** to discover available tools + +3. **Link to Agent** via the "Tools and MCP" tab + +4. **Agent can now use** GitHub tools like `list_prs`, `create_issue`, etc. + +### What HUF Is NOT + +- ❌ An MCP Server (does not expose tools via MCP) +- ❌ An MCP Gateway/Proxy +- ❌ An OAuth broker (simple header-based auth only) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9128d65..8bb72e3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { UnifiedLayout } from './layouts/UnifiedLayout'; import { HomeHeaderActions } from './components/HomeHeaderActions'; import { AgentsHeaderActions } from './components/AgentsHeaderActions'; import { ChatHeaderActions } from './components/ChatHeaderActions'; +import { McpHeaderActions } from './components/McpHeaderActions'; import { HomePage } from './pages/HomePage'; import { AgentsPage } from './pages/AgentsPage'; import { AgentFormPageWrapper } from './pages/AgentFormPageWrapper'; @@ -23,6 +24,8 @@ import Executions from './pages/Executions'; import { AgentRunDetailPage } from './pages/AgentRunDetailPage'; import { useEffect } from 'react'; import { createFrappeSocket } from './utils/socket'; +import { McpDetailsPageWrapper } from './pages/McpDetailsPageWrapper'; +import McpListingPage from './pages/McpListingPage'; function App() { useEffect(() => { @@ -196,6 +199,24 @@ function App() { } /> + + }> + + + + } + /> + + + + } + /> { + navigate('/mcp/new'); + }; + + return ( + + ); +} + diff --git a/frontend/src/components/agent/ToolsTab.tsx b/frontend/src/components/agent/ToolsTab.tsx index 71f809d..b19a551 100644 --- a/frontend/src/components/agent/ToolsTab.tsx +++ b/frontend/src/components/agent/ToolsTab.tsx @@ -1,31 +1,24 @@ -import { Plus, Server, Plug, Trash2 } from 'lucide-react'; +import { Plus, Server, Plug, Trash2, RefreshCw } from 'lucide-react'; +import { Link } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { toast } from 'sonner'; import type { AgentToolFunctionRef, AgentToolType } from '@/types/agent.types'; - -interface MCPItem { - id: string; - name: string; - description: string; - provider: string; - status: 'connected' | 'inactive'; -} - -const mockMCPs: MCPItem[] = [ - { id: 'm1', name: 'Zendesk MCP', description: 'Query and manage Zendesk tickets', provider: 'Zendesk', status: 'connected' }, - { id: 'm2', name: 'Slack MCP', description: 'Send messages and read channels', provider: 'Slack', status: 'connected' }, - { id: 'm3', name: 'PostgreSQL MCP', description: 'Query customer database', provider: 'PostgreSQL', status: 'connected' }, - { id: 'm4', name: 'Stripe MCP', description: 'Access payment and subscription data', provider: 'Stripe', status: 'inactive' }, -]; +import type { MCPServerRef } from '@/services/mcpApi'; interface ToolsTabProps { selectedTools: AgentToolFunctionRef[]; toolTypes: AgentToolType[]; onAddTools: () => void; onRemoveTool: (toolId: string) => void; + // MCP Server props + mcpServers?: MCPServerRef[]; + onAddMCP?: () => void; + onRemoveMCP?: (serverId: string) => void; + onToggleMCP?: (serverId: string, enabled: boolean) => void; + onSyncMCP?: (serverId: string) => void; + mcpLoading?: boolean; } export function ToolsTab({ @@ -33,9 +26,67 @@ export function ToolsTab({ toolTypes, onAddTools, onRemoveTool, + mcpServers = [], + onAddMCP, + onRemoveMCP, + onToggleMCP, + onSyncMCP, + mcpLoading = false, }: ToolsTabProps) { + + const handleMCPAction = (action: string, serverId?: string) => { + switch (action) { + case 'add': + onAddMCP?.(); + break; + case 'remove': + if (serverId) { + onRemoveMCP?.(serverId); + } + break; + case 'toggle': + if (serverId && onToggleMCP) { + const server = mcpServers.find(s => s.name === serverId); + if (server) { + // Toggle the enabled state - normalize current value first + const currentEnabled = isEnabled(server.enabled); + onToggleMCP(serverId, !currentEnabled); + } + } + break; + case 'sync': + if (serverId) { + onSyncMCP?.(serverId); + } + break; + } + }; + + // Helper to normalize enabled state (handles both boolean and number 0/1) + // If undefined, defaults to true (assume enabled if not specified) + const isEnabled = (value: boolean | number | undefined): boolean => { + if (value === undefined) return true; // Default to enabled if not specified + return value === true || value === 1; + }; + + const getStatusBadge = (server: MCPServerRef) => { + const agentEnabled = isEnabled(server.enabled); + + // If MCP server itself is explicitly disabled (not undefined), show "server disabled" + if (server.mcp_enabled !== undefined && !isEnabled(server.mcp_enabled)) { + return server disabled; + } + // If MCP server is enabled (or unknown) but agent has it disabled, show "disabled" + if (!agentEnabled) { + return disabled; + } + // Both enabled - show "connected" + return connected; + }; + return ( <> + {/* Native Tools Section */}
@@ -101,6 +152,7 @@ export function ToolsTab({ + {/* MCP Servers Section */}
@@ -111,11 +163,12 @@ export function ToolsTab({ Connected MCP servers for extended capabilities
-
-
- {mockMCPs.map((mcp) => ( -
+
+
+ +
+
+

No MCP servers connected

+

+ Connect external MCP servers to extend agent capabilities with tools like Gmail, GitHub, Slack, and more. +

+ +
+ ) : ( +
+ {mcpServers.map((mcp) => ( +
+ +
+

{mcp.server_name || mcp.mcp_server}

+ {getStatusBadge(mcp)} + {mcp.tool_count !== undefined && mcp.tool_count > 0 && ( + + {mcp.tool_count} tools + + )} +
+ {mcp.description && ( +

{mcp.description}

+ )} + {mcp.server_url && ( +

+ {mcp.server_url} +

+ )} + +
e.stopPropagation()}> + + { + // Only allow toggle if MCP server is enabled (or unknown/undefined) + if (!mcpLoading && (mcp.mcp_enabled === undefined || isEnabled(mcp.mcp_enabled))) { + handleMCPAction('toggle', mcp.name); + } + }} + /> +
-

{mcp.description}

-
- - -
-
- ))} -
+ ))} + + )}
); -} - +} \ No newline at end of file diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index ba67edc..2e4f5ce 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -1,5 +1,5 @@ import * as React from "react" -import { Home, Bot, Workflow, Database, Plug, MessageSquare, Zap } from "lucide-react" +import { Home, Bot, Workflow, Database, Plug, MessageSquare, Zap, Server } from "lucide-react" import { NavMain } from "@/components/nav-main" import { NavUser } from "@/components/nav-user" @@ -48,6 +48,11 @@ const navItems = [ url: "/providers", icon: Plug, }, + { + title: "MCP Servers", + url: "/mcp", + icon: Server, + }, ] // const systemItems = [ diff --git a/frontend/src/components/dashboard/LoadMoreButton.tsx b/frontend/src/components/dashboard/LoadMoreButton.tsx new file mode 100644 index 0000000..dd410d1 --- /dev/null +++ b/frontend/src/components/dashboard/LoadMoreButton.tsx @@ -0,0 +1,40 @@ +import { Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface LoadMoreButtonProps { + hasMore: boolean; + loading: boolean; + onLoadMore: () => void; + disabled?: boolean; +} + +export function LoadMoreButton({ + hasMore, + loading, + onLoadMore, + disabled = false, +}: LoadMoreButtonProps) { + if (!hasMore || disabled) { + return null; + } + + return ( +
+ +
+ ); +} + diff --git a/frontend/src/components/dashboard/cards/BaseCard.tsx b/frontend/src/components/dashboard/cards/BaseCard.tsx index 49d4932..691717d 100644 --- a/frontend/src/components/dashboard/cards/BaseCard.tsx +++ b/frontend/src/components/dashboard/cards/BaseCard.tsx @@ -18,7 +18,7 @@ export function BaseCard({ return ( + +
{title} {description && ( @@ -61,7 +63,9 @@ export function ItemCard({ {(metadata.length > 0 || actions.length > 0 || footer) && ( - + + {metadata.length > 0 && ( +
{metadata.map((item, index) => (
{item.value}
))} +
+ )} + {(actions.length > 0 || footer) && ( +
{actions.length > 0 && ( -
+
{actions.map((action, index) => (
} +
+ )} )} +
); } diff --git a/frontend/src/components/dashboard/cards/SkeletonCard.tsx b/frontend/src/components/dashboard/cards/SkeletonCard.tsx new file mode 100644 index 0000000..49d8d19 --- /dev/null +++ b/frontend/src/components/dashboard/cards/SkeletonCard.tsx @@ -0,0 +1,45 @@ +import { BaseCard } from './BaseCard'; +import { CardHeader, CardContent } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; + +interface SkeletonCardProps { + className?: string; +} + +export function SkeletonCard({ className }: SkeletonCardProps) { + return ( + +
+ +
+
+ + + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+
+
+ ); +} + diff --git a/frontend/src/components/dashboard/index.ts b/frontend/src/components/dashboard/index.ts index 12a3bcc..21547a5 100644 --- a/frontend/src/components/dashboard/index.ts +++ b/frontend/src/components/dashboard/index.ts @@ -2,6 +2,7 @@ export { PageLayout } from './layouts/PageLayout'; export { PageSection } from './layouts/PageSection'; export { GridView } from './views/GridView'; +export { SkeletonGridView } from './views/SkeletonGridView'; export { ActiveAgentsTab } from './views/ActiveAgentsTab'; export { ActiveFlowsTab } from './views/ActiveFlowsTab'; export { RecentExecutionsTab } from './views/RecentExecutionsTab'; @@ -12,3 +13,6 @@ export { FilterBar } from './filters/FilterBar'; export { BaseCard } from './cards/BaseCard'; export { StatCard } from './cards/StatCard'; export { ItemCard } from './cards/ItemCard'; +export { SkeletonCard } from './cards/SkeletonCard'; + +export { LoadMoreButton } from './LoadMoreButton'; \ No newline at end of file diff --git a/frontend/src/components/dashboard/views/GridView.tsx b/frontend/src/components/dashboard/views/GridView.tsx index d6f7986..611aa9c 100644 --- a/frontend/src/components/dashboard/views/GridView.tsx +++ b/frontend/src/components/dashboard/views/GridView.tsx @@ -1,7 +1,8 @@ import { ReactNode } from 'react'; import { cn } from '@/lib/utils'; +import { SkeletonGridView } from './SkeletonGridView'; -interface GridViewColumns { +export interface GridViewColumns { sm?: number; md?: number; lg?: number; @@ -72,7 +73,7 @@ const gapMap: Record = { 8: 'gap-8', }; -function getGridClasses(columns: GridViewColumns, gap: number = 4): string { +export function getGridClasses(columns: GridViewColumns, gap: number = 4): string { const classes = ['grid']; if (columns.sm && columnMap[columns.sm]) { @@ -106,11 +107,7 @@ export function GridView({ className, }: GridViewProps) { if (loading) { - return ( -
-
Loading...
-
- ); + return ; } if (items.length === 0) { diff --git a/frontend/src/components/dashboard/views/SkeletonGridView.tsx b/frontend/src/components/dashboard/views/SkeletonGridView.tsx new file mode 100644 index 0000000..e40a4e7 --- /dev/null +++ b/frontend/src/components/dashboard/views/SkeletonGridView.tsx @@ -0,0 +1,25 @@ +import { SkeletonCard } from '../cards/SkeletonCard'; +import { getGridClasses, GridViewColumns } from './GridView'; +import { cn } from '@/lib/utils'; + +interface SkeletonGridViewProps { + columns?: GridViewColumns; + gap?: number; + count?: number; + className?: string; +} + +export function SkeletonGridView({ + columns = { sm: 1, md: 2, lg: 3 }, + gap = 4, + count = 6, + className, +}: SkeletonGridViewProps) { + return ( +
+ {Array.from({ length: count }).map((_, index) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/mcp/ConnectionTab.tsx b/frontend/src/components/mcp/ConnectionTab.tsx new file mode 100644 index 0000000..2f4f18f --- /dev/null +++ b/frontend/src/components/mcp/ConnectionTab.tsx @@ -0,0 +1,163 @@ +import { useEffect } from 'react'; +import { FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { UseFormReturn } from 'react-hook-form'; +import type { MCPFormValues } from './types'; +import { mcpAuthTypes, mcpAuthHeaderNames, mcpTransportTypes } from '@/data/mcp'; + +interface ConnectionTabProps { + form: UseFormReturn; +} + +export function ConnectionTab({ form }: ConnectionTabProps) { + const watchAuthType = form.watch('auth_type'); + + // Auto-fill auth_header_name based on auth_type + useEffect(() => { + if (watchAuthType && watchAuthType !== 'none') { + const headerName = mcpAuthHeaderNames[watchAuthType]; + if (headerName) { + form.setValue('auth_header_name', headerName, { shouldDirty: false }); + } + } else if (watchAuthType === 'none') { + // Clear auth fields when auth_type is 'none' + form.setValue('auth_header_name', '', { shouldDirty: false }); + form.setValue('auth_header_value', '', { shouldDirty: false }); + } + }, [watchAuthType, form]); + + const showAuthFields = watchAuthType && watchAuthType !== 'none'; + + return ( + + + Connection Settings + Configure authentication and connection parameters + + + ( + + Transport Type + + Communication protocol for MCP server + + + )} + /> + + ( + + Server URL + + + + MCP server endpoint URL (e.g., 'https://mcp.example.com/mcp') + + + )} + /> + + ( + + Authentication Type + + Select the authentication method for this MCP server + + + )} + /> + + {showAuthFields && ( + <> + ( + + Auth Header Name + + + + + Header name for authentication (e.g., 'Authorization', 'X-API-Key') + {watchAuthType !== 'custom_header' && ' (auto-filled based on auth type, but can be edited)'} + + + + )} + /> + + ( + + Auth Header Value + + + + The API key, bearer token, or header value (stored encrypted) + + + )} + /> + + )} + + + ); +} + diff --git a/frontend/src/components/mcp/DetailsTab.tsx b/frontend/src/components/mcp/DetailsTab.tsx new file mode 100644 index 0000000..aee66bb --- /dev/null +++ b/frontend/src/components/mcp/DetailsTab.tsx @@ -0,0 +1,118 @@ +import { FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Switch } from '@/components/ui/switch'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { UseFormReturn } from 'react-hook-form'; +import type { MCPFormValues } from './types'; + +interface DetailsTabProps { + form: UseFormReturn; + isNew: boolean; +} + +export function DetailsTab({ form, isNew }: DetailsTabProps) { + return ( + + + Server Details + Configure MCP server basic information + + + {isNew && ( + ( + + Server Name + + + + Unique name for this MCP server + + + )} + /> + )} + + ( + +
+ Enabled + + Enable or disable this MCP server + +
+ + + +
+ )} + /> + + ( + + Description + +