From ba320e1a6907e13a39f7f4debae6e809e232c0d0 Mon Sep 17 00:00:00 2001 From: esafwan Date: Wed, 31 Dec 2025 19:57:04 +0530 Subject: [PATCH 01/29] feat(backend): Add MCP Server Data Model - Add 'MCP Server' DocType for storing connection configs (URL, auth, headers) - Add 'MCP Server Header' child table for custom headers - Add 'Agent MCP Server' child table for linking agents to servers - Update 'Agent' DocType schema to include MCP servers list --- huf/huf/doctype/agent/agent.json | 551 +++++++++--------- .../agent_mcp_server/agent_mcp_server.json | 57 ++ .../agent_mcp_server/agent_mcp_server.py | 29 + huf/huf/doctype/mcp_server/mcp_server.js | 59 ++ huf/huf/doctype/mcp_server/mcp_server.json | 181 ++++++ huf/huf/doctype/mcp_server/mcp_server.py | 41 ++ .../mcp_server_header/mcp_server_header.json | 38 ++ .../mcp_server_header/mcp_server_header.py | 8 + 8 files changed, 696 insertions(+), 268 deletions(-) create mode 100644 huf/huf/doctype/agent_mcp_server/agent_mcp_server.json create mode 100644 huf/huf/doctype/agent_mcp_server/agent_mcp_server.py create mode 100644 huf/huf/doctype/mcp_server/mcp_server.js create mode 100644 huf/huf/doctype/mcp_server/mcp_server.json create mode 100644 huf/huf/doctype/mcp_server/mcp_server.py create mode 100644 huf/huf/doctype/mcp_server_header/mcp_server_header.json create mode 100644 huf/huf/doctype/mcp_server_header/mcp_server_header.py diff --git a/huf/huf/doctype/agent/agent.json b/huf/huf/doctype/agent/agent.json index 3c11058..7996e0a 100644 --- a/huf/huf/doctype/agent/agent.json +++ b/huf/huf/doctype/agent/agent.json @@ -1,270 +1,285 @@ { - "actions": [], - "allow_rename": 1, - "autoname": "field:agent_name", - "creation": "2025-08-11 18:33:44.565632", - "doctype": "DocType", - "engine": "InnoDB", - "field_order": [ - "general_tab", - "llm_configuration_section", - "agent_name", - "provider", - "chef", - "slug", - "temperature", - "description", - "column_break_df8m", - "model", - "async", - "top_p", - "disabled", - "behaviour_tab", - "conversation_settings_section", - "persist_user_history", - "allow_chat", - "enable_multi_run", - "column_break_vfhq", - "persist_conversation", - "section_break_yyoa", - "instructions", - "tools_and_mcp_tab", - "agent_tool", - "permissions_tab", - "allowed_users", - "column_break_dq1z", - "allowed_roles", - "metadata_tab", - "last_run", - "total_run" - ], - "fields": [ - { - "description": "A unique name for this agent.", - "documentation_url": "https://docs.huf.ai/docs/concepts/agents/", - "fieldname": "agent_name", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Agent Name", - "reqd": 1, - "unique": 1 - }, - { - "description": "The AI provider that will power this agent (e.g., OpenAI, OpenRouter).", - "documentation_url": "https://docs.huf.ai/docs/concepts/providers-models/", - "fieldname": "provider", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Provider", - "options": "AI Provider", - "reqd": 1 - }, - { - "fieldname": "column_break_df8m", - "fieldtype": "Column Break" - }, - { - "description": "The specific AI model to use from the selected provider (e.g., gpt-4-turbo).", - "documentation_url": "https://docs.huf.ai/docs/concepts/providers-models/", - "fieldname": "model", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Model", - "options": "AI Model", - "reqd": 1 - }, - { - "description": "The system prompt or instructions that define the agent's personality, goals, and constraints. This is the core logic of the agent.", - "fieldname": "instructions", - "fieldtype": "Code", - "label": "Instructions" - }, - { - "default": "1", - "description": "What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.", - "fieldname": "temperature", - "fieldtype": "Float", - "label": "Temperature", - "non_negative": 1 - }, - { - "description": "The set of tools this agent is allowed to use to interact with the system.", - "documentation_url": "https://docs.huf.ai/docs/concepts/tools/", - "fieldname": "agent_tool", - "fieldtype": "Table", - "label": "Agent Tool", - "options": "Agent Tool" - }, - { - "default": "1", - "description": "An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered.\n\nWe generally recommend altering this or temperature but not both.", - "fieldname": "top_p", - "fieldtype": "Float", - "label": "Top P" - }, - { - "default": "0", - "fieldname": "async", - "fieldtype": "Check", - "hidden": 1, - "label": "Async" - }, - { - "default": "0", - "description": "If checked, this agent will be disabled and will not run.", - "fieldname": "disabled", - "fieldtype": "Check", - "label": "Disabled" - }, - { - "description": "Define system prompt, goal, and constraints", - "fieldname": "section_break_yyoa", - "fieldtype": "Section Break", - "label": "Instruction" - }, - { - "default": "0", - "description": "If checked, this agent can be interacted with in the Agent Chat window.", - "fieldname": "allow_chat", - "fieldtype": "Check", - "label": "Allow Chat" - }, - { - "default": "1", - "description": "If checked, the conversation history with this agent will be saved and loaded for future sessions.", - "fieldname": "persist_conversation", - "fieldtype": "Check", - "label": "Persist Conversation" - }, - { - "fieldname": "column_break_vfhq", - "fieldtype": "Column Break" - }, - { - "fieldname": "general_tab", - "fieldtype": "Tab Break", - "label": "General" - }, - { - "fieldname": "behaviour_tab", - "fieldtype": "Tab Break", - "label": "Behaviour" - }, - { - "fieldname": "tools_and_mcp_tab", - "fieldtype": "Tab Break", - "label": "Tools and MCP" - }, - { - "description": "Configure language model settings ", - "fieldname": "llm_configuration_section", - "fieldtype": "Section Break", - "label": "LLM Configuration" - }, - { - "description": "Configure conversation behaviour", - "fieldname": "conversation_settings_section", - "fieldtype": "Section Break", - "label": "Conversation Settings" - }, - { - "description": "A short summary describing what this agent does or is designed for.", - "fieldname": "description", - "fieldtype": "Small Text", - "label": "Description" - }, - { - "fieldname": "last_run", - "fieldtype": "Datetime", - "label": "Last Run", - "read_only": 1 - }, - { - "fieldname": "total_run", - "fieldtype": "Int", - "label": "Total Run", - "read_only": 1 - }, - { - "fieldname": "metadata_tab", - "fieldtype": "Tab Break", - "label": "Metadata" - }, - { - "default": "1", - "description": "When checked, Doc Event and Scheduled runs create / maintain conversation history per initiating user (or trigger owner). If unchecked, a single shared history is used.", - "fieldname": "persist_user_history", - "fieldtype": "Check", - "label": "Persist per User (Doc/Schedule)" - }, - { - "description": "This is the provider standard name", - "fetch_from": "provider.chef", - "fieldname": "chef", - "fieldtype": "Data", - "hidden": 1, - "label": "Chef" - }, - { - "fetch_from": "provider.slug", - "fieldname": "slug", - "fieldtype": "Data", - "hidden": 1, - "label": "Slug" - }, - { - "default": "0", - "fieldname": "enable_multi_run", - "fieldtype": "Check", - "label": "Enable Multi Run" - }, - { - "fieldname": "permissions_tab", - "fieldtype": "Tab Break", - "label": "Permissions" - }, - { - "fieldname": "allowed_users", - "fieldtype": "Table MultiSelect", - "label": "Allowed Users", - "options": "Agent User" - }, - { - "fieldname": "column_break_dq1z", - "fieldtype": "Column Break" - }, - { - "fieldname": "allowed_roles", - "fieldtype": "Table MultiSelect", - "label": "Allowed Roles", - "options": "Agent Role" - } - ], - "grid_page_length": 50, - "index_web_pages_for_search": 1, - "links": [], - "modified": "2025-12-05 12:57:58.416884", - "modified_by": "Administrator", - "module": "Huf", - "name": "Agent", - "naming_rule": "By fieldname", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "row_format": "Dynamic", - "sort_field": "modified", - "sort_order": "DESC", - "states": [] + "actions": [], + "allow_rename": 1, + "autoname": "field:agent_name", + "creation": "2025-08-11 18:33:44.565632", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "general_tab", + "llm_configuration_section", + "agent_name", + "provider", + "chef", + "slug", + "temperature", + "description", + "column_break_df8m", + "model", + "async", + "top_p", + "disabled", + "behaviour_tab", + "conversation_settings_section", + "persist_user_history", + "allow_chat", + "enable_multi_run", + "column_break_vfhq", + "persist_conversation", + "section_break_yyoa", + "instructions", + "tools_and_mcp_tab", + "agent_tool", + "mcp_servers_section", + "agent_mcp_server", + "permissions_tab", + "allowed_users", + "column_break_dq1z", + "allowed_roles", + "metadata_tab", + "last_run", + "total_run" + ], + "fields": [ + { + "description": "A unique name for this agent.", + "documentation_url": "https://docs.huf.ai/docs/concepts/agents/", + "fieldname": "agent_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Agent Name", + "reqd": 1, + "unique": 1 + }, + { + "description": "The AI provider that will power this agent (e.g., OpenAI, OpenRouter).", + "documentation_url": "https://docs.huf.ai/docs/concepts/providers-models/", + "fieldname": "provider", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Provider", + "options": "AI Provider", + "reqd": 1 + }, + { + "fieldname": "column_break_df8m", + "fieldtype": "Column Break" + }, + { + "description": "The specific AI model to use from the selected provider (e.g., gpt-4-turbo).", + "documentation_url": "https://docs.huf.ai/docs/concepts/providers-models/", + "fieldname": "model", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Model", + "options": "AI Model", + "reqd": 1 + }, + { + "description": "The system prompt or instructions that define the agent's personality, goals, and constraints. This is the core logic of the agent.", + "fieldname": "instructions", + "fieldtype": "Code", + "label": "Instructions" + }, + { + "default": "1", + "description": "What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.", + "fieldname": "temperature", + "fieldtype": "Float", + "label": "Temperature", + "non_negative": 1 + }, + { + "description": "The set of tools this agent is allowed to use to interact with the system.", + "documentation_url": "https://docs.huf.ai/docs/concepts/tools/", + "fieldname": "agent_tool", + "fieldtype": "Table", + "label": "Agent Tool", + "options": "Agent Tool" + }, + { + "fieldname": "mcp_servers_section", + "fieldtype": "Section Break", + "label": "MCP Servers", + "description": "Connect to external MCP servers for additional tool capabilities" + }, + { + "description": "External MCP servers this agent can use for additional tools", + "fieldname": "agent_mcp_server", + "fieldtype": "Table", + "label": "MCP Servers", + "options": "Agent MCP Server" + }, + { + "default": "1", + "description": "An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered.\n\nWe generally recommend altering this or temperature but not both.", + "fieldname": "top_p", + "fieldtype": "Float", + "label": "Top P" + }, + { + "default": "0", + "fieldname": "async", + "fieldtype": "Check", + "hidden": 1, + "label": "Async" + }, + { + "default": "0", + "description": "If checked, this agent will be disabled and will not run.", + "fieldname": "disabled", + "fieldtype": "Check", + "label": "Disabled" + }, + { + "description": "Define system prompt, goal, and constraints", + "fieldname": "section_break_yyoa", + "fieldtype": "Section Break", + "label": "Instruction" + }, + { + "default": "0", + "description": "If checked, this agent can be interacted with in the Agent Chat window.", + "fieldname": "allow_chat", + "fieldtype": "Check", + "label": "Allow Chat" + }, + { + "default": "1", + "description": "If checked, the conversation history with this agent will be saved and loaded for future sessions.", + "fieldname": "persist_conversation", + "fieldtype": "Check", + "label": "Persist Conversation" + }, + { + "fieldname": "column_break_vfhq", + "fieldtype": "Column Break" + }, + { + "fieldname": "general_tab", + "fieldtype": "Tab Break", + "label": "General" + }, + { + "fieldname": "behaviour_tab", + "fieldtype": "Tab Break", + "label": "Behaviour" + }, + { + "fieldname": "tools_and_mcp_tab", + "fieldtype": "Tab Break", + "label": "Tools and MCP" + }, + { + "description": "Configure language model settings ", + "fieldname": "llm_configuration_section", + "fieldtype": "Section Break", + "label": "LLM Configuration" + }, + { + "description": "Configure conversation behaviour", + "fieldname": "conversation_settings_section", + "fieldtype": "Section Break", + "label": "Conversation Settings" + }, + { + "description": "A short summary describing what this agent does or is designed for.", + "fieldname": "description", + "fieldtype": "Small Text", + "label": "Description" + }, + { + "fieldname": "last_run", + "fieldtype": "Datetime", + "label": "Last Run", + "read_only": 1 + }, + { + "fieldname": "total_run", + "fieldtype": "Int", + "label": "Total Run", + "read_only": 1 + }, + { + "fieldname": "metadata_tab", + "fieldtype": "Tab Break", + "label": "Metadata" + }, + { + "default": "1", + "description": "When checked, Doc Event and Scheduled runs create / maintain conversation history per initiating user (or trigger owner). If unchecked, a single shared history is used.", + "fieldname": "persist_user_history", + "fieldtype": "Check", + "label": "Persist per User (Doc/Schedule)" + }, + { + "description": "This is the provider standard name", + "fetch_from": "provider.chef", + "fieldname": "chef", + "fieldtype": "Data", + "hidden": 1, + "label": "Chef" + }, + { + "fetch_from": "provider.slug", + "fieldname": "slug", + "fieldtype": "Data", + "hidden": 1, + "label": "Slug" + }, + { + "default": "0", + "fieldname": "enable_multi_run", + "fieldtype": "Check", + "label": "Enable Multi Run" + }, + { + "fieldname": "permissions_tab", + "fieldtype": "Tab Break", + "label": "Permissions" + }, + { + "fieldname": "allowed_users", + "fieldtype": "Table MultiSelect", + "label": "Allowed Users", + "options": "Agent User" + }, + { + "fieldname": "column_break_dq1z", + "fieldtype": "Column Break" + }, + { + "fieldname": "allowed_roles", + "fieldtype": "Table MultiSelect", + "label": "Allowed Roles", + "options": "Agent Role" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-12-05 12:57:58.416884", + "modified_by": "Administrator", + "module": "Huf", + "name": "Agent", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/huf/huf/doctype/agent_mcp_server/agent_mcp_server.json b/huf/huf/doctype/agent_mcp_server/agent_mcp_server.json new file mode 100644 index 0000000..2a9daea --- /dev/null +++ b/huf/huf/doctype/agent_mcp_server/agent_mcp_server.json @@ -0,0 +1,57 @@ +{ + "actions": [], + "creation": "2025-12-31 18:59:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "mcp_server", + "enabled", + "server_url", + "tool_count" + ], + "fields": [ + { + "fieldname": "mcp_server", + "fieldtype": "Link", + "label": "MCP Server", + "options": "MCP Server", + "reqd": 1, + "in_list_view": 1 + }, + { + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled", + "default": "1", + "in_list_view": 1 + }, + { + "fieldname": "server_url", + "fieldtype": "Data", + "label": "Server URL", + "fetch_from": "mcp_server.server_url", + "read_only": 1, + "in_list_view": 1 + }, + { + "fieldname": "tool_count", + "fieldtype": "Int", + "label": "Tools", + "read_only": 1, + "in_list_view": 1, + "description": "Number of tools available from this MCP server" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-12-31 18:59:00.000000", + "modified_by": "Administrator", + "module": "Huf", + "name": "Agent MCP Server", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/huf/huf/doctype/agent_mcp_server/agent_mcp_server.py b/huf/huf/doctype/agent_mcp_server/agent_mcp_server.py new file mode 100644 index 0000000..23751f0 --- /dev/null +++ b/huf/huf/doctype/agent_mcp_server/agent_mcp_server.py @@ -0,0 +1,29 @@ +# Copyright (c) 2025, Tridz Technologies Pvt Ltd +# For license information, please see license.txt + +import json +import frappe +from frappe.model.document import Document + + +class AgentMCPServer(Document): + def before_insert(self): + """Fetch tool count from MCP server""" + self._update_tool_count() + + def before_save(self): + """Update tool count on save""" + self._update_tool_count() + + def _update_tool_count(self): + """Update the tool count from the linked MCP server""" + if self.mcp_server: + try: + mcp_doc = frappe.get_doc("MCP Server", self.mcp_server) + if mcp_doc.available_tools: + tools = json.loads(mcp_doc.available_tools) + self.tool_count = len(tools) if isinstance(tools, list) else 0 + else: + self.tool_count = 0 + except Exception: + self.tool_count = 0 diff --git a/huf/huf/doctype/mcp_server/mcp_server.js b/huf/huf/doctype/mcp_server/mcp_server.js new file mode 100644 index 0000000..70c7042 --- /dev/null +++ b/huf/huf/doctype/mcp_server/mcp_server.js @@ -0,0 +1,59 @@ +// Copyright (c) 2025, Tridz Technologies Pvt Ltd +// For license information, please see license.txt + +frappe.ui.form.on("MCP Server", { + refresh(frm) { + // Add sync tools button handler + if (!frm.is_new()) { + frm.add_custom_button(__("Sync Tools"), function() { + frm.call({ + method: "sync_tools", + doc: frm.doc, + freeze: true, + freeze_message: __("Syncing tools from MCP server..."), + callback: function(r) { + if (r.message && r.message.success) { + frm.reload_doc(); + } + } + }); + }, __("Actions")); + + // Add test connection button + frm.add_custom_button(__("Test Connection"), function() { + frappe.call({ + method: "huf.ai.mcp_client.test_mcp_connection", + args: { + server_name: frm.doc.name + }, + freeze: true, + freeze_message: __("Testing connection..."), + callback: function(r) { + if (r.message && r.message.success) { + frappe.msgprint({ + title: __("Connection Successful"), + message: __("Successfully connected to MCP server"), + indicator: "green" + }); + } else { + frappe.msgprint({ + title: __("Connection Failed"), + message: r.message ? r.message.error : __("Unknown error"), + indicator: "red" + }); + } + } + }); + }, __("Actions")); + } + }, + + auth_type(frm) { + // Set default header name based on auth type + if (frm.doc.auth_type === "bearer_token") { + frm.set_value("auth_header_name", "Authorization"); + } else if (frm.doc.auth_type === "api_key") { + frm.set_value("auth_header_name", "X-API-Key"); + } + } +}); diff --git a/huf/huf/doctype/mcp_server/mcp_server.json b/huf/huf/doctype/mcp_server/mcp_server.json new file mode 100644 index 0000000..62a6de6 --- /dev/null +++ b/huf/huf/doctype/mcp_server/mcp_server.json @@ -0,0 +1,181 @@ +{ + "actions": [], + "autoname": "field:server_name", + "creation": "2025-12-31 18:59:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "server_name", + "description", + "enabled", + "column_break_basic", + "tool_namespace", + "timeout_seconds", + "connection_section", + "transport_type", + "server_url", + "auth_section", + "auth_type", + "auth_header_name", + "auth_header_value", + "custom_headers_section", + "custom_headers", + "tools_section", + "sync_tools_button", + "last_sync", + "available_tools" + ], + "fields": [ + { + "fieldname": "server_name", + "fieldtype": "Data", + "label": "Server Name", + "reqd": 1, + "unique": 1, + "in_list_view": 1, + "description": "Unique identifier for this MCP server (e.g., 'gmail', 'github', 'frappe-erp')" + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "label": "Description", + "description": "What capabilities this MCP server provides" + }, + { + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled", + "default": "1", + "in_list_view": 1 + }, + { + "fieldname": "column_break_basic", + "fieldtype": "Column Break" + }, + { + "fieldname": "tool_namespace", + "fieldtype": "Data", + "label": "Tool Namespace", + "description": "Optional prefix for tool names (e.g., 'gmail' results in 'gmail.send_email')" + }, + { + "fieldname": "timeout_seconds", + "fieldtype": "Int", + "label": "Timeout (seconds)", + "default": "30", + "description": "Request timeout for MCP server calls" + }, + { + "fieldname": "connection_section", + "fieldtype": "Section Break", + "label": "Connection" + }, + { + "fieldname": "transport_type", + "fieldtype": "Select", + "label": "Transport Type", + "options": "http\nsse", + "default": "http", + "reqd": 1, + "in_list_view": 1 + }, + { + "fieldname": "server_url", + "fieldtype": "Data", + "label": "Server URL", + "reqd": 1, + "description": "MCP server endpoint URL (e.g., 'https://mcp.example.com/mcp')" + }, + { + "fieldname": "auth_section", + "fieldtype": "Section Break", + "label": "Authentication" + }, + { + "fieldname": "auth_type", + "fieldtype": "Select", + "label": "Auth Type", + "options": "none\napi_key\nbearer_token\ncustom_header", + "default": "none" + }, + { + "fieldname": "auth_header_name", + "fieldtype": "Data", + "label": "Auth Header Name", + "depends_on": "eval:doc.auth_type && doc.auth_type !== 'none'", + "default": "Authorization", + "description": "Header name for authentication (e.g., 'Authorization', 'X-API-Key')" + }, + { + "fieldname": "auth_header_value", + "fieldtype": "Password", + "label": "Auth Header Value", + "depends_on": "eval:doc.auth_type && doc.auth_type !== 'none'", + "description": "The API key, bearer token, or header value (stored encrypted)" + }, + { + "fieldname": "custom_headers_section", + "fieldtype": "Section Break", + "label": "Custom Headers", + "collapsible": 1 + }, + { + "fieldname": "custom_headers", + "fieldtype": "Table", + "label": "Custom Headers", + "options": "MCP Server Header", + "description": "Additional HTTP headers to send with MCP requests" + }, + { + "fieldname": "tools_section", + "fieldtype": "Section Break", + "label": "Available Tools" + }, + { + "fieldname": "sync_tools_button", + "fieldtype": "Button", + "label": "Sync Tools", + "description": "Fetch available tools from the MCP server" + }, + { + "fieldname": "last_sync", + "fieldtype": "Datetime", + "label": "Last Synced", + "read_only": 1 + }, + { + "fieldname": "available_tools", + "fieldtype": "Code", + "label": "Available Tools", + "read_only": 1, + "options": "JSON", + "description": "Cached list of tools available from this MCP server" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-12-31 18:59:00.000000", + "modified_by": "Administrator", + "module": "Huf", + "name": "MCP Server", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/huf/huf/doctype/mcp_server/mcp_server.py b/huf/huf/doctype/mcp_server/mcp_server.py new file mode 100644 index 0000000..aa02a2d --- /dev/null +++ b/huf/huf/doctype/mcp_server/mcp_server.py @@ -0,0 +1,41 @@ +# Copyright (c) 2025, Tridz Technologies Pvt Ltd +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document + + +class MCPServer(Document): + def validate(self): + """Validate MCP server configuration""" + if self.auth_type and self.auth_type != "none": + if not self.auth_header_name: + frappe.throw("Auth Header Name is required when authentication is enabled") + + def before_save(self): + """Format auth header based on auth type""" + if self.auth_type == "bearer_token" and self.auth_header_name == "Authorization": + # Will be formatted as "Bearer " during request + pass + + @frappe.whitelist() + def sync_tools(self): + """Fetch and cache available tools from the MCP server""" + from huf.ai.mcp_client import sync_mcp_server_tools + + result = sync_mcp_server_tools(self.name) + + if result.get("success"): + frappe.msgprint( + f"Successfully synced {result.get('tool_count', 0)} tools from {self.server_name}", + indicator="green", + title="MCP Tools Synced" + ) + else: + frappe.msgprint( + f"Failed to sync tools: {result.get('error', 'Unknown error')}", + indicator="red", + title="Sync Failed" + ) + + return result diff --git a/huf/huf/doctype/mcp_server_header/mcp_server_header.json b/huf/huf/doctype/mcp_server_header/mcp_server_header.json new file mode 100644 index 0000000..c260eae --- /dev/null +++ b/huf/huf/doctype/mcp_server_header/mcp_server_header.json @@ -0,0 +1,38 @@ +{ + "actions": [], + "creation": "2025-12-31 18:59:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "header_name", + "header_value" + ], + "fields": [ + { + "fieldname": "header_name", + "fieldtype": "Data", + "label": "Header Name", + "reqd": 1, + "in_list_view": 1 + }, + { + "fieldname": "header_value", + "fieldtype": "Data", + "label": "Header Value", + "reqd": 1, + "in_list_view": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-12-31 18:59:00.000000", + "modified_by": "Administrator", + "module": "Huf", + "name": "MCP Server Header", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/huf/huf/doctype/mcp_server_header/mcp_server_header.py b/huf/huf/doctype/mcp_server_header/mcp_server_header.py new file mode 100644 index 0000000..a9eb17c --- /dev/null +++ b/huf/huf/doctype/mcp_server_header/mcp_server_header.py @@ -0,0 +1,8 @@ +# Copyright (c) 2025, Tridz Technologies Pvt Ltd +# For license information, please see license.txt + +from frappe.model.document import Document + + +class MCPServerHeader(Document): + pass From c611b6b3ad32230205ed6016fdae00e779ce8f4b Mon Sep 17 00:00:00 2001 From: esafwan Date: Wed, 31 Dec 2025 19:57:11 +0530 Subject: [PATCH 02/29] feat(backend): Implement MCP Client Adapter & Tool Discovery - Add huf/ai/mcp_client.py: Core adapter using litellm.experimental_mcp_client - Handles HTTP/SSE connections, Auth, and OpenAI-format tool conversion - Update SDK tools to discover and load MCP tools alongside native tools - Implement safe execution wrappers for external tools --- huf/ai/mcp_client.py | 583 +++++++++++++++++++++++++++++++++++++++++++ huf/ai/sdk_tools.py | 18 ++ 2 files changed, 601 insertions(+) create mode 100644 huf/ai/mcp_client.py diff --git a/huf/ai/mcp_client.py b/huf/ai/mcp_client.py new file mode 100644 index 0000000..dcd39a4 --- /dev/null +++ b/huf/ai/mcp_client.py @@ -0,0 +1,583 @@ +# Copyright (c) 2025, Tridz Technologies Pvt Ltd +# For license information, please see license.txt + +""" +MCP Client Adapter for HUF + +This module provides the core MCP client functionality for HUF agents. +It allows agents to connect to external MCP servers and use their tools. + +Features: +- Connect to MCP servers (HTTP/SSE) +- Fetch available tools from MCP servers +- Convert MCP tools to HUF FunctionTool format +- Execute MCP tool calls +- Return results in HUF's expected format + +Uses LiteLLM's experimental MCP client for underlying MCP protocol handling. +""" + +import asyncio +import json +from typing import Any + +import frappe +from frappe import _ +from frappe.utils import now_datetime + +from agents import FunctionTool + + +# MCP Tool prefix to identify MCP-sourced tools during execution +MCP_TOOL_PREFIX = "__mcp__" + + +def create_mcp_tools(agent_doc) -> list[FunctionTool]: + """ + Create FunctionTool objects for all MCP tools available to an agent. + + This is the main entry point called from sdk_tools.create_agent_tools(). + + Args: + agent_doc: The Agent document with agent_mcp_server child table + + Returns: + list[FunctionTool]: List of FunctionTool objects for MCP tools + """ + tools = [] + + if not hasattr(agent_doc, "agent_mcp_server") or not agent_doc.agent_mcp_server: + return tools + + for mcp_link in agent_doc.agent_mcp_server: + if not mcp_link.enabled: + continue + + try: + mcp_server = frappe.get_doc("MCP Server", mcp_link.mcp_server) + + if not mcp_server.enabled: + continue + + # Get cached tools from the MCP server + server_tools = _get_cached_mcp_tools(mcp_server) + + for tool_def in server_tools: + tool = _create_mcp_function_tool(mcp_server, tool_def) + if tool: + tools.append(tool) + + except Exception as e: + frappe.log_error( + f"Error loading MCP tools from {mcp_link.mcp_server}: {str(e)}", + "MCP Client Error" + ) + + return tools + + +def _get_cached_mcp_tools(mcp_server) -> list[dict]: + """ + Get cached tools from an MCP server document. + + Args: + mcp_server: MCP Server document + + Returns: + list[dict]: List of tool definitions + """ + if not mcp_server.available_tools: + return [] + + try: + tools = json.loads(mcp_server.available_tools) + return tools if isinstance(tools, list) else [] + except (json.JSONDecodeError, TypeError): + return [] + + +def _create_mcp_function_tool(mcp_server, tool_def: dict) -> FunctionTool: + """ + Create a FunctionTool wrapper for an MCP tool. + + The tool's on_invoke_tool will call the MCP server to execute the tool. + + Args: + mcp_server: MCP Server document + tool_def: Tool definition from MCP server (OpenAI format) + + Returns: + FunctionTool: Wrapped tool that calls MCP server on invocation + """ + try: + # Extract tool info from OpenAI format + if "function" in tool_def: + # OpenAI format: {"type": "function", "function": {...}} + func_def = tool_def["function"] + else: + # Direct format + func_def = tool_def + + tool_name = func_def.get("name", "") + description = func_def.get("description", "") + parameters = func_def.get("parameters", {}) + + if not tool_name: + return None + + # Apply namespace prefix if configured + display_name = tool_name + if mcp_server.tool_namespace: + display_name = f"{mcp_server.tool_namespace}.{tool_name}" + + # Store server info for the closure + server_name = mcp_server.name + original_tool_name = tool_name + + async def on_invoke_tool(ctx=None, args_json: str = None) -> str: + """Execute the MCP tool via the MCP server""" + try: + if args_json is None and isinstance(ctx, str): + args_json = ctx + ctx = None + + args_dict = json.loads(args_json or "{}") + + # Execute the tool on the MCP server + result = await execute_mcp_tool( + server_name=server_name, + tool_name=original_tool_name, + arguments=args_dict + ) + + return json.dumps(result, default=str) if isinstance(result, (dict, list)) else str(result) + + except Exception as e: + frappe.log_error( + f"Error executing MCP tool '{display_name}': {str(e)}", + "MCP Tool Execution Error" + ) + return json.dumps({"error": str(e)}) + + # Sanitize tool name for OpenAI compatibility + import re + safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', display_name) + if len(safe_name) > 64: + safe_name = safe_name[:64] + + tool = FunctionTool( + name=safe_name, + description=f"[MCP:{mcp_server.server_name}] {description}", + params_json_schema=parameters, + on_invoke_tool=on_invoke_tool, + strict_json_schema=False + ) + + return tool + + except Exception as e: + frappe.log_error( + f"Error creating MCP tool from {tool_def}: {str(e)}", + "MCP Client Error" + ) + return None + + +async def execute_mcp_tool( + server_name: str, + tool_name: str, + arguments: dict +) -> Any: + """ + Execute a tool call on an MCP server and return the result. + + This function handles the actual MCP protocol communication. + + Args: + server_name: Name of the MCP Server document + tool_name: Name of the tool to execute + arguments: Arguments to pass to the tool + + Returns: + The result from the MCP tool execution + """ + try: + mcp_server = frappe.get_doc("MCP Server", server_name) + + # Build headers + headers = _build_mcp_headers(mcp_server) + + # Use LiteLLM's experimental MCP client if available + try: + from litellm.experimental_mcp_client import call_openai_tool + + # Prepare the tool call in OpenAI format + tool_call = { + "type": "function", + "function": { + "name": tool_name, + "arguments": json.dumps(arguments) + } + } + + # Call the MCP tool + result = await call_openai_tool( + mcp_url=mcp_server.server_url, + tool_call=tool_call, + headers=headers, + timeout=mcp_server.timeout_seconds or 30 + ) + + return result + + except ImportError: + # Fallback to direct HTTP call if LiteLLM MCP client not available + return await _execute_mcp_tool_http( + mcp_server, tool_name, arguments, headers + ) + + except Exception as e: + frappe.log_error( + f"Error executing MCP tool {tool_name} on {server_name}: {str(e)}", + "MCP Tool Execution Error" + ) + return {"error": str(e), "success": False} + + +async def _execute_mcp_tool_http( + mcp_server, + tool_name: str, + arguments: dict, + headers: dict +) -> Any: + """ + Fallback HTTP-based MCP tool execution. + + This is used when LiteLLM's MCP client is not available. + """ + import aiohttp + + url = mcp_server.server_url + timeout = aiohttp.ClientTimeout(total=mcp_server.timeout_seconds or 30) + + # MCP JSON-RPC format for tool calls + payload = { + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments + }, + "id": 1 + } + + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, json=payload, headers=headers) as response: + if response.status != 200: + error_text = await response.text() + return { + "error": f"MCP server returned status {response.status}: {error_text}", + "success": False + } + + result = await response.json() + + # Handle JSON-RPC response + if "error" in result: + return { + "error": result["error"].get("message", "Unknown MCP error"), + "success": False + } + + return result.get("result", result) + + except asyncio.TimeoutError: + return {"error": f"MCP server timeout after {mcp_server.timeout_seconds}s", "success": False} + except Exception as e: + return {"error": str(e), "success": False} + + +def _build_mcp_headers(mcp_server) -> dict: + """ + Build HTTP headers for MCP server requests. + + Args: + mcp_server: MCP Server document + + Returns: + dict: Headers dictionary + """ + headers = { + "Content-Type": "application/json", + "Accept": "application/json" + } + + # Add authentication header + if mcp_server.auth_type and mcp_server.auth_type != "none": + auth_value = mcp_server.get_password("auth_header_value") + + if auth_value and mcp_server.auth_header_name: + if mcp_server.auth_type == "bearer_token": + headers[mcp_server.auth_header_name] = f"Bearer {auth_value}" + else: + headers[mcp_server.auth_header_name] = auth_value + + # Add custom headers + if mcp_server.custom_headers: + for header in mcp_server.custom_headers: + headers[header.header_name] = header.header_value + + return headers + + +@frappe.whitelist() +def sync_mcp_server_tools(server_name: str) -> dict: + """ + Fetch and cache available tools from an MCP server. + + This function connects to the MCP server, retrieves the list of + available tools, and caches them in the MCP Server document. + + Args: + server_name: Name of the MCP Server document + + Returns: + dict: Result with success status and tool count + """ + try: + mcp_server = frappe.get_doc("MCP Server", server_name) + headers = _build_mcp_headers(mcp_server) + + # Try to use LiteLLM's MCP client + try: + tools = _sync_tools_via_litellm(mcp_server, headers) + except ImportError: + # Fallback to direct HTTP + tools = _sync_tools_via_http(mcp_server, headers) + + # Cache tools in the document + mcp_server.available_tools = json.dumps(tools, indent=2) + mcp_server.last_sync = now_datetime() + mcp_server.save(ignore_permissions=True) + frappe.db.commit() + + return { + "success": True, + "tool_count": len(tools), + "tools": [t.get("function", t).get("name", "unknown") for t in tools] + } + + except Exception as e: + frappe.log_error( + f"Error syncing MCP tools from {server_name}: {str(e)}", + "MCP Sync Error" + ) + return { + "success": False, + "error": str(e) + } + + +def _sync_tools_via_litellm(mcp_server, headers: dict) -> list: + """ + Sync tools using LiteLLM's experimental MCP client. + """ + from litellm.experimental_mcp_client import load_mcp_tools + + # Run async function synchronously + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + tools = loop.run_until_complete( + load_mcp_tools( + mcp_url=mcp_server.server_url, + headers=headers, + format="openai" # Get tools in OpenAI function format + ) + ) + return tools if tools else [] + finally: + loop.close() + + +def _sync_tools_via_http(mcp_server, headers: dict) -> list: + """ + Sync tools using direct HTTP calls to MCP server. + """ + import requests + + # MCP JSON-RPC format for listing tools + payload = { + "jsonrpc": "2.0", + "method": "tools/list", + "params": {}, + "id": 1 + } + + response = requests.post( + mcp_server.server_url, + json=payload, + headers=headers, + timeout=mcp_server.timeout_seconds or 30 + ) + + response.raise_for_status() + result = response.json() + + if "error" in result: + raise Exception(result["error"].get("message", "Unknown MCP error")) + + # Convert MCP tools list to OpenAI format + mcp_tools = result.get("result", {}).get("tools", []) + openai_tools = [] + + for tool in mcp_tools: + openai_tools.append({ + "type": "function", + "function": { + "name": tool.get("name", ""), + "description": tool.get("description", ""), + "parameters": tool.get("inputSchema", {}) + } + }) + + return openai_tools + + +@frappe.whitelist() +def test_mcp_connection(server_name: str) -> dict: + """ + Test connection to an MCP server. + + Args: + server_name: Name of the MCP Server document + + Returns: + dict: Result with success status + """ + try: + mcp_server = frappe.get_doc("MCP Server", server_name) + headers = _build_mcp_headers(mcp_server) + + import requests + + # Try a simple ping/list request + payload = { + "jsonrpc": "2.0", + "method": "tools/list", + "params": {}, + "id": 1 + } + + response = requests.post( + mcp_server.server_url, + json=payload, + headers=headers, + timeout=min(mcp_server.timeout_seconds or 30, 10) # Max 10s for test + ) + + if response.status_code == 200: + return {"success": True, "message": "Connection successful"} + else: + return { + "success": False, + "error": f"Server returned status {response.status_code}" + } + + except requests.exceptions.Timeout: + return {"success": False, "error": "Connection timed out"} + except requests.exceptions.ConnectionError as e: + return {"success": False, "error": f"Connection failed: {str(e)}"} + except Exception as e: + return {"success": False, "error": str(e)} + + +@frappe.whitelist() +def get_agent_mcp_servers(agent_name: str) -> list: + """ + Get MCP servers linked to an agent. + + Args: + agent_name: Name of the Agent document + + Returns: + list: List of MCP server info dicts + """ + try: + agent = frappe.get_doc("Agent", agent_name) + result = [] + + for mcp_link in (agent.agent_mcp_server or []): + try: + mcp_server = frappe.get_doc("MCP Server", mcp_link.mcp_server) + + # Count tools + tool_count = 0 + if mcp_server.available_tools: + try: + tools = json.loads(mcp_server.available_tools) + tool_count = len(tools) if isinstance(tools, list) else 0 + except Exception: + pass + + result.append({ + "name": mcp_link.name, + "mcp_server": mcp_server.name, + "server_name": mcp_server.server_name, + "description": mcp_server.description, + "server_url": mcp_server.server_url, + "enabled": mcp_link.enabled, + "mcp_enabled": mcp_server.enabled, + "tool_count": tool_count, + "last_sync": mcp_server.last_sync + }) + except Exception: + continue + + return result + + except Exception as e: + frappe.log_error(f"Error getting agent MCP servers: {str(e)}", "MCP API Error") + return [] + + +@frappe.whitelist() +def get_available_mcp_servers() -> list: + """ + Get all available MCP servers. + + Returns: + list: List of MCP server info dicts + """ + try: + servers = frappe.get_all( + "MCP Server", + filters={"enabled": 1}, + fields=["name", "server_name", "description", "server_url", "last_sync"] + ) + + result = [] + for server in servers: + tool_count = 0 + try: + available_tools = frappe.db.get_value( + "MCP Server", server.name, "available_tools" + ) + if available_tools: + tools = json.loads(available_tools) + tool_count = len(tools) if isinstance(tools, list) else 0 + except Exception: + pass + + result.append({ + **server, + "tool_count": tool_count + }) + + return result + + except Exception as e: + frappe.log_error(f"Error getting available MCP servers: {str(e)}", "MCP API Error") + return [] diff --git a/huf/ai/sdk_tools.py b/huf/ai/sdk_tools.py index 9a2fc46..81d31d0 100644 --- a/huf/ai/sdk_tools.py +++ b/huf/ai/sdk_tools.py @@ -27,8 +27,26 @@ def create_agent_tools(agent) -> list[FunctionTool]: """ Create function tools for Huf Agent + + This combines: + 1. MCP tools from linked MCP servers + 2. Native tools from Agent Tool Function documents """ tools = [] + + # Load MCP tools from linked MCP servers + if hasattr(agent, "agent_mcp_server") and agent.agent_mcp_server: + try: + from huf.ai.mcp_client import create_mcp_tools + mcp_tools = create_mcp_tools(agent) + tools.extend(mcp_tools) + except Exception as e: + frappe.log_error( + f"Error loading MCP tools for agent: {str(e)}", + "MCP Tool Loading Error" + ) + + # Load native tools from Agent Tool Function documents if hasattr(agent, "agent_tool") and agent.agent_tool: for func in agent.agent_tool: From 15c7a3ae84f957660c3a7dc9faefe9594cdc31a1 Mon Sep 17 00:00:00 2001 From: esafwan Date: Wed, 31 Dec 2025 19:57:16 +0530 Subject: [PATCH 03/29] feat(frontend): Add UI for Managing MCP Servers - Update ToolsTab.tsx to display connected MCP servers with status badges - Add sync, toggle, and remove actions for MCP servers - Add mcpApi.ts service to interface with backend MCP endpoints --- frontend/src/components/agent/ToolsTab.tsx | 223 +++++++++++++++------ frontend/src/services/mcpApi.ts | 182 +++++++++++++++++ 2 files changed, 349 insertions(+), 56 deletions(-) create mode 100644 frontend/src/services/mcpApi.ts diff --git a/frontend/src/components/agent/ToolsTab.tsx b/frontend/src/components/agent/ToolsTab.tsx index 71f809d..e051ca6 100644 --- a/frontend/src/components/agent/ToolsTab.tsx +++ b/frontend/src/components/agent/ToolsTab.tsx @@ -1,4 +1,4 @@ -import { Plus, Server, Plug, Trash2 } from 'lucide-react'; +import { Plus, Server, Plug, Trash2, RefreshCw, AlertCircle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; @@ -6,26 +6,33 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com 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'; +/** + * MCP Server reference as stored in agent_mcp_server child table + */ +export interface MCPServerRef { + name: string; // Child table row name + mcp_server: string; // Link to MCP Server DocType + server_name?: string; // Display name from MCP Server + description?: string; // Description from MCP Server + server_url?: string; // URL from MCP Server + enabled: boolean; // Whether enabled for this agent + mcp_enabled?: boolean; // Whether the MCP Server itself is enabled + tool_count?: number; // Number of tools available + last_sync?: string; // Last sync timestamp } -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' }, -]; - interface ToolsTabProps { selectedTools: AgentToolFunctionRef[]; toolTypes: AgentToolType[]; onAddTools: () => void; onRemoveTool: (toolId: string) => void; + // MCP Server props - optional for backward compatibility + 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 +40,64 @@ export function ToolsTab({ toolTypes, onAddTools, onRemoveTool, + mcpServers = [], + onAddMCP, + onRemoveMCP, + onToggleMCP, + onSyncMCP, + mcpLoading = false, }: ToolsTabProps) { + + const handleMCPAction = (action: string, serverId?: string) => { + // If no handler provided, show "coming soon" + switch (action) { + case 'add': + if (onAddMCP) { + onAddMCP(); + } else { + toast.info('MCP server management coming soon'); + } + break; + case 'remove': + if (serverId && onRemoveMCP) { + onRemoveMCP(serverId); + } else { + toast.info('MCP server management coming soon'); + } + break; + case 'toggle': + if (serverId && onToggleMCP) { + const server = mcpServers.find(s => s.name === serverId); + if (server) { + onToggleMCP(serverId, !server.enabled); + } + } else { + toast.info('MCP server management coming soon'); + } + break; + case 'sync': + if (serverId && onSyncMCP) { + onSyncMCP(serverId); + } else { + toast.info('MCP server sync coming soon'); + } + break; + } + }; + + const getStatusBadge = (server: MCPServerRef) => { + if (!server.mcp_enabled) { + return server disabled; + } + if (!server.enabled) { + return inactive; + } + return connected; + }; + return ( <> + {/* Native Tools Section */}
@@ -101,6 +163,7 @@ export function ToolsTab({ + {/* MCP Servers Section */}
@@ -111,11 +174,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} +

+ )} + {!mcp.mcp_enabled && ( +
+ + MCP server is disabled globally +
+ )} +
+
+ + +
-

{mcp.description}

-
-
- -
-
- ))} -
+ ))} + + )}
); } - diff --git a/frontend/src/services/mcpApi.ts b/frontend/src/services/mcpApi.ts new file mode 100644 index 0000000..df18364 --- /dev/null +++ b/frontend/src/services/mcpApi.ts @@ -0,0 +1,182 @@ +/** + * MCP Server API functions + * + * Provides frontend API for managing MCP server connections. + */ + +import { db, call } from '@/lib/frappe-sdk'; +import { handleFrappeError } from '@/lib/frappe-error'; + +/** + * MCP Server document from Frappe + */ +export interface MCPServerDoc { + name: string; + server_name: string; + description?: string; + enabled: 0 | 1; + transport_type: 'http' | 'sse'; + server_url: string; + auth_type?: 'none' | 'api_key' | 'bearer_token' | 'custom_header'; + auth_header_name?: string; + tool_namespace?: string; + timeout_seconds?: number; + last_sync?: string; + available_tools?: string; +} + +/** + * MCP Server reference for agent child table + */ +export interface MCPServerRef { + name: string; // Child table row name + mcp_server: string; // Link to MCP Server DocType + server_name?: string; // Display name from MCP Server + description?: string; // Description from MCP Server + server_url?: string; // URL from MCP Server + enabled: boolean; // Whether enabled for this agent + mcp_enabled?: boolean; // Whether the MCP Server itself is enabled + tool_count?: number; // Number of tools available + last_sync?: string; // Last sync timestamp +} + +/** + * Fetch all available MCP servers + */ +export async function getMCPServers(): Promise { + try { + const response = await db.getDocList('MCP Server', { + fields: [ + 'name', + 'server_name', + 'description', + 'enabled', + 'transport_type', + 'server_url', + 'tool_namespace', + 'timeout_seconds', + 'last_sync', + ], + orderBy: { field: 'server_name', order: 'asc' }, + limit: 100, + }); + return response as MCPServerDoc[]; + } catch (error) { + handleFrappeError(error); + throw error; + } +} + +/** + * Fetch a single MCP server by name + */ +export async function getMCPServer(name: string): Promise { + try { + const response = await db.getDoc('MCP Server', name); + return response as MCPServerDoc; + } catch (error) { + handleFrappeError(error); + throw error; + } +} + +/** + * Create a new MCP server + */ +export async function createMCPServer(data: Partial): Promise { + try { + const response = await db.createDoc('MCP Server', data); + return response as MCPServerDoc; + } catch (error) { + handleFrappeError(error); + throw error; + } +} + +/** + * Update an MCP server + */ +export async function updateMCPServer(name: string, data: Partial): Promise { + try { + const response = await db.updateDoc('MCP Server', name, data); + return response as MCPServerDoc; + } catch (error) { + handleFrappeError(error); + throw error; + } +} + +/** + * Delete an MCP server + */ +export async function deleteMCPServer(name: string): Promise { + try { + await db.deleteDoc('MCP Server', name); + } catch (error) { + handleFrappeError(error); + throw error; + } +} + +/** + * Get MCP servers linked to an agent + */ +export async function getAgentMCPServers(agentName: string): Promise { + try { + const response = await call.post('huf.ai.mcp_client.get_agent_mcp_servers', { + agent_name: agentName, + }); + return (response.message || []) as MCPServerRef[]; + } catch (error) { + handleFrappeError(error); + throw error; + } +} + +/** + * Get all available MCP servers (enabled ones) + */ +export async function getAvailableMCPServers(): Promise { + try { + const response = await call.post('huf.ai.mcp_client.get_available_mcp_servers', {}); + return (response.message || []) as MCPServerRef[]; + } catch (error) { + handleFrappeError(error); + throw error; + } +} + +/** + * Test connection to an MCP server + */ +export async function testMCPConnection(serverName: string): Promise<{ success: boolean; error?: string }> { + try { + const response = await call.post('huf.ai.mcp_client.test_mcp_connection', { + server_name: serverName, + }); + return response.message as { success: boolean; error?: string }; + } catch (error) { + handleFrappeError(error); + throw error; + } +} + +/** + * Sync tools from an MCP server + */ +export async function syncMCPTools(serverName: string): Promise<{ + success: boolean; + tool_count?: number; + tools?: string[]; + error?: string; +}> { + try { + const response = await call.post('huf.ai.mcp_client.sync_mcp_server_tools', { + server_name: serverName, + }); + return response.message as { success: boolean; tool_count?: number; tools?: string[]; error?: string }; + } catch (error) { + handleFrappeError(error); + throw error; + } +} From 85da11d5ba48af9f180cd576bae409bfddb132f1 Mon Sep 17 00:00:00 2001 From: esafwan Date: Wed, 31 Dec 2025 19:57:22 +0530 Subject: [PATCH 04/29] docs: Add MCP Client Integration Guide - Document MCP architecture, DocTypes, and authentication methods - Explain tool discovery and execution flows - Add usage examples for connecting external MCP servers --- AGENTS.md | 195 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) 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) From abb2d91bd7e6de1fa5ea8ae1ea0e7e9b55d8b819 Mon Sep 17 00:00:00 2001 From: Sanjusha_tridz Date: Tue, 6 Jan 2026 11:56:59 +0000 Subject: [PATCH 05/29] fix: use native HTTP fallback for tool sync and execution in MCP - Replace experimental 'litellm.load_mcp_tools' with native '_sync_tools_via_http' in 'mcp_client.py' to resolve 'unexpected keyword argument mcp_url' errors caused by recent 'litellm' library updates. - Update 'execute_mcp_tool' to use '_execute_mcp_tool_http', ensuring stable JSON-RPC 2.0 compliance for tool execution. - Remove dependency on unstable experimental MCP client signatures, improving reliability when connecting to external Frappe MCP servers. --- huf/ai/mcp_client.py | 57 +++++++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/huf/ai/mcp_client.py b/huf/ai/mcp_client.py index dcd39a4..92a8ce3 100644 --- a/huf/ai/mcp_client.py +++ b/huf/ai/mcp_client.py @@ -206,35 +206,38 @@ async def execute_mcp_tool( # Build headers headers = _build_mcp_headers(mcp_server) + return await _execute_mcp_tool_http( + mcp_server, tool_name, arguments, headers + ) # Use LiteLLM's experimental MCP client if available - try: - from litellm.experimental_mcp_client import call_openai_tool + # try: + # from litellm.experimental_mcp_client import call_openai_tool - # Prepare the tool call in OpenAI format - tool_call = { - "type": "function", - "function": { - "name": tool_name, - "arguments": json.dumps(arguments) - } - } + # # Prepare the tool call in OpenAI format + # tool_call = { + # "type": "function", + # "function": { + # "name": tool_name, + # "arguments": json.dumps(arguments) + # } + # } - # Call the MCP tool - result = await call_openai_tool( - mcp_url=mcp_server.server_url, - tool_call=tool_call, - headers=headers, - timeout=mcp_server.timeout_seconds or 30 - ) + # # Call the MCP tool + # result = await call_openai_tool( + # mcp_url=mcp_server.server_url, + # tool_call=tool_call, + # headers=headers, + # timeout=mcp_server.timeout_seconds or 30 + # ) - return result + # return result - except ImportError: - # Fallback to direct HTTP call if LiteLLM MCP client not available - return await _execute_mcp_tool_http( - mcp_server, tool_name, arguments, headers - ) + # except ImportError: + # # Fallback to direct HTTP call if LiteLLM MCP client not available + # return await _execute_mcp_tool_http( + # mcp_server, tool_name, arguments, headers + # ) except Exception as e: frappe.log_error( @@ -350,11 +353,11 @@ def sync_mcp_server_tools(server_name: str) -> dict: headers = _build_mcp_headers(mcp_server) # Try to use LiteLLM's MCP client - try: - tools = _sync_tools_via_litellm(mcp_server, headers) - except ImportError: + # try: + # tools = _sync_tools_via_litellm(mcp_server, headers) + # except ImportError: # Fallback to direct HTTP - tools = _sync_tools_via_http(mcp_server, headers) + tools = _sync_tools_via_http(mcp_server, headers) # Cache tools in the document mcp_server.available_tools = json.dumps(tools, indent=2) From ad51716456153da03deb7fdc3bb27b3c7d47acea Mon Sep 17 00:00:00 2001 From: Sanjusha_tridz Date: Tue, 6 Jan 2026 12:57:00 +0000 Subject: [PATCH 06/29] fix: allow external tool names in Agent Tool Call - Update 'tool' field in 'Agent Tool Call' DocType from 'Link' to 'Data'. - Remove 'options' configuration pointing to 'Agent Tool Function'. --- huf/huf/doctype/agent_tool_call/agent_tool_call.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/huf/huf/doctype/agent_tool_call/agent_tool_call.json b/huf/huf/doctype/agent_tool_call/agent_tool_call.json index 4e9569d..3806d98 100644 --- a/huf/huf/doctype/agent_tool_call/agent_tool_call.json +++ b/huf/huf/doctype/agent_tool_call/agent_tool_call.json @@ -62,17 +62,16 @@ }, { "fieldname": "tool", - "fieldtype": "Link", + "fieldtype": "Data", "in_list_view": 1, "label": "Tool Name", - "options": "Agent Tool Function", "read_only": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2025-09-24 19:42:43.241277", + "modified": "2026-01-06 17:58:52.558076", "modified_by": "Administrator", "module": "Huf", "name": "Agent Tool Call", From 18a5b5de6e0f396afeeec520603604adf874654f Mon Sep 17 00:00:00 2001 From: Sanjusha_tridz Date: Wed, 7 Jan 2026 09:44:49 +0000 Subject: [PATCH 07/29] feat: create MCP Server Tool child doctype - Introduced MCP Server Tool child doctype to store individual tool details. - Added fields: tool_name, enabled, description, and parameters. - This structure will serve as the configuration layer for enabling/disabling specific MCP tools. --- huf/huf/doctype/mcp_server/test_mcp_server.py | 9 +++ huf/huf/doctype/mcp_server_tool/__init__.py | 0 .../mcp_server_tool/mcp_server_tool.json | 57 +++++++++++++++++++ .../mcp_server_tool/mcp_server_tool.py | 8 +++ 4 files changed, 74 insertions(+) create mode 100644 huf/huf/doctype/mcp_server/test_mcp_server.py create mode 100644 huf/huf/doctype/mcp_server_tool/__init__.py create mode 100644 huf/huf/doctype/mcp_server_tool/mcp_server_tool.json create mode 100644 huf/huf/doctype/mcp_server_tool/mcp_server_tool.py diff --git a/huf/huf/doctype/mcp_server/test_mcp_server.py b/huf/huf/doctype/mcp_server/test_mcp_server.py new file mode 100644 index 0000000..8e19d28 --- /dev/null +++ b/huf/huf/doctype/mcp_server/test_mcp_server.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, Huf and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestMCPServer(FrappeTestCase): + pass diff --git a/huf/huf/doctype/mcp_server_tool/__init__.py b/huf/huf/doctype/mcp_server_tool/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/huf/huf/doctype/mcp_server_tool/mcp_server_tool.json b/huf/huf/doctype/mcp_server_tool/mcp_server_tool.json new file mode 100644 index 0000000..4ad71a8 --- /dev/null +++ b/huf/huf/doctype/mcp_server_tool/mcp_server_tool.json @@ -0,0 +1,57 @@ +{ + "actions": [], + "autoname": "hash", + "creation": "2026-01-07 10:00:00", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "tool_name", + "enabled", + "description", + "parameters" + ], + "fields": [ + { + "fieldname": "tool_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Tool Name", + "read_only": 1, + "reqd": 1 + }, + { + "default": "1", + "fieldname": "enabled", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Enabled" + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Description", + "read_only": 1 + }, + { + "fieldname": "parameters", + "fieldtype": "Code", + "label": "Parameters", + "options": "JSON", + "read_only": 1 + } + ], + "istable": 1, + "links": [], + "modified": "2026-01-07 14:30:26.452515", + "modified_by": "Administrator", + "module": "Huf", + "name": "MCP Server Tool", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/huf/huf/doctype/mcp_server_tool/mcp_server_tool.py b/huf/huf/doctype/mcp_server_tool/mcp_server_tool.py new file mode 100644 index 0000000..960cebb --- /dev/null +++ b/huf/huf/doctype/mcp_server_tool/mcp_server_tool.py @@ -0,0 +1,8 @@ +# Copyright (c) 2025, Tridz Technologies Pvt Ltd +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +class MCPServerTool(Document): + pass From 76047cdb5d2f1b0e4e61c07c43495de2fbed0b00 Mon Sep 17 00:00:00 2001 From: Sanjusha_tridz Date: Wed, 7 Jan 2026 09:50:24 +0000 Subject: [PATCH 08/29] feat: add detailed control for MCP tools - DocType Update: Added tools Table field to MCP Server pointing to MCP Server Tool. - Sync Logic: Updated sync_mcp_server_tools in mcp_client.py to populate the child table instead of just caching JSON. - Agent Integration: Modified create_mcp_tools to iterate over the mcp_server.tools child table. - Filtering: Implemented checks to skip tools where enabled is false, effectively allowing users to control tool exposure to agents. --- huf/ai/mcp_client.py | 55 ++- huf/huf/doctype/mcp_server/__init__.py | 0 huf/huf/doctype/mcp_server/mcp_server.json | 369 +++++++++++---------- 3 files changed, 241 insertions(+), 183 deletions(-) create mode 100644 huf/huf/doctype/mcp_server/__init__.py diff --git a/huf/ai/mcp_client.py b/huf/ai/mcp_client.py index 92a8ce3..9048a06 100644 --- a/huf/ai/mcp_client.py +++ b/huf/ai/mcp_client.py @@ -59,10 +59,23 @@ def create_mcp_tools(agent_doc) -> list[FunctionTool]: if not mcp_server.enabled: continue - # Get cached tools from the MCP server - server_tools = _get_cached_mcp_tools(mcp_server) - - for tool_def in server_tools: + # Iterate through enabled tools in child table + for tool_row in mcp_server.tools: + if not tool_row.enabled: + continue + + # Reconstruct tool definition from child table + try: + parameters = json.loads(tool_row.parameters) if tool_row.parameters else {} + except Exception: + parameters = {} + + tool_def = { + "name": tool_row.tool_name, + "description": tool_row.description, + "parameters": parameters + } + tool = _create_mcp_function_tool(mcp_server, tool_def) if tool: tools.append(tool) @@ -362,6 +375,40 @@ def sync_mcp_server_tools(server_name: str) -> dict: # Cache tools in the document mcp_server.available_tools = json.dumps(tools, indent=2) mcp_server.last_sync = now_datetime() + + # Sync tools to child table + current_tools = {t.tool_name: t for t in mcp_server.tools} + synced_tool_names = set() + + for tool_def in tools: + # Handle both OpenAI format and direct format + if isinstance(tool_def, dict) and "function" in tool_def: + func_def = tool_def["function"] + else: + func_def = tool_def + + tool_name = func_def.get("name") + if not tool_name: + continue + + synced_tool_names.add(tool_name) + description = func_def.get("description", "") + parameters = json.dumps(func_def.get("parameters", {}), indent=2) + + if tool_name in current_tools: + # Update existing tool + row = current_tools[tool_name] + row.description = description + row.parameters = parameters + else: + # Add new tool + mcp_server.append("tools", { + "tool_name": tool_name, + "description": description, + "parameters": parameters, + "enabled": 1 + }) + mcp_server.save(ignore_permissions=True) frappe.db.commit() diff --git a/huf/huf/doctype/mcp_server/__init__.py b/huf/huf/doctype/mcp_server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/huf/huf/doctype/mcp_server/mcp_server.json b/huf/huf/doctype/mcp_server/mcp_server.json index 62a6de6..218c845 100644 --- a/huf/huf/doctype/mcp_server/mcp_server.json +++ b/huf/huf/doctype/mcp_server/mcp_server.json @@ -1,181 +1,192 @@ { - "actions": [], - "autoname": "field:server_name", - "creation": "2025-12-31 18:59:00.000000", - "doctype": "DocType", - "engine": "InnoDB", - "field_order": [ - "server_name", - "description", - "enabled", - "column_break_basic", - "tool_namespace", - "timeout_seconds", - "connection_section", - "transport_type", - "server_url", - "auth_section", - "auth_type", - "auth_header_name", - "auth_header_value", - "custom_headers_section", - "custom_headers", - "tools_section", - "sync_tools_button", - "last_sync", - "available_tools" - ], - "fields": [ - { - "fieldname": "server_name", - "fieldtype": "Data", - "label": "Server Name", - "reqd": 1, - "unique": 1, - "in_list_view": 1, - "description": "Unique identifier for this MCP server (e.g., 'gmail', 'github', 'frappe-erp')" - }, - { - "fieldname": "description", - "fieldtype": "Small Text", - "label": "Description", - "description": "What capabilities this MCP server provides" - }, - { - "fieldname": "enabled", - "fieldtype": "Check", - "label": "Enabled", - "default": "1", - "in_list_view": 1 - }, - { - "fieldname": "column_break_basic", - "fieldtype": "Column Break" - }, - { - "fieldname": "tool_namespace", - "fieldtype": "Data", - "label": "Tool Namespace", - "description": "Optional prefix for tool names (e.g., 'gmail' results in 'gmail.send_email')" - }, - { - "fieldname": "timeout_seconds", - "fieldtype": "Int", - "label": "Timeout (seconds)", - "default": "30", - "description": "Request timeout for MCP server calls" - }, - { - "fieldname": "connection_section", - "fieldtype": "Section Break", - "label": "Connection" - }, - { - "fieldname": "transport_type", - "fieldtype": "Select", - "label": "Transport Type", - "options": "http\nsse", - "default": "http", - "reqd": 1, - "in_list_view": 1 - }, - { - "fieldname": "server_url", - "fieldtype": "Data", - "label": "Server URL", - "reqd": 1, - "description": "MCP server endpoint URL (e.g., 'https://mcp.example.com/mcp')" - }, - { - "fieldname": "auth_section", - "fieldtype": "Section Break", - "label": "Authentication" - }, - { - "fieldname": "auth_type", - "fieldtype": "Select", - "label": "Auth Type", - "options": "none\napi_key\nbearer_token\ncustom_header", - "default": "none" - }, - { - "fieldname": "auth_header_name", - "fieldtype": "Data", - "label": "Auth Header Name", - "depends_on": "eval:doc.auth_type && doc.auth_type !== 'none'", - "default": "Authorization", - "description": "Header name for authentication (e.g., 'Authorization', 'X-API-Key')" - }, - { - "fieldname": "auth_header_value", - "fieldtype": "Password", - "label": "Auth Header Value", - "depends_on": "eval:doc.auth_type && doc.auth_type !== 'none'", - "description": "The API key, bearer token, or header value (stored encrypted)" - }, - { - "fieldname": "custom_headers_section", - "fieldtype": "Section Break", - "label": "Custom Headers", - "collapsible": 1 - }, - { - "fieldname": "custom_headers", - "fieldtype": "Table", - "label": "Custom Headers", - "options": "MCP Server Header", - "description": "Additional HTTP headers to send with MCP requests" - }, - { - "fieldname": "tools_section", - "fieldtype": "Section Break", - "label": "Available Tools" - }, - { - "fieldname": "sync_tools_button", - "fieldtype": "Button", - "label": "Sync Tools", - "description": "Fetch available tools from the MCP server" - }, - { - "fieldname": "last_sync", - "fieldtype": "Datetime", - "label": "Last Synced", - "read_only": 1 - }, - { - "fieldname": "available_tools", - "fieldtype": "Code", - "label": "Available Tools", - "read_only": 1, - "options": "JSON", - "description": "Cached list of tools available from this MCP server" - } - ], - "index_web_pages_for_search": 1, - "links": [], - "modified": "2025-12-31 18:59:00.000000", - "modified_by": "Administrator", - "module": "Huf", - "name": "MCP Server", - "naming_rule": "By fieldname", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "sort_field": "modified", - "sort_order": "DESC", - "states": [], - "track_changes": 1 + "actions": [], + "autoname": "field:server_name", + "creation": "2025-12-31 18:59:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "server_name", + "description", + "enabled", + "column_break_basic", + "tool_namespace", + "timeout_seconds", + "connection_section", + "transport_type", + "server_url", + "auth_section", + "auth_type", + "auth_header_name", + "auth_header_value", + "custom_headers_section", + "custom_headers", + "tools_section", + "sync_tools_button", + "last_sync", + "tools", + "available_tools" + ], + "fields": [ + { + "fieldname": "server_name", + "fieldtype": "Data", + "label": "Server Name", + "reqd": 1, + "unique": 1, + "in_list_view": 1, + "description": "Unique identifier for this MCP server (e.g., 'gmail', 'github', 'frappe-erp')" + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "label": "Description", + "description": "What capabilities this MCP server provides" + }, + { + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled", + "default": "1", + "in_list_view": 1 + }, + { + "fieldname": "column_break_basic", + "fieldtype": "Column Break" + }, + { + "fieldname": "tool_namespace", + "fieldtype": "Data", + "label": "Tool Namespace", + "description": "Optional prefix for tool names (e.g., 'gmail' results in 'gmail.send_email')" + }, + { + "fieldname": "timeout_seconds", + "fieldtype": "Int", + "label": "Timeout (seconds)", + "default": "30", + "description": "Request timeout for MCP server calls" + }, + { + "fieldname": "connection_section", + "fieldtype": "Section Break", + "label": "Connection" + }, + { + "fieldname": "transport_type", + "fieldtype": "Select", + "label": "Transport Type", + "options": "http\nsse", + "default": "http", + "reqd": 1, + "in_list_view": 1 + }, + { + "fieldname": "server_url", + "fieldtype": "Data", + "label": "Server URL", + "reqd": 1, + "description": "MCP server endpoint URL (e.g., 'https://mcp.example.com/mcp')" + }, + { + "fieldname": "auth_section", + "fieldtype": "Section Break", + "label": "Authentication" + }, + { + "fieldname": "auth_type", + "fieldtype": "Select", + "label": "Auth Type", + "options": "none\napi_key\nbearer_token\ncustom_header", + "default": "none" + }, + { + "fieldname": "auth_header_name", + "fieldtype": "Data", + "label": "Auth Header Name", + "depends_on": "eval:doc.auth_type && doc.auth_type !== 'none'", + "default": "Authorization", + "description": "Header name for authentication (e.g., 'Authorization', 'X-API-Key')" + }, + { + "fieldname": "auth_header_value", + "fieldtype": "Password", + "label": "Auth Header Value", + "depends_on": "eval:doc.auth_type && doc.auth_type !== 'none'", + "description": "The API key, bearer token, or header value (stored encrypted)" + }, + { + "fieldname": "custom_headers_section", + "fieldtype": "Section Break", + "label": "Custom Headers", + "collapsible": 1 + }, + { + "allow_bulk_edit": 1, + "fieldname": "custom_headers", + "fieldtype": "Table", + "label": "Custom Headers", + "options": "MCP Server Header", + "description": "Additional HTTP headers to send with MCP requests" + }, + { + "fieldname": "tools_section", + "fieldtype": "Section Break", + "label": "Available Tools" + }, + { + "fieldname": "sync_tools_button", + "fieldtype": "Button", + "label": "Sync Tools", + "description": "Fetch available tools from the MCP server" + }, + { + "allow_bulk_edit": 1, + "description": "Manage enabled tools from this server", + "fieldname": "tools", + "fieldtype": "Table", + "label": "Tools", + "options": "MCP Server Tool" + }, + { + "fieldname": "last_sync", + "fieldtype": "Datetime", + "label": "Last Synced", + "read_only": 1 + }, + { + "fieldname": "available_tools", + "fieldtype": "Code", + "label": "Available Tools", + "read_only": 1, + "options": "JSON", + "description": "Cached list of tools available from this MCP server" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-01-07 14:37:54.791104", + "modified_by": "Administrator", + "module": "Huf", + "name": "MCP Server", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 } \ No newline at end of file From 290dae33abf0fc854e2e00fed045bf6b5c625566 Mon Sep 17 00:00:00 2001 From: Sanjusha_tridz Date: Wed, 7 Jan 2026 11:27:46 +0000 Subject: [PATCH 09/29] feat: add auto-sync for MCP server tools - Added enable_auto_sync and auto_sync_interval fields to MCP Server DocType to configure automated tooling synchronization. - Implemented auto_sync_mcp_server_tools in mcp_client.py to check and sync tools based on the configured interval. - Registered an hourly scheduler event in hooks.py to trigger the auto-sync check. --- huf/ai/mcp_client.py | 23 ++++++++++++++++++++++ huf/hooks.py | 5 ++++- huf/huf/doctype/mcp_server/mcp_server.json | 20 ++++++++++++++++++- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/huf/ai/mcp_client.py b/huf/ai/mcp_client.py index 9048a06..203a079 100644 --- a/huf/ai/mcp_client.py +++ b/huf/ai/mcp_client.py @@ -631,3 +631,26 @@ def get_available_mcp_servers() -> list: except Exception as e: frappe.log_error(f"Error getting available MCP servers: {str(e)}", "MCP API Error") return [] + +@frappe.whitelist() +def auto_sync_mcp_server_tools(): + """ + Scheduled job to auto-sync MCP server Tools. + Runs hourly and checks if sync is due based on the interval. + """ + from frappe.utils import time_diff_in_hours + + servers = frappe.get_all( + "MCP Server", + filters={"enabled": 1, "enable_auto_sync": 1}, + fields=["name", "auto_sync_interval", "last_sync"] + ) + + for server in servers: + try: + # Check if sync is due + if not server.last_sync or time_diff_in_hours(now_datetime(), server.last_sync) >= server.auto_sync_interval: + frappe.log_error(f"Auto-syncing MCP Tools: {server.name}", "MCP Tools Auto Synced") + sync_mcp_server_tools(server.name) + except Exception as e: + frappe.log_error(f"Error auto-syncing {server.name}: {str(e)}", "MCP Tools Auto Sync Error") diff --git a/huf/hooks.py b/huf/hooks.py index ebdda31..597cf56 100644 --- a/huf/hooks.py +++ b/huf/hooks.py @@ -210,7 +210,10 @@ "*/1 * * * *": [ "huf.ai.orchestration.scheduler.process_orchestrations" ] - } + }, + "hourly": [ + "huf.ai.mcp_client.auto_sync_mcp_server_tools" + ] } diff --git a/huf/huf/doctype/mcp_server/mcp_server.json b/huf/huf/doctype/mcp_server/mcp_server.json index 218c845..323ea17 100644 --- a/huf/huf/doctype/mcp_server/mcp_server.json +++ b/huf/huf/doctype/mcp_server/mcp_server.json @@ -22,6 +22,8 @@ "custom_headers", "tools_section", "sync_tools_button", + "enable_auto_sync", + "auto_sync_interval", "last_sync", "tools", "available_tools" @@ -160,11 +162,27 @@ "read_only": 1, "options": "JSON", "description": "Cached list of tools available from this MCP server" + }, + { + "default": "0", + "description": "Automatically sync tools periodically", + "fieldname": "enable_auto_sync", + "fieldtype": "Check", + "label": "Enable Auto Sync" + }, + { + "default": 1, + "depends_on": "eval:doc.enable_auto_sync", + "description": "Interval in hours to auto-sync tools", + "fieldname": "auto_sync_interval", + "fieldtype": "Int", + "label": "Sync Interval (Hours)", + "mandatory_depends_on": "eval:doc.enable_auto_sync" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2026-01-07 14:37:54.791104", + "modified": "2026-01-07 15:39:23.569156", "modified_by": "Administrator", "module": "Huf", "name": "MCP Server", From 52b02bf4a39546c2dace46be721b37ecc80261ab Mon Sep 17 00:00:00 2001 From: Sanjusha_tridz Date: Wed, 7 Jan 2026 13:08:42 +0000 Subject: [PATCH 10/29] feat: track MCP source in agent tool calls - Added is_mcp_tool and mcp_server fields to Agent Tool Call DocType to distinguish MCP tool usage. - Updated process_tool_call in agent_integration.py to automatically detect if a tool belongs to an MCP server and populate the new fields. --- huf/ai/agent_integration.py | 11 +++++++++++ .../doctype/agent_tool_call/agent_tool_call.json | 16 ++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/huf/ai/agent_integration.py b/huf/ai/agent_integration.py index 97aaa5e..50ebd1e 100644 --- a/huf/ai/agent_integration.py +++ b/huf/ai/agent_integration.py @@ -258,11 +258,22 @@ def process_tool_call(agent_run, conversation, name=None, args=None, result=None return None else: + is_mcp_tool = 0 + mcp_server = None + + if name: + mcp_tool_entry = frappe.db.get_value("MCP Server Tool", {"tool_name": name, "enabled": 1}, "parent") + if mcp_tool_entry: + is_mcp_tool = 1 + mcp_server = mcp_tool_entry + doc = frappe.get_doc({ "doctype": "Agent Tool Call", "agent_run": agent_run, "conversation": conversation, "tool": name, + "is_mcp_tool": is_mcp_tool, + "mcp_server": mcp_server, "tool_args": json.dumps(args) if args else None, "tool_result": json.dumps(result) if result else None, "error_message": error, diff --git a/huf/huf/doctype/agent_tool_call/agent_tool_call.json b/huf/huf/doctype/agent_tool_call/agent_tool_call.json index 3806d98..bb92c0d 100644 --- a/huf/huf/doctype/agent_tool_call/agent_tool_call.json +++ b/huf/huf/doctype/agent_tool_call/agent_tool_call.json @@ -8,6 +8,8 @@ "agent_run", "conversation", "tool", + "is_mcp_tool", + "mcp_server", "tool_args", "tool_result", "status", @@ -66,6 +68,20 @@ "in_list_view": 1, "label": "Tool Name", "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_mcp_tool", + "fieldtype": "Check", + "label": "Is MCP Tool", + "read_only": 1 + }, + { + "fieldname": "mcp_server", + "fieldtype": "Link", + "label": "MCP Server", + "options": "MCP Server", + "read_only": 1 } ], "grid_page_length": 50, From 39276682fda1c6410c3bc5a6b97fdbcc8ace66b2 Mon Sep 17 00:00:00 2001 From: Sanjusha_tridz Date: Fri, 9 Jan 2026 13:54:17 +0000 Subject: [PATCH 11/29] feat: configure app visibility - Configure 'add_to_apps_screen' with updated logo path --- huf/hooks.py | 21 +++++++++++---------- huf/permission.py | 5 +++++ 2 files changed, 16 insertions(+), 10 deletions(-) create mode 100644 huf/permission.py diff --git a/huf/hooks.py b/huf/hooks.py index ebdda31..dd7acc3 100644 --- a/huf/hooks.py +++ b/huf/hooks.py @@ -1,9 +1,10 @@ app_name = "huf" app_title = "Huf" -app_publisher = "Huf" +app_publisher = "Tridz Technologies Pvt Ltd" app_description = "Build and run smart AI agents with tools, chat, and automation directly in the Frappe ecosystem." app_email = "info@tridz.com" app_license = "agpl" +source_link = "https://github.com/tridz-dev/huf.git" # Apps # ------------------ @@ -11,15 +12,15 @@ # required_apps = [] # Each item in the list will be shown as an app in the apps page -# add_to_apps_screen = [ -# { -# "name": "huf", -# "logo": "/assets/huf/logo.png", -# "title": "Huf", -# "route": "/huf", -# "has_permission": "huf.api.permission.has_app_permission" -# } -# ] +add_to_apps_screen = [ + { + "name": "huf", + "logo": "/assets/huf/Images/Huf.jpg", + "title": "Huf", + "route": "huf", + "has_permission": "huf.permission.check_app_permission" + } +] # Includes in # ------------------ diff --git a/huf/permission.py b/huf/permission.py new file mode 100644 index 0000000..9d2dbc9 --- /dev/null +++ b/huf/permission.py @@ -0,0 +1,5 @@ +import frappe + +def check_app_permission(): + if frappe.session.user == "Administrator" or frappe.session.user == "System Manager" : + return True From 3ba6a5ddf27e360a0dd6f7c752849fbe949e98dd Mon Sep 17 00:00:00 2001 From: shahzadbinshahajhan Date: Fri, 9 Jan 2026 13:57:41 +0000 Subject: [PATCH 12/29] feat: mcp listing and adding in agent form --- frontend/src/App.tsx | 22 ++ frontend/src/components/agent/ToolsTab.tsx | 76 +++---- .../src/components/tools/MCPServerCard.tsx | 74 +++++++ .../tools/SelectMCPServersModal.tsx | 193 ++++++++++++++++++ frontend/src/components/tools/index.ts | 4 +- frontend/src/data/doctypes.ts | 1 + frontend/src/pages/AgentFormPage.tsx | 182 ++++++++++++++++- frontend/src/pages/McpDetailsPage.tsx | 3 + frontend/src/pages/McpListingPage.tsx | 3 + frontend/src/services/mcpApi.ts | 4 +- frontend/src/types/agent.types.ts | 4 + 11 files changed, 508 insertions(+), 58 deletions(-) create mode 100644 frontend/src/components/tools/MCPServerCard.tsx create mode 100644 frontend/src/components/tools/SelectMCPServersModal.tsx create mode 100644 frontend/src/pages/McpDetailsPage.tsx create mode 100644 frontend/src/pages/McpListingPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9128d65..0980c0e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,8 @@ import Executions from './pages/Executions'; import { AgentRunDetailPage } from './pages/AgentRunDetailPage'; import { useEffect } from 'react'; import { createFrappeSocket } from './utils/socket'; +import McpDetailsPage from './pages/McpDetailsPage'; +import McpListingPage from './pages/McpListingPage'; function App() { useEffect(() => { @@ -196,6 +198,26 @@ function App() { } /> + + + + + + } + /> + + + + + + } + /> void; onRemoveTool: (toolId: string) => void; - // MCP Server props - optional for backward compatibility + // MCP Server props mcpServers?: MCPServerRef[]; onAddMCP?: () => void; onRemoveMCP?: (serverId: string) => void; @@ -49,7 +35,6 @@ export function ToolsTab({ }: ToolsTabProps) { const handleMCPAction = (action: string, serverId?: string) => { - // If no handler provided, show "coming soon" switch (action) { case 'add': if (onAddMCP) { @@ -85,11 +70,19 @@ export function ToolsTab({ } }; + // Helper to normalize enabled state (handles both boolean and number 0/1) + const isEnabled = (value: boolean | number | undefined): boolean => { + return value === true || value === 1; + }; + const getStatusBadge = (server: MCPServerRef) => { - if (!server.mcp_enabled) { - return server disabled; + const mcpEnabled = isEnabled(server.mcp_enabled); + const agentEnabled = isEnabled(server.enabled); + + if (!mcpEnabled) { + return server disabled; } - if (!server.enabled) { + if (!agentEnabled) { return inactive; } return connected; @@ -214,9 +207,9 @@ export function ToolsTab({ key={mcp.name} className="flex items-start justify-between gap-3 rounded-lg border p-4 hover:bg-muted/50 transition-colors" > -
+
-

{mcp.server_name || mcp.mcp_server}

+

{mcp.server_name || mcp.mcp_server}

{getStatusBadge(mcp)} {mcp.tool_count !== undefined && mcp.tool_count > 0 && ( @@ -225,44 +218,25 @@ export function ToolsTab({ )}
{mcp.description && ( -

{mcp.description}

+

{mcp.description}

)} - {mcp.server_url && ( -

+ {/* {mcp.server_url && ( +

{mcp.server_url}

- )} - {!mcp.mcp_enabled && ( -
- - MCP server is disabled globally -
- )} + )} */}
-
- - +
+ +
+ + + + ); +} + diff --git a/frontend/src/components/tools/index.ts b/frontend/src/components/tools/index.ts index 0200681..70ba6a5 100644 --- a/frontend/src/components/tools/index.ts +++ b/frontend/src/components/tools/index.ts @@ -1,2 +1,4 @@ export { ToolCard } from './ToolCard'; -export { SelectToolsModal } from './SelectToolsModal'; \ No newline at end of file +export { SelectToolsModal } from './SelectToolsModal'; +export { MCPServerCard } from './MCPServerCard'; +export { SelectMCPServersModal } from './SelectMCPServersModal'; \ No newline at end of file diff --git a/frontend/src/data/doctypes.ts b/frontend/src/data/doctypes.ts index cababc2..1443be9 100644 --- a/frontend/src/data/doctypes.ts +++ b/frontend/src/data/doctypes.ts @@ -9,6 +9,7 @@ export const doctype = { "Agent Conversation": "Agent Conversation", "Agent Message": "Agent Message", "Agent Run": "Agent Run", + "MCP Server": "MCP Server", } as const; export type DocType = typeof doctype[keyof typeof doctype]; diff --git a/frontend/src/pages/AgentFormPage.tsx b/frontend/src/pages/AgentFormPage.tsx index d1d9685..5ac7880 100644 --- a/frontend/src/pages/AgentFormPage.tsx +++ b/frontend/src/pages/AgentFormPage.tsx @@ -11,7 +11,7 @@ import { getProviders, getModels } from '../services/providerApi'; import { getToolFunctions, getToolTypes } from '../services/toolApi'; import type { AgentDoc } from '../types/agent.types'; import type { AgentToolType } from '../types/agent.types'; -import { SelectToolsModal } from '../components/tools'; +import { SelectToolsModal, SelectMCPServersModal } from '../components/tools'; import { TriggerModal } from '../components/agent/TriggerModal'; import { getFrappeErrorMessage } from '../lib/frappe-error'; import { AgentHeader } from '../components/agent/AgentHeader'; @@ -20,6 +20,8 @@ import { BehaviorTab } from '../components/agent/BehaviorTab'; import { TriggersTab } from '../components/agent/TriggersTab'; import { ToolsTab } from '../components/agent/ToolsTab'; import { agentFormSchema, type AgentFormValues } from '../components/agent/types'; +import { syncMCPTools, type MCPServerRef } from '../services/mcpApi'; +import type { MCPServerDoc } from '../services/mcpApi'; export function AgentFormPage() { @@ -46,6 +48,10 @@ export function AgentFormPage() { const [loadingDocTypes, setLoadingDocTypes] = useState(false); const [triggerTypes, setTriggerTypes] = useState([]); const [loadingTriggerTypes, setLoadingTriggerTypes] = useState(false); + const [showMCPServersModal, setShowMCPServersModal] = useState(false); + const [mcpServers, setMcpServers] = useState([]); + const [initialMcpServers, setInitialMcpServers] = useState([]); // Track initial MCP servers state + const [mcpLoading, setMcpLoading] = useState(false); const form = useForm({ resolver: zodResolver(agentFormSchema), @@ -90,8 +96,34 @@ export function AgentFormPage() { return watchDisabled !== initialDisabled; }, [watchDisabled, initialDisabled, isNew]); - // Show save button for new agents, when form is dirty, when tools have changed, or when disabled changed - const showSaveButton = isNew || isDirty || toolsChanged || disabledChanged; + // Check if MCP servers have changed by comparing server names and enabled states + const mcpServersChanged = useMemo(() => { + if (isNew) return mcpServers.length > 0; // New agent with MCP servers selected + + // Normalize enabled state to number for comparison + const normalizeEnabled = (enabled: boolean | number | undefined): number => { + return enabled === true || enabled === 1 ? 1 : 0; + }; + + // Compare by mcp_server link field (the actual server name) and enabled state + const initialServerMap = new Map( + initialMcpServers.map((s) => [`${s.mcp_server}:${normalizeEnabled(s.enabled)}`, s]) + ); + const currentServerMap = new Map( + mcpServers.map((s) => [`${s.mcp_server}:${normalizeEnabled(s.enabled)}`, s]) + ); + + if (initialServerMap.size !== currentServerMap.size) return true; + + for (const [key] of currentServerMap) { + if (!initialServerMap.has(key)) return true; + } + + return false; + }, [mcpServers, initialMcpServers, isNew]); + + // Show save button for new agents, when form is dirty, when tools have changed, when disabled changed, or when MCP servers changed + const showSaveButton = isNew || isDirty || toolsChanged || disabledChanged || mcpServersChanged; // Load trigger types on mount useEffect(() => { @@ -221,6 +253,28 @@ export function AgentFormPage() { // Don't show error toast for triggers, just log it setTriggers([]); }); + // Load MCP servers from agent_mcp_server child table (already in agent document) + if (data.agent_mcp_server && Array.isArray(data.agent_mcp_server) && data.agent_mcp_server.length > 0) { + // Transform child table data to MCPServerRef format + // The child table includes: name, mcp_server (link), enabled, server_url (fetched), tool_count + const servers: MCPServerRef[] = data.agent_mcp_server.map((item: any) => ({ + name: item.name || '', // Child table row name + mcp_server: item.mcp_server, // Link to MCP Server DocType + server_url: item.server_url || '', + enabled: item.enabled === 1 || item.enabled === true ? 1 : 0, + tool_count: item.tool_count || 0, + // Note: server_name, description, and mcp_enabled come from the linked MCP Server DocType + // These may or may not be included depending on Frappe's serialization + server_name: item.server_name, + description: item.description, + mcp_enabled: item.mcp_enabled !== undefined ? (item.mcp_enabled === 1 || item.mcp_enabled === true ? 1 : 0) : undefined, + })); + setMcpServers(servers); + setInitialMcpServers(servers); // Store initial state for change detection + } else { + setMcpServers([]); + setInitialMcpServers([]); + } setLoading(false); }).catch((error) => { console.error('Error loading agent:', error); @@ -233,6 +287,8 @@ export function AgentFormPage() { setSelectedTools([]); setInitialTools([]); setInitialDisabled(false); + setMcpServers([]); + setInitialMcpServers([]); setLoading(false); } }, [id, isNew, form]); @@ -258,6 +314,11 @@ export function AgentFormPage() { agent_tool: selectedTools.map((tool) => ({ tool: tool.name, })) as any, + // Include MCP servers - Frappe child table format: array of objects with 'mcp_server' field and 'enabled' field + agent_mcp_server: mcpServers.map((server) => ({ + mcp_server: server.mcp_server, // This is the link field to MCP Server DocType + enabled: (server.enabled === true || server.enabled === 1) ? 1 : (0 as 0 | 1), + })), }; if (isNew) { @@ -301,9 +362,34 @@ export function AgentFormPage() { description: values.description, instructions: values.instructions, }); - // Reset tools and disabled state after successful update to mark as unchanged + // Reset tools, disabled state, and MCP servers after successful update to mark as unchanged setInitialTools([...selectedTools]); setInitialDisabled(values.disabled); + // Reload agent to get updated MCP servers from the agent document + if (id) { + getAgent(id).then((updatedData: AgentDoc) => { + // Reload MCP servers from updated agent document + if (updatedData.agent_mcp_server && Array.isArray(updatedData.agent_mcp_server) && updatedData.agent_mcp_server.length > 0) { + const servers: MCPServerRef[] = updatedData.agent_mcp_server.map((item: any) => ({ + name: item.name || '', + mcp_server: item.mcp_server, + server_url: item.server_url || '', + enabled: item.enabled === 1 || item.enabled === true ? 1 : 0, + tool_count: item.tool_count || 0, + server_name: item.server_name, + description: item.description, + mcp_enabled: item.mcp_enabled !== undefined ? (item.mcp_enabled === 1 || item.mcp_enabled === true ? 1 : 0) : undefined, + })); + setMcpServers(servers); + setInitialMcpServers(servers); + } else { + setMcpServers([]); + setInitialMcpServers([]); + } + }).catch((error) => { + console.error('Error reloading agent:', error); + }); + } } } catch (error) { console.error(`Error ${isNew ? 'creating' : 'updating'} agent:`, error); @@ -394,6 +480,73 @@ export function AgentFormPage() { toast.success('Tool removed'); }; + const handleAddMCPServers = (servers: MCPServerDoc[]) => { + // Convert MCPServerDoc to MCPServerRef format for child table + const newServers: MCPServerRef[] = servers.map((server) => ({ + name: '', // Will be set by Frappe when saved + mcp_server: server.name, + server_name: server.server_name, + description: server.description, + server_url: server.server_url, + enabled: true, // Default to enabled when adding + mcp_enabled: server.enabled === 1, + tool_count: 0, // Will be updated when synced + })); + setMcpServers([...mcpServers, ...newServers]); + }; + + const handleRemoveMCPServer = (serverId: string) => { + setMcpServers(mcpServers.filter((s) => s.name !== serverId)); + toast.success('MCP server removed'); + }; + + const handleToggleMCPServer = async (serverId: string, enabled: boolean) => { + setMcpServers( + mcpServers.map((s) => + s.name === serverId ? { ...s, enabled } : s + ) + ); + toast.success(`MCP server ${enabled ? 'enabled' : 'disabled'}`); + }; + + const handleSyncMCPServer = async (serverId: string) => { + const server = mcpServers.find((s) => s.name === serverId); + if (!server) { + toast.error('MCP server not found'); + return; + } + + setMcpLoading(true); + try { + const result = await syncMCPTools(server.mcp_server); + if (result.success) { + // Update the server with new tool count + setMcpServers( + mcpServers.map((s) => + s.name === serverId + ? { + ...s, + tool_count: result.tool_count ?? s.tool_count, + last_sync: new Date().toISOString(), + } + : s + ) + ); + toast.success( + `Synced ${result.tool_count ?? 0} tool${(result.tool_count ?? 0) !== 1 ? 's' : ''} from MCP server` + ); + } else { + toast.error(result.error || 'Failed to sync MCP server tools'); + } + } catch (error) { + console.error('Error syncing MCP server:', error); + const errorMessage = getFrappeErrorMessage(error); + toast.error(errorMessage || 'Failed to sync MCP server tools'); + } finally { + setMcpLoading(false); + } + }; + const handleAddTrigger = () => { setEditingTrigger(null); setShowTriggerModal(true); @@ -556,6 +709,12 @@ export function AgentFormPage() { toolTypes={toolTypes} onAddTools={() => setShowToolsModal(true)} onRemoveTool={handleRemoveTool} + mcpServers={mcpServers} + onAddMCP={() => setShowMCPServersModal(true)} + onRemoveMCP={handleRemoveMCPServer} + onToggleMCP={handleToggleMCPServer} + onSyncMCP={handleSyncMCPServer} + mcpLoading={mcpLoading} /> @@ -582,6 +741,21 @@ export function AgentFormPage() { selectedTools={selectedTools} onAddTools={handleAddTools} /> + + {/* Select MCP Servers Modal */} + ({ + name: s.mcp_server, + server_name: s.server_name || '', + description: s.description, + enabled: ((s.mcp_enabled === true || s.mcp_enabled === 1) ? 1 : 0) as 0 | 1, + server_url: s.server_url || '', + transport_type: 'http' as const, + })) as MCPServerDoc[]} + onAddServers={handleAddMCPServers} + /> ); } diff --git a/frontend/src/pages/McpDetailsPage.tsx b/frontend/src/pages/McpDetailsPage.tsx new file mode 100644 index 0000000..71a314f --- /dev/null +++ b/frontend/src/pages/McpDetailsPage.tsx @@ -0,0 +1,3 @@ +export default function McpDetailsPage() { + return
McpDetailsPage
; +} \ No newline at end of file diff --git a/frontend/src/pages/McpListingPage.tsx b/frontend/src/pages/McpListingPage.tsx new file mode 100644 index 0000000..b2e1264 --- /dev/null +++ b/frontend/src/pages/McpListingPage.tsx @@ -0,0 +1,3 @@ +export default function McpListingPage() { + return
McpListingPage
; +} \ No newline at end of file diff --git a/frontend/src/services/mcpApi.ts b/frontend/src/services/mcpApi.ts index df18364..f42d5e9 100644 --- a/frontend/src/services/mcpApi.ts +++ b/frontend/src/services/mcpApi.ts @@ -34,8 +34,8 @@ export interface MCPServerRef { server_name?: string; // Display name from MCP Server description?: string; // Description from MCP Server server_url?: string; // URL from MCP Server - enabled: boolean; // Whether enabled for this agent - mcp_enabled?: boolean; // Whether the MCP Server itself is enabled + enabled: boolean | number; // Whether enabled for this agent (0/1 from Frappe, boolean from frontend) + mcp_enabled?: boolean | number; // Whether the MCP Server itself is enabled (0/1 from Frappe, boolean from frontend) tool_count?: number; // Number of tools available last_sync?: string; // Last sync timestamp } diff --git a/frontend/src/types/agent.types.ts b/frontend/src/types/agent.types.ts index 6b37a84..b719ebd 100644 --- a/frontend/src/types/agent.types.ts +++ b/frontend/src/types/agent.types.ts @@ -192,6 +192,10 @@ export interface AgentDoc { description?: string | null; instructions: string; agent_tool: AgentToolFunctionRef[]; // Array of agent tool references + agent_mcp_server?: Array<{ + mcp_server: string; + enabled: 0 | 1; + }>; // Array of MCP server references last_run?: string | null; // Last execution timestamp total_run?: number; // Total number of runs } From 74c96ddd1e8be36ca3ae866772959d8ab1751033 Mon Sep 17 00:00:00 2001 From: Sanjusha_tridz Date: Fri, 9 Jan 2026 14:31:39 +0000 Subject: [PATCH 13/29] fix: populate MCP tool_count in Agent MCP Server child table --- huf/huf/doctype/agent/agent.js | 41 +++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/huf/huf/doctype/agent/agent.js b/huf/huf/doctype/agent/agent.js index e8c3946..a84985d 100644 --- a/huf/huf/doctype/agent/agent.js +++ b/huf/huf/doctype/agent/agent.js @@ -23,17 +23,32 @@ frappe.ui.form.on("Agent", { } } }, - provider(frm) { - frm.set_value("model", ""); - - if (frm.doc.provider) { - frm.set_query("model", () => ({ - filters: { provider: frm.doc.provider } - })); - } else { - frm.set_query("model", () => ({})); - } - }, - - + provider(frm) { + frm.set_value("model", ""); + + if (frm.doc.provider) { + frm.set_query("model", () => ({ + filters: { provider: frm.doc.provider } + })); + } else { + frm.set_query("model", () => ({})); + } + } +}); + +frappe.ui.form.on("Agent MCP Server", { + mcp_server(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + if (row.mcp_server) { + frappe.db.get_doc("MCP Server", row.mcp_server).then(doc => { + let count = 0; + if (doc.tools) { + count = doc.tools.length; + } + frappe.model.set_value(cdt, cdn, "tool_count", count); + }); + } else { + frappe.model.set_value(cdt, cdn, "tool_count", 0); + } + } }); From 06c250171f71d11d4da29c7cfd255affcda928cf Mon Sep 17 00:00:00 2001 From: shahzadbinshahajhan Date: Fri, 9 Jan 2026 14:34:28 +0000 Subject: [PATCH 14/29] fix: mcp listing responsive, enabling/disabling --- frontend/src/components/agent/ToolsTab.tsx | 70 +++++++++--------- .../src/components/tools/MCPServerCard.tsx | 2 +- frontend/src/pages/AgentFormPage.tsx | 72 +++++++++++++++---- frontend/src/services/mcpApi.ts | 11 +-- 4 files changed, 102 insertions(+), 53 deletions(-) diff --git a/frontend/src/components/agent/ToolsTab.tsx b/frontend/src/components/agent/ToolsTab.tsx index 01ef76e..058d010 100644 --- a/frontend/src/components/agent/ToolsTab.tsx +++ b/frontend/src/components/agent/ToolsTab.tsx @@ -1,4 +1,4 @@ -import { Plus, Server, Plug, Trash2, RefreshCw, AlertCircle } from 'lucide-react'; +import { Plus, Server, Plug, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; @@ -54,7 +54,9 @@ export function ToolsTab({ if (serverId && onToggleMCP) { const server = mcpServers.find(s => s.name === serverId); if (server) { - onToggleMCP(serverId, !server.enabled); + // Toggle the enabled state - normalize current value first + const currentEnabled = isEnabled(server.enabled); + onToggleMCP(serverId, !currentEnabled); } } else { toast.info('MCP server management coming soon'); @@ -71,20 +73,24 @@ export function ToolsTab({ }; // 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 mcpEnabled = isEnabled(server.mcp_enabled); const agentEnabled = isEnabled(server.enabled); - if (!mcpEnabled) { + // 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 inactive; + return disabled; } + // Both enabled - show "connected" return connected; }; @@ -201,7 +207,7 @@ export function ToolsTab({ ) : ( -
+
{mcpServers.map((mcp) => (
-

{mcp.server_name || mcp.mcp_server}

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

{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} +

)} -
- {mcp.description && ( -

{mcp.description}

- )} - {/* {mcp.server_url && ( -

- {mcp.server_url} -

- )} */}
-
handleMCPAction('toggle', mcp.name)} - className={mcpLoading || !isEnabled(mcp.mcp_enabled) ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'} - > - -
+ { + // 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); + } + }} + />
+ Date: Fri, 9 Jan 2026 14:50:37 +0000 Subject: [PATCH 16/29] fix: bind sync_tools_button to sync logic and refactor in MCP Server --- huf/huf/doctype/mcp_server/mcp_server.js | 42 +++++++++++++++--------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/huf/huf/doctype/mcp_server/mcp_server.js b/huf/huf/doctype/mcp_server/mcp_server.js index 70c7042..b29fa8f 100644 --- a/huf/huf/doctype/mcp_server/mcp_server.js +++ b/huf/huf/doctype/mcp_server/mcp_server.js @@ -5,22 +5,12 @@ frappe.ui.form.on("MCP Server", { refresh(frm) { // Add sync tools button handler if (!frm.is_new()) { - frm.add_custom_button(__("Sync Tools"), function() { - frm.call({ - method: "sync_tools", - doc: frm.doc, - freeze: true, - freeze_message: __("Syncing tools from MCP server..."), - callback: function(r) { - if (r.message && r.message.success) { - frm.reload_doc(); - } - } - }); + frm.add_custom_button(__("Sync Tools"), function () { + frm.events.sync_tools(frm); }, __("Actions")); - + // Add test connection button - frm.add_custom_button(__("Test Connection"), function() { + frm.add_custom_button(__("Test Connection"), function () { frappe.call({ method: "huf.ai.mcp_client.test_mcp_connection", args: { @@ -28,7 +18,7 @@ frappe.ui.form.on("MCP Server", { }, freeze: true, freeze_message: __("Testing connection..."), - callback: function(r) { + callback: function (r) { if (r.message && r.message.success) { frappe.msgprint({ title: __("Connection Successful"), @@ -47,7 +37,27 @@ frappe.ui.form.on("MCP Server", { }, __("Actions")); } }, - + + sync_tools_button(frm) { + frm.events.sync_tools(frm); + }, + + sync_tools(frm) { + frm.call({ + method: "sync_tools", + doc: frm.doc, + freeze: true, + freeze_message: __("Syncing tools from MCP server..."), + callback: function (r) { + if (r.message && r.message.success) { + frm.reload_doc(); + } + } + }); + }, + + + auth_type(frm) { // Set default header name based on auth type if (frm.doc.auth_type === "bearer_token") { From a7ceb25af00fa698f7780afdfaed5eea3a91c5e9 Mon Sep 17 00:00:00 2001 From: shahzadbinshahajhan Date: Fri, 9 Jan 2026 14:45:02 +0000 Subject: [PATCH 17/29] update: click of card opens mcp details page --- frontend/src/components/agent/ToolsTab.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/agent/ToolsTab.tsx b/frontend/src/components/agent/ToolsTab.tsx index b1fb7e8..39a52ea 100644 --- a/frontend/src/components/agent/ToolsTab.tsx +++ b/frontend/src/components/agent/ToolsTab.tsx @@ -1,4 +1,5 @@ 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'; @@ -202,7 +203,10 @@ export function ToolsTab({ key={mcp.name} className="flex items-start justify-between gap-3 rounded-lg border p-4 hover:bg-muted/50 transition-colors" > -
+

{mcp.server_name || mcp.mcp_server}

{getStatusBadge(mcp)} @@ -220,8 +224,8 @@ export function ToolsTab({ {mcp.server_url}

)} -
-
+ +
e.stopPropagation()}> - ))} -
- )} + {(actions.length > 0 || footer) && ( +
+ {actions.length > 0 && ( +
+ {actions.map((action, index) => ( + + ))} +
+ )} - {footer &&
{footer}
} - - )} + {footer &&
{footer}
} +
+ )} + + )} +
); } diff --git a/frontend/src/components/tools/SelectMCPServersModal.tsx b/frontend/src/components/tools/SelectMCPServersModal.tsx index f9c2ecb..30a4cd9 100644 --- a/frontend/src/components/tools/SelectMCPServersModal.tsx +++ b/frontend/src/components/tools/SelectMCPServersModal.tsx @@ -40,8 +40,11 @@ export function SelectMCPServersModal({ useEffect(() => { if (open) { setLoading(true); + // Call without params to get all servers as array (backward compatible) getMCPServers() - .then((servers) => { + .then((response) => { + // Handle both array and paginated response formats + const servers = Array.isArray(response) ? response : response.items; setAllServers(servers); setLoading(false); }) diff --git a/frontend/src/pages/McpListingPage.tsx b/frontend/src/pages/McpListingPage.tsx index b2e1264..a86f49a 100644 --- a/frontend/src/pages/McpListingPage.tsx +++ b/frontend/src/pages/McpListingPage.tsx @@ -1,3 +1,136 @@ +import { Calendar, Settings, Loader2, Tag } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { PageLayout, FilterBar, GridView, ItemCard } from '../components/dashboard'; +import { useInfiniteScroll } from '../hooks/useInfiniteScroll'; +import { getMCPServers } from '../services/mcpApi'; +import { formatTimeAgo } from '../utils/time'; +import type { MCPServerDoc } from '../services/mcpApi'; +import { Button } from '../components/ui/button'; + +function getStatusVariant(enabled: 0 | 1): 'default' | 'secondary' { + return enabled === 1 ? 'default' : 'secondary'; +} + +function getStatusLabel(enabled: 0 | 1): 'enabled' | 'disabled' { + return enabled === 1 ? 'enabled' : 'disabled'; +} + export default function McpListingPage() { - return
McpListingPage
; -} \ No newline at end of file + const navigate = useNavigate(); + + const { + items: servers, + hasMore, + initialLoading, + loadingMore, + search, + setSearch, + loadMore, + total, + } = useInfiniteScroll< + { page?: number; limit?: number; start?: number; search?: string }, + MCPServerDoc + >({ + fetchFn: async (params) => { + const response = await getMCPServers({ + page: params.page, + limit: params.limit, + start: params.start, + search: params.search, + }); + + // Handle both old (array) and new (paginated) response formats + if (Array.isArray(response)) { + return { + data: response, + hasMore: false, + total: response.length, + }; + } + + // Convert PaginatedMCPServersResponse to PaginatedResponse format + return { + data: response.items, + hasMore: response.hasMore, + total: response.total, + }; + }, + initialParams: {}, + pageSize: 20, + debounceMs: 300, + autoLoad: true, + }); + + return ( + + } + > + +

No MCP servers found.

+
+ } + renderItem={(server) => { + const status = getStatusLabel(server.enabled); + return ( + navigate(`/mcp/${server.name}`), + }, + ]} + onClick={() => navigate(`/mcp/${server.name}`)} + /> + ); + }} + keyExtractor={(server) => server.name} + /> + {hasMore && ( +
+ +
+ )} + {!hasMore && servers.length > 0 && ( +
+ {total !== undefined ? `Showing all ${total} MCP servers` : 'No more MCP servers to load'} +
+ )} + + ); +} diff --git a/frontend/src/services/mcpApi.ts b/frontend/src/services/mcpApi.ts index a9d3005..d6e9e26 100644 --- a/frontend/src/services/mcpApi.ts +++ b/frontend/src/services/mcpApi.ts @@ -41,12 +41,70 @@ export interface MCPServerRef { last_sync?: string; // Last sync timestamp } +/** + * Pagination parameters for MCP servers + */ +export interface GetMCPServersParams { + page?: number; + limit?: number; + start?: number; + search?: string; +} + +/** + * Paginated response for MCP servers + */ +export interface PaginatedMCPServersResponse { + items: MCPServerDoc[]; + hasMore: boolean; + total?: number; +} + /** * Fetch all available MCP servers + * Supports pagination and search */ -export async function getMCPServers(): Promise { +export async function getMCPServers( + params?: GetMCPServersParams +): Promise { try { - const response = await db.getDocList(doctype['MCP Server'], { + // Backward compatibility: if no params, return array (old API) + if (!params) { + const response = await db.getDocList(doctype['MCP Server'], { + fields: [ + 'name', + 'server_name', + 'description', + 'enabled', + 'transport_type', + 'server_url', + 'tool_namespace', + 'timeout_seconds', + 'last_sync', + ], + orderBy: { field: 'server_name', order: 'asc' }, + limit: 100, + }); + return response as MCPServerDoc[]; + } + + const { + page = 1, + limit = 20, + start = (page - 1) * limit, + search, + } = params; + + // Build filters + const filters: Array<[string, string, unknown]> = []; + + // Build search filters if provided (search in server_name) + if (search && search.trim()) { + filters.push(['server_name', 'like', `%${search.trim()}%`]); + } + + // Fetch data + const servers = await db.getDocList(doctype['MCP Server'], { fields: [ 'name', 'server_name', @@ -58,13 +116,35 @@ export async function getMCPServers(): Promise { 'timeout_seconds', 'last_sync', ], - orderBy: { field: 'server_name', order: 'asc' }, - limit: 100, + filters: filters.length > 0 ? (filters as any) : undefined, + limit: limit + 1, // Fetch one extra to check if there's more + ...(start > 0 && { limit_start: start }), // Only include if start > 0 + orderBy: { field: 'modified', order: 'desc' }, }); - return response as MCPServerDoc[]; + + const mappedServers = servers as MCPServerDoc[]; + const hasMore = mappedServers.length > limit; + const items = hasMore ? mappedServers.slice(0, limit) : mappedServers; + + // Only fetch count on first page to avoid unnecessary API calls + let total: number | undefined; + if (page === 1) { + try { + const { fetchDocCount } = await import('./utilsApi'); + const countFilters = [...filters]; + total = await fetchDocCount(doctype['MCP Server'], countFilters); + } catch { + // Ignore count errors - total is optional + } + } + + return { + items, + hasMore, + total, + }; } catch (error) { - handleFrappeError(error); - throw error; + handleFrappeError(error, 'Error fetching MCP servers'); } } From e86625ba8ca6eb7f1551830b53a9da09e485508b Mon Sep 17 00:00:00 2001 From: shahzadbinshahajhan Date: Fri, 9 Jan 2026 17:26:18 +0000 Subject: [PATCH 21/29] fix: breadcrumb spacing --- frontend/src/layouts/UnifiedHeader.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/layouts/UnifiedHeader.tsx b/frontend/src/layouts/UnifiedHeader.tsx index 80a7e73..ed40332 100644 --- a/frontend/src/layouts/UnifiedHeader.tsx +++ b/frontend/src/layouts/UnifiedHeader.tsx @@ -1,5 +1,5 @@ import { Link, useLocation } from 'react-router-dom'; -import { ReactNode } from 'react'; +import { Fragment, ReactNode } from 'react'; import { Breadcrumb, BreadcrumbItem, @@ -38,8 +38,8 @@ export function UnifiedHeader({ actions, breadcrumbs }: UnifiedHeaderProps) { {breadcrumbs.map((crumb, index) => ( -
- {index > 0 && } + +
{index === breadcrumbs.length - 1 ? ( {crumb.label} @@ -52,6 +52,8 @@ export function UnifiedHeader({ actions, breadcrumbs }: UnifiedHeaderProps) { )}
+ {index < breadcrumbs.length - 1 && } +
))} From 9db24e05366a427c489e870bb36909cb2a0c5aec Mon Sep 17 00:00:00 2001 From: shahzadbinshahajhan Date: Fri, 9 Jan 2026 17:30:43 +0000 Subject: [PATCH 22/29] feat: card grid skeleton for loader --- .../dashboard/cards/SkeletonCard.tsx | 45 +++++++++++++++++++ frontend/src/components/dashboard/index.ts | 2 + .../components/dashboard/views/GridView.tsx | 11 ++--- .../dashboard/views/SkeletonGridView.tsx | 25 +++++++++++ frontend/src/components/ui/skeleton.tsx | 2 +- frontend/src/pages/FlowListPage.tsx | 3 +- 6 files changed, 79 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/dashboard/cards/SkeletonCard.tsx create mode 100644 frontend/src/components/dashboard/views/SkeletonGridView.tsx 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..d254aed 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,4 @@ 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'; 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/ui/skeleton.tsx b/frontend/src/components/ui/skeleton.tsx index cb09f16..a626d9b 100644 --- a/frontend/src/components/ui/skeleton.tsx +++ b/frontend/src/components/ui/skeleton.tsx @@ -6,7 +6,7 @@ function Skeleton({ }: React.HTMLAttributes) { return (
); diff --git a/frontend/src/pages/FlowListPage.tsx b/frontend/src/pages/FlowListPage.tsx index 984f682..209026a 100644 --- a/frontend/src/pages/FlowListPage.tsx +++ b/frontend/src/pages/FlowListPage.tsx @@ -37,7 +37,7 @@ export function FlowListPage() { const { flows } = useFlowContext(); const navigate = useNavigate(); - const { data, search, setSearch, filters, setFilters } = usePageData({ + const { data, loading, search, setSearch, filters, setFilters } = usePageData({ initialData: flows, searchFields: ['name', 'description'], filterFn: (flow, filters) => { @@ -83,6 +83,7 @@ export function FlowListPage() { ( Date: Fri, 9 Jan 2026 17:36:38 +0000 Subject: [PATCH 23/29] update: reusable load more button --- .../components/dashboard/LoadMoreButton.tsx | 40 +++++++++++++++++++ frontend/src/components/dashboard/index.ts | 2 + frontend/src/pages/AgentsPage.tsx | 29 ++++---------- frontend/src/pages/Executions.tsx | 26 ++++-------- frontend/src/pages/IntegrationsPage.tsx | 26 ++++-------- frontend/src/pages/McpListingPage.tsx | 29 ++++---------- 6 files changed, 72 insertions(+), 80 deletions(-) create mode 100644 frontend/src/components/dashboard/LoadMoreButton.tsx 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/index.ts b/frontend/src/components/dashboard/index.ts index d254aed..21547a5 100644 --- a/frontend/src/components/dashboard/index.ts +++ b/frontend/src/components/dashboard/index.ts @@ -14,3 +14,5 @@ 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/pages/AgentsPage.tsx b/frontend/src/pages/AgentsPage.tsx index c335b91..d1af1ed 100644 --- a/frontend/src/pages/AgentsPage.tsx +++ b/frontend/src/pages/AgentsPage.tsx @@ -1,11 +1,10 @@ -import { Calendar, Activity, Settings, Zap, Loader2 } from 'lucide-react'; +import { Calendar, Activity, Settings, Zap } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; -import { PageLayout, FilterBar, GridView, ItemCard } from '../components/dashboard'; +import { PageLayout, FilterBar, GridView, ItemCard, LoadMoreButton } from '../components/dashboard'; import { useInfiniteScroll } from '../hooks/useInfiniteScroll'; import { getAgents } from '../services/agentApi'; import { formatTimeAgo } from '../utils/time'; import type { AgentDoc } from '../types/agent.types'; -import { Button } from '../components/ui/button'; const statusOptions = [ { label: 'All Status', value: 'all' }, @@ -138,24 +137,12 @@ export function AgentsPage() { }} keyExtractor={(agent) => agent.name} /> - {hasMore && ( -
- -
- )} + {!hasMore && agents.length > 0 && (
{total !== undefined ? `Showing all ${total} agents` : 'No more agents to load'} diff --git a/frontend/src/pages/Executions.tsx b/frontend/src/pages/Executions.tsx index 7427517..619e833 100644 --- a/frontend/src/pages/Executions.tsx +++ b/frontend/src/pages/Executions.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo } from 'react'; import { ArrowUpDown, Loader2 } from 'lucide-react'; -import { FilterBar, PageLayout } from '@/components/dashboard'; +import { FilterBar, PageLayout, LoadMoreButton } from '@/components/dashboard'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useInfiniteScroll } from '../hooks/useInfiniteScroll'; import { getAgentRuns, type AgentRunDoc } from '@/services/agentRunApi'; @@ -353,24 +353,12 @@ export default function Executions() { )}
- {hasMore && ( -
- -
- )} + {!hasMore && runs.length > 0 && (
diff --git a/frontend/src/pages/IntegrationsPage.tsx b/frontend/src/pages/IntegrationsPage.tsx index 8b6d1ef..6690bd9 100644 --- a/frontend/src/pages/IntegrationsPage.tsx +++ b/frontend/src/pages/IntegrationsPage.tsx @@ -12,7 +12,7 @@ import { } from '../components/ui/dialog'; import { Input } from '../components/ui/input'; import { Label } from '../components/ui/label'; -import { PageLayout, FilterBar, GridView } from '../components/dashboard'; +import { PageLayout, FilterBar, GridView, LoadMoreButton } from '../components/dashboard'; import { useInfiniteScroll } from '../hooks/useInfiniteScroll'; import { getProviders, getProvider, updateProvider, createProvider } from '../services/providerApi'; import { getModels } from '../services/providerApi'; @@ -248,24 +248,12 @@ export function IntegrationsPage({ addProviderKey }: IntegrationsPageProps) { }} keyExtractor={(provider) => provider.name} /> - {hasMore && ( -
- -
- )} + {!hasMore && providers.length > 0 && (
{total !== undefined ? `Showing all ${total} providers` : 'No more providers to load'} diff --git a/frontend/src/pages/McpListingPage.tsx b/frontend/src/pages/McpListingPage.tsx index a86f49a..4b18a05 100644 --- a/frontend/src/pages/McpListingPage.tsx +++ b/frontend/src/pages/McpListingPage.tsx @@ -1,11 +1,10 @@ -import { Calendar, Settings, Loader2, Tag } from 'lucide-react'; +import { Calendar, Settings, Tag } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; -import { PageLayout, FilterBar, GridView, ItemCard } from '../components/dashboard'; +import { PageLayout, FilterBar, GridView, ItemCard, LoadMoreButton } from '../components/dashboard'; import { useInfiniteScroll } from '../hooks/useInfiniteScroll'; import { getMCPServers } from '../services/mcpApi'; import { formatTimeAgo } from '../utils/time'; import type { MCPServerDoc } from '../services/mcpApi'; -import { Button } from '../components/ui/button'; function getStatusVariant(enabled: 0 | 1): 'default' | 'secondary' { return enabled === 1 ? 'default' : 'secondary'; @@ -108,24 +107,12 @@ export default function McpListingPage() { }} keyExtractor={(server) => server.name} /> - {hasMore && ( -
- -
- )} + {!hasMore && servers.length > 0 && (
{total !== undefined ? `Showing all ${total} MCP servers` : 'No more MCP servers to load'} From b1acff18e6dda7807d20a9cb3afc46cc3306da67 Mon Sep 17 00:00:00 2001 From: shahzadbinshahajhan Date: Fri, 9 Jan 2026 18:04:15 +0000 Subject: [PATCH 24/29] feat: mcp details page/form --- frontend/src/App.tsx | 6 +- frontend/src/components/mcp/ConnectionTab.tsx | 163 +++++++++++++ frontend/src/components/mcp/DetailsTab.tsx | 118 +++++++++ frontend/src/components/mcp/MCPHeader.tsx | 61 +++++ frontend/src/components/mcp/types.ts | 17 ++ frontend/src/data/mcp.ts | 39 +++ frontend/src/pages/McpDetailsPage.tsx | 223 +++++++++++++++++- frontend/src/pages/McpDetailsPageWrapper.tsx | 36 +++ frontend/src/services/mcpApi.ts | 1 + 9 files changed, 657 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/mcp/ConnectionTab.tsx create mode 100644 frontend/src/components/mcp/DetailsTab.tsx create mode 100644 frontend/src/components/mcp/MCPHeader.tsx create mode 100644 frontend/src/components/mcp/types.ts create mode 100644 frontend/src/data/mcp.ts create mode 100644 frontend/src/pages/McpDetailsPageWrapper.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0980c0e..37b86cd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,7 +23,7 @@ import Executions from './pages/Executions'; import { AgentRunDetailPage } from './pages/AgentRunDetailPage'; import { useEffect } from 'react'; import { createFrappeSocket } from './utils/socket'; -import McpDetailsPage from './pages/McpDetailsPage'; +import { McpDetailsPageWrapper } from './pages/McpDetailsPageWrapper'; import McpListingPage from './pages/McpListingPage'; function App() { @@ -212,9 +212,7 @@ function App() { path="/mcp/:mcpId" element={ - - - + } /> 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 + +