diff --git a/mcp_connector/README.rst b/mcp_connector/README.rst new file mode 100644 index 0000000..b4de2ac --- /dev/null +++ b/mcp_connector/README.rst @@ -0,0 +1,665 @@ +============= +MCP Connector +============= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:b9a33cdd36b66f3a3bd2a72dbd3b44015b0da3bd2bc6d5dea429774689c8dc39 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fai-lightgray.png?logo=github + :target: https://github.com/OCA/ai/tree/16.0/mcp_connector + :alt: OCA/ai +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/ai-16-0/ai-16-0-mcp_connector + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/ai&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Overview +-------- + +The MCP Connector module provides seamless integration between Odoo and +Model Context Protocol (MCP) servers. This powerful module enables +businesses to connect, manage, and leverage external tools, resources, +and AI capabilities directly within their Odoo environment. + +What is Model Context Protocol (MCP)? +------------------------------------- + +Model Context Protocol (MCP) is a standardized protocol that enables AI +applications to securely connect to data sources and tools. It provides +a common interface for AI systems to access external resources, making +it easier to integrate AI capabilities into existing applications. + +Key Features +------------ + +Server Management +~~~~~~~~~~~~~~~~~ + +- **Easy Configuration**: Set up MCP servers with simple forms and + wizards +- **Command Execution**: Run servers with custom commands and arguments +- **Environment Variables**: Configure server settings through + environment variables +- **Auto-Start**: Automatically start servers when Odoo starts +- **Status Monitoring**: Real-time server status and health monitoring + +Tool Integration +~~~~~~~~~~~~~~~~ + +- **Automatic Discovery**: Automatically discover tools exposed by MCP + servers +- **Intuitive Interface**: User-friendly wizards for tool execution +- **Parameter Support**: Pass JSON parameters to tools +- **Response Handling**: Process and display tool responses +- **Error Management**: Comprehensive error handling and logging + +Resource Handling +~~~~~~~~~~~~~~~~~ + +- **Resource Discovery**: Automatically discover available resources +- **Resource Reading**: Read resources with optional parameters +- **Template Support**: Support for templated resource outputs +- **Caching**: Intelligent caching for improved performance +- **Access Control**: Secure access to resources + +Security +~~~~~~~~ + +- **Access Rights**: Role-based access control +- **Secure Communication**: Encrypted communication with MCP servers +- **Audit Trail**: Complete audit trail of all operations +- **User Permissions**: Granular permission system + +Technical Architecture +---------------------- + +Threading Model +~~~~~~~~~~~~~~~ + +The module uses a threaded architecture to run MCP servers in the +background, ensuring that Odoo remains responsive while MCP operations +are being performed. + +Protocol Compliance +~~~~~~~~~~~~~~~~~~~ + +The module fully implements the Model Context Protocol specification, +ensuring compatibility with any MCP-compliant server. + +Error Handling +~~~~~~~~~~~~~~ + +Comprehensive error handling ensures that MCP server issues don't affect +Odoo's stability. + +Use Cases +--------- + +AI Integration +~~~~~~~~~~~~~~ + +- **Image Generation**: Connect to AI image generation services like + EverArt +- **Text Processing**: Integrate with language models for text analysis +- **Translation**: Add multi-language support to Odoo +- **Content Creation**: Generate content using AI tools + +Data Integration +~~~~~~~~~~~~~~~~ + +- **Database Access**: Connect to SQLite databases via MCP servers +- **Web Automation**: Use Puppeteer for web scraping and automation +- **Memory Management**: Store and retrieve data using memory servers +- **Analytics**: Connect to business intelligence tools + +Official MCP Servers +~~~~~~~~~~~~~~~~~~~~ + +- **EverArt**: AI image generation and manipulation +- **SQLite**: Database operations and queries +- **Puppeteer**: Web automation and scraping +- **Memory**: Data storage and retrieval +- **Custom Servers**: Connect to any MCP-compliant server + +Custom Tools +~~~~~~~~~~~~ + +- **Internal APIs**: Connect to internal company APIs +- **Legacy Systems**: Integrate with legacy systems +- **Third-party Services**: Connect to external services +- **Custom Workflows**: Create custom business workflows + +Benefits +-------- + +For Developers +~~~~~~~~~~~~~~ + +- **Easy Integration**: Simple API for connecting to MCP servers +- **Extensible**: Easy to add new MCP server types +- **Well Documented**: Comprehensive documentation and examples +- **Open Source**: Full source code available for customization + +For Business Users +~~~~~~~~~~~~~~~~~~ + +- **User-Friendly**: Intuitive interface for non-technical users +- **Powerful**: Access to advanced AI and external tools +- **Reliable**: Robust error handling and monitoring +- **Scalable**: Supports multiple servers and concurrent operations + +For Organizations +~~~~~~~~~~~~~~~~~ + +- **Cost-Effective**: Open source solution with no licensing fees +- **Flexible**: Adapt to any MCP-compliant server +- **Secure**: Built-in security and access controls +- **Maintainable**: Well-structured code and documentation + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Prerequisites +------------- + +Before using the MCP Connector module, ensure you have: + +1. **Python MCP SDK**: Install the required dependency: + + .. code:: bash + + pip install mcp + +2. **MCP Server**: Have access to an MCP-compliant server or create your + own. + +Basic Configuration +------------------- + +1. Install the Module +~~~~~~~~~~~~~~~~~~~~~ + +1. Place the module in your Odoo addons directory +2. Restart your Odoo server +3. Update the apps list: ``odoo-bin -u all`` +4. Install the module from the Apps menu + +2. Configure MCP Server +~~~~~~~~~~~~~~~~~~~~~~~ + +1. Navigate to **MCP Servers** +2. Click **Create** to add a new server +3. Fill in the required fields: + + - **Server Name**: A descriptive name for your server + - **Command**: The executable to run (e.g., ``node``, ``python3``, + ``python``) + - **Arguments**: JSON array of command arguments + - **Environment Variables**: JSON object with environment settings + +3. Example Configuration +~~~~~~~~~~~~~~~~~~~~~~~~ + +**Command**: ``npx`` **Arguments**: +``["-y", "@modelcontextprotocol/server-everart"]`` **Environment +Variables**: ``{"EVERART_API_KEY": "your-api-key"}`` + +Advanced Configuration +---------------------- + +Auto-Start Configuration +~~~~~~~~~~~~~~~~~~~~~~~~ + +Enable the **Auto-Start** option to automatically launch MCP servers +when Odoo starts. This ensures the MCP server is always available. + +Security Configuration +~~~~~~~~~~~~~~~~~~~~~~ + +- **Access Rights**: Configure role-based access control +- **Environment Variables**: Use secure environment variable management +- **Server Isolation**: Run servers in isolated environments when + possible + +Server Management +----------------- + +Starting and Stopping Servers +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. **Manual Control**: Use the **Start** and **Stop** buttons in the + server interface +2. **Auto-Start**: Enable auto-start for critical servers +3. **Status Monitoring**: Monitor server health through the interface + +Troubleshooting +~~~~~~~~~~~~~~~ + +1. **Check Logs**: Review server logs for error messages +2. **Verify Dependencies**: Ensure all required dependencies are + installed +3. **Test Connection**: Use the test connection feature +4. **Check Permissions**: Verify file and network permissions + +Performance Optimization +------------------------ + +Resource Management +~~~~~~~~~~~~~~~~~~~ + +1. **Memory Usage**: Monitor server memory consumption +2. **CPU Usage**: Track CPU utilization +3. **Connection Limits**: Set appropriate connection limits +4. **Timeout Settings**: Configure appropriate timeout values + +Scaling +~~~~~~~ + +1. **Multiple Servers**: Run multiple MCP servers for load distribution +2. **Load Balancing**: Implement load balancing strategies +3. **Caching**: Enable caching for frequently accessed resources +4. **Monitoring**: Set up comprehensive monitoring + +Integration Examples +-------------------- + +AI Image Generation (EverArt) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code:: json + + { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-everart"], + "env_vars": { + "EVERART_API_KEY": "your-everart-api-key" + } + } + +SQLite Database Server +~~~~~~~~~~~~~~~~~~~~~~ + +.. code:: json + + { + "command": "uvx", + "args": ["mcp-server-sqlite", "--db-path", "./database.db"], + "env_vars": {} + } + +Puppeteer Web Automation +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code:: json + + { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-puppeteer"], + "env_vars": {} + } + +Memory Server +~~~~~~~~~~~~~ + +.. code:: json + + { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "env_vars": {} + } + +Maintenance +----------- + +Regular Updates +~~~~~~~~~~~~~~~ + +1. **Module Updates**: Keep the module updated +2. **Dependency Updates**: Update MCP SDK and server dependencies +3. **Security Patches**: Apply security patches promptly +4. **Backup Configuration**: Backup server configurations + +Monitoring and Alerts +~~~~~~~~~~~~~~~~~~~~~ + +1. **Health Checks**: Implement regular health checks +2. **Performance Metrics**: Monitor key performance indicators +3. **Error Tracking**: Set up error tracking and alerting +4. **Log Analysis**: Regular analysis of server logs + +Best Practices +-------------- + +Security +~~~~~~~~ + +1. **Environment Variables**: Never hardcode sensitive information +2. **Access Control**: Implement proper access control +3. **Network Security**: Use secure network configurations +4. **Regular Audits**: Conduct regular security audits + +Performance +~~~~~~~~~~~ + +1. **Resource Monitoring**: Monitor resource usage +2. **Optimization**: Regular performance optimization +3. **Caching**: Implement appropriate caching strategies +4. **Load Testing**: Regular load testing + +Maintenance +~~~~~~~~~~~ + +1. **Regular Updates**: Keep all components updated +2. **Backup Strategies**: Implement comprehensive backup strategies +3. **Documentation**: Maintain up-to-date documentation +4. **Testing**: Regular testing of configurations + +Usage +===== + +Getting Started +--------------- + +1. First Steps +~~~~~~~~~~~~~~ + +After installing the MCP Connector module: + +1. Navigate to **MCP** in the main menu +2. You'll see three main sections: + + - **Servers**: Manage MCP server configurations + - **Tools**: Interact with discovered tools + - **Resources**: Access server resources + +2. Setting Up Your First Server +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Go to **MCP Servers** +2. Click **Create** +3. Fill in the server details: + + - **Name**: Give your server a descriptive name + - **Command**: The executable to run (e.g., ``npx``, ``uvx``) + - **Arguments**: JSON array of command arguments + - **Environment Variables**: JSON object with settings + +4. Click **Save** +5. Click **Start Server** to launch it + +3. Discovering Tools and Resources +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Once your server is running: + +1. **Tools**: The module will automatically discover available tools +2. **Resources**: Available resources will be listed +3. **Status**: Monitor server status and health + +Official MCP Servers +-------------------- + +The module supports official MCP servers from the `Model Context +Protocol servers +repository `__. Here +are some popular options: + +EverArt AI Image Generation +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code:: json + + { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-everart"], + "env_vars": { + "EVERART_API_KEY": "your-api-key" + } + } + +SQLite Database Server +~~~~~~~~~~~~~~~~~~~~~~ + +.. code:: json + + { + "command": "uvx", + "args": ["mcp-server-sqlite", "--db-path", "./database.db"], + "env_vars": {} + } + +Puppeteer Web Automation +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code:: json + + { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-puppeteer"], + "env_vars": {} + } + +Memory Server +~~~~~~~~~~~~~ + +.. code:: json + + { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "env_vars": {} + } + +Using MCP Tools +--------------- + +1. Accessing Tools +~~~~~~~~~~~~~~~~~~ + +1. Navigate to **MCP Tools** +2. Select a tool from the list +3. Click **Execute Tool** + +2. Tool Parameters +~~~~~~~~~~~~~~~~~~ + +1. **Input Schema**: Review the tool's input requirements +2. **Parameters**: Fill in the required parameters as JSON +3. **Execute**: Run the tool with your parameters + +3. Tool Responses +~~~~~~~~~~~~~~~~~ + +1. **Response Handling**: View tool responses in the interface +2. **Error Handling**: Review any errors or warnings +3. **Logging**: Check execution logs for debugging + +Using MCP Resources +------------------- + +1. Accessing Resources +~~~~~~~~~~~~~~~~~~~~~~ + +1. Navigate to **MCP Resources** +2. Select a resource from the list +3. Click **Read Resource** + +2. Resource Parameters +~~~~~~~~~~~~~~~~~~~~~~ + +1. **URI**: Review the resource URI +2. **Parameters**: Provide any required parameters +3. **Read**: Access the resource content + +3. Resource Content +~~~~~~~~~~~~~~~~~~~ + +1. **Content Display**: View resource content in the interface +2. **MIME Type**: Check the content type +3. **Caching**: Resources may be cached for performance + +Advanced Usage +-------------- + +1. Multiple Servers +~~~~~~~~~~~~~~~~~~~ + +1. **Server Management**: Configure multiple MCP servers +2. **Load Distribution**: Distribute load across servers +3. **Failover**: Implement failover strategies + +2. Custom Integrations +~~~~~~~~~~~~~~~~~~~~~~ + +1. **API Integration**: Connect to external APIs +2. **Database Integration**: Access database resources +3. **File System**: Work with file system resources + +3. Workflow Automation +~~~~~~~~~~~~~~~~~~~~~~ + +1. **Tool Chains**: Chain multiple tools together +2. **Conditional Logic**: Implement conditional execution +3. **Scheduling**: Schedule automated tasks + +Monitoring and Maintenance +-------------------------- + +1. Server Monitoring +~~~~~~~~~~~~~~~~~~~~ + +1. **Status Dashboard**: Monitor server health +2. **Performance Metrics**: Track performance indicators +3. **Error Logs**: Review error logs and alerts + +2. Tool Management +~~~~~~~~~~~~~~~~~~ + +1. **Tool Discovery**: Monitor tool discovery process +2. **Tool Updates**: Handle tool updates and changes +3. **Tool Testing**: Test tools before production use + +3. Resource Management +~~~~~~~~~~~~~~~~~~~~~~ + +1. **Resource Monitoring**: Monitor resource availability +2. **Cache Management**: Manage resource caching +3. **Access Control**: Control resource access permissions + +Troubleshooting +--------------- + +1. Common Issues +~~~~~~~~~~~~~~~~ + +1. **Server Connection**: Check server connectivity +2. **Tool Execution**: Verify tool parameters and permissions +3. **Resource Access**: Check resource availability and permissions + +2. Debugging +~~~~~~~~~~~~ + +1. **Log Analysis**: Review detailed logs +2. **Error Messages**: Analyze error messages +3. **Performance Issues**: Identify performance bottlenecks + +3. Support +~~~~~~~~~~ + +1. **Documentation**: Consult module documentation +2. **Community**: Seek help from the Odoo community +3. **Issues**: Report issues through GitHub + +Best Practices +-------------- + +1. Security +~~~~~~~~~~~ + +1. **Access Control**: Implement proper access controls +2. **Environment Variables**: Secure sensitive configuration +3. **Network Security**: Use secure network configurations + +2. Performance +~~~~~~~~~~~~~~ + +1. **Resource Management**: Monitor resource usage +2. **Caching**: Implement appropriate caching +3. **Load Balancing**: Distribute load effectively + +3. Maintenance +~~~~~~~~~~~~~~ + +1. **Regular Updates**: Keep components updated +2. **Monitoring**: Implement comprehensive monitoring +3. **Backup**: Regular backup of configurations + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Escodoo + +Contributors +------------ + +- Escodoo +- Marcel Savegnago marcel.savegnago@escodoo.com.br + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-marcelsavegnago| image:: https://github.com/marcelsavegnago.png?size=40px + :target: https://github.com/marcelsavegnago + :alt: marcelsavegnago + +Current `maintainer `__: + +|maintainer-marcelsavegnago| + +This module is part of the `OCA/ai `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/mcp_connector/__init__.py b/mcp_connector/__init__.py new file mode 100644 index 0000000..9b42961 --- /dev/null +++ b/mcp_connector/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/mcp_connector/__manifest__.py b/mcp_connector/__manifest__.py new file mode 100644 index 0000000..b629ff5 --- /dev/null +++ b/mcp_connector/__manifest__.py @@ -0,0 +1,33 @@ +{ + "name": "MCP Connector", + "summary": "Integrate Odoo with Model Context Protocol (MCP) servers", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "Escodoo,Odoo Community Association (OCA)", + "maintainers": ["marcelsavegnago"], + "website": "https://github.com/OCA/ai", + "depends": ["base"], + "external_dependencies": { + "python": [ + "mcp", + "uv", + ] + }, + "data": [ + "security/ir.model.access.csv", + "views/mcp_server_views.xml", + "views/mcp_tool_views.xml", + "views/mcp_resource_views.xml", + "views/mcp_prompt_views.xml", + "views/menu_views.xml", + "wizard/mcp_tool_call_wizard.xml", + "wizard/mcp_resource_read_wizard.xml", + "wizard/mcp_prompt_get_wizard.xml", + ], + "demo": [ + "demo/demo_data.xml", + ], + "installable": True, + "application": True, + "auto_install": False, +} diff --git a/mcp_connector/demo/demo_data.xml b/mcp_connector/demo/demo_data.xml new file mode 100644 index 0000000..cde29b2 --- /dev/null +++ b/mcp_connector/demo/demo_data.xml @@ -0,0 +1,28 @@ + + + + + + Memory Server + npx + ["-y", "@modelcontextprotocol/server-memory"] + {} + ["*"] + true + true + stopped + + + + + Puppeteer Web Automation Server + npx + ["-y", "@modelcontextprotocol/server-puppeteer"] + {} + ["*"] + true + true + stopped + + + diff --git a/mcp_connector/models/__init__.py b/mcp_connector/models/__init__.py new file mode 100644 index 0000000..1c5060d --- /dev/null +++ b/mcp_connector/models/__init__.py @@ -0,0 +1,4 @@ +from . import mcp_server +from . import mcp_tool +from . import mcp_resource +from . import mcp_prompt diff --git a/mcp_connector/models/mcp_prompt.py b/mcp_connector/models/mcp_prompt.py new file mode 100644 index 0000000..0148319 --- /dev/null +++ b/mcp_connector/models/mcp_prompt.py @@ -0,0 +1,171 @@ +# Copyright 2025 Escodoo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + +_logger = logging.getLogger(__name__) + + +class McpPrompt(models.Model): + _name = "mcp.prompt" + _description = "MCP Prompt" + _order = "server_id, name" + + name = fields.Char(string="Prompt Name", required=True, index=True) + server_id = fields.Many2one( + "mcp.server", string="Server", required=True, ondelete="cascade", index=True + ) + server_state = fields.Selection( + related="server_id.state", string="Server Status", readonly=True + ) + description = fields.Text() + arguments = fields.Text( + help="JSON array of prompt arguments", + default="[]", + ) + + _sql_constraints = [ + ( + "server_name_uniq", + "unique(server_id, name)", + "Prompt name must be unique per server!", + ) + ] + + @api.constrains("arguments") + def _check_arguments(self): + for record in self: + if record.arguments: + try: + args_json = json.loads(record.arguments) + if not isinstance(args_json, list): + raise ValidationError(_("Arguments must be a JSON array")) + except json.JSONDecodeError: + raise ValidationError(_("Arguments must be valid JSON")) from None + + def action_get_prompt(self): + """Open a wizard to get the prompt with parameters.""" + self.ensure_one() + return { + "name": _("Get Prompt: %s") % self.name, + "type": "ir.actions.act_window", + "res_model": "mcp.prompt.get.wizard", + "view_mode": "form", + "target": "new", + "context": { + "default_prompt_id": self.id, + "default_server_id": self.server_id.id, + }, + } + + def get_prompt(self, arguments=None): + """Get the prompt with the provided arguments. + + Args: + arguments (dict, optional): The arguments to pass to the prompt + + Returns: + dict: The prompt result + """ + self.ensure_one() + + if self.server_id.state != "running": + raise UserError(_("Server is not running")) + + # Use the server's async methods to communicate with the MCP server + try: + result = self.server_id._run_async_in_thread( + self._async_get_prompt(arguments or {}) + ) + return result + except Exception as e: + _logger.exception( + "Error getting prompt %(prompt_name)s: %(error)s", + {"prompt_name": self.name, "error": str(e)}, + ) + raise UserError( + _("Error getting prompt: %(error)s") % {"error": str(e)} + ) from None + + async def _async_get_prompt(self, arguments): + """Get a prompt from the MCP server using async API. + + Args: + arguments: The arguments to pass to the prompt + + Returns: + The prompt result + """ + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + + # Parse command and arguments from the server + command = self.server_id.command + args = json.loads(self.server_id.args) if self.server_id.args else [] + env_vars = ( + json.loads(self.server_id.env_vars) if self.server_id.env_vars else None + ) + + # Log the command that will be executed + _logger.info( + "Getting prompt %s from MCP server with command: %s %s", + self.name, + command, + " ".join(args), + ) + + # Create server parameters + server_params = StdioServerParameters(command=command, args=args, env=env_vars) + + result = None + + # Connect to the server via stdio + async with stdio_client(server_params) as (read, write): + # Log successful connection + _logger.info("Successfully established stdio connection to MCP server") + + async with ClientSession(read, write) as session: + # Initialize the connection + await session.initialize() + _logger.info("Successfully initialized MCP session") + + # Get the prompt + _logger.info( + "Getting prompt %s with arguments: %s", self.name, arguments + ) + response = await session.get_prompt(self.name, arguments) + + # Process the response + result = {"description": response.description, "messages": []} + + # Extract messages from the response + for message in response.messages: + message_data = {"role": message.role, "content": []} + + # Process content based on type + if hasattr(message, "content") and message.content: + for content_item in message.content: + if hasattr(content_item, "text"): + message_data["content"].append( + {"type": "text", "text": content_item.text} + ) + elif hasattr(content_item, "data"): + message_data["content"].append( + { + "type": content_item.type, + "data": content_item.data, + "mimeType": getattr( + content_item, "mimeType", None + ), + } + ) + + result["messages"].append(message_data) + + _logger.info("Successfully got prompt %s", self.name) + + return result diff --git a/mcp_connector/models/mcp_resource.py b/mcp_connector/models/mcp_resource.py new file mode 100644 index 0000000..2257deb --- /dev/null +++ b/mcp_connector/models/mcp_resource.py @@ -0,0 +1,251 @@ +# Copyright 2025 Escodoo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +import logging + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + +_logger = logging.getLogger(__name__) + + +class McpResource(models.Model): + _name = "mcp.resource" + _description = "MCP Resource" + _order = "server_id, name" + + name = fields.Char(string="Resource Name", required=True, index=True) + server_id = fields.Many2one( + "mcp.server", string="Server", required=True, ondelete="cascade", index=True + ) + server_state = fields.Selection( + related="server_id.state", string="Server Status", readonly=True + ) + uri = fields.Char( + string="URI", + required=True, + index=True, + help="Uniform Resource Identifier for the resource", + ) + mime_type = fields.Char( + string="MIME Type", + required=True, + help="MIME type of the resource (e.g., application/json)", + ) + description = fields.Text() + is_template = fields.Boolean( + default=False, + help="Whether this is a resource template with URI parameters", + ) + uri_template = fields.Char( + string="URI Template", + help="URI template with parameters (e.g., weather://{city}/current)", + ) + template_parameters = fields.Text( + help="JSON array of template parameter definitions", + default="[]", + ) + + _sql_constraints = [ + ( + "server_uri_uniq", + "unique(server_id, uri)", + "Resource URI must be unique per server!", + ) + ] + + @api.constrains("template_parameters") + def _check_template_parameters(self): + for record in self: + if record.template_parameters: + try: + params_json = json.loads(record.template_parameters) + if not isinstance(params_json, list): + raise ValidationError( + _("Template parameters must be a JSON array") + ) + except json.JSONDecodeError: + raise ValidationError( + _("Template parameters must be valid JSON") + ) from None + + def action_read_resource(self): + """Open a wizard to read the resource.""" + self.ensure_one() + + if self.is_template: + return { + "name": _("Read Resource Template: %s") % self.name, + "type": "ir.actions.act_window", + "res_model": "mcp.resource.read.wizard", + "view_mode": "form", + "target": "new", + "context": { + "default_resource_id": self.id, + "default_server_id": self.server_id.id, + "default_uri_template": self.uri_template or self.uri, + "default_is_template": True, + }, + } + else: + return { + "name": _("Read Resource: %s") % self.name, + "type": "ir.actions.act_window", + "res_model": "mcp.resource.read.wizard", + "view_mode": "form", + "target": "new", + "context": { + "default_resource_id": self.id, + "default_server_id": self.server_id.id, + "default_uri": self.uri, + "default_is_template": False, + }, + } + + async def _async_read_resource(self, uri): + """Read a resource from the MCP server using async API. + + Args: + uri: The URI of the resource to read + + Returns: + The resource contents + """ + + # Parse command and arguments from the server + command = self.server_id.command + args = json.loads(self.server_id.args) if self.server_id.args else [] + env_vars = ( + json.loads(self.server_id.env_vars) if self.server_id.env_vars else None + ) + + # Log the command that will be executed + _logger.info( + "Reading resource %s from MCP server with command: %s %s", + uri, + command, + " ".join(args), + ) + + # Create server parameters + + server_params = StdioServerParameters(command=command, args=args, env=env_vars) + + result = None + + # Connect to the server via stdio + async with stdio_client(server_params) as (read, write): + # Log successful connection + _logger.info("Successfully established stdio connection to MCP server") + + async with ClientSession(read, write) as session: + # Initialize the connection + await session.initialize() + _logger.info("Successfully initialized MCP session") + + # Read the resource + _logger.info("Reading resource with URI: %s", uri) + response = await session.read_resource(uri) + + # Process the response + result = {"contents": []} + + # Extract content from the response + for content_item in response.contents: + if hasattr(content_item, "text"): + result["contents"].append( + { + "uri": content_item.uri, + "mimeType": content_item.mimeType, + "text": content_item.text, + } + ) + else: + # Handle binary content if needed + result["contents"].append( + { + "uri": content_item.uri, + "mimeType": content_item.mimeType, + "data": content_item.data, + } + ) + + _logger.info("Successfully read resource %s", uri) + + return result + + def read_resource(self, uri=None, params=None): + """Read the resource from the MCP server. + + Args: + uri (str, optional): The URI to read. If not provided, the resource's URI will + be used. + params (dict, optional): Parameters to substitute in the URI template. + + Returns: + dict: The resource contents + """ + self.ensure_one() + + if self.server_id.state != "running": + raise UserError(_("Server is not running")) + + # Determine the URI to use + if uri is None: + if self.is_template: + if not params: + raise UserError(_("Parameters are required for resource templates")) + + # Implement URI template substitution + uri = self.uri_template + for key, value in params.items(): + uri = uri.replace(f"{{{key}}}", value) + else: + uri = self.uri + + try: + _logger.info("Reading resource %s from server %s", uri, self.server_id.name) + + # Check if MCP SDK is available + if not hasattr(self.server_id, "_run_async_in_thread"): + raise UserError( + _( + "MCP SDK is not available. Please install it with: pip install mcp" + ) + ) + + # Use the server's async methods to communicate with the MCP server + result = self.server_id._run_async_in_thread(self._async_read_resource(uri)) + + return result + except Exception as e: + _logger.exception( + "Error reading resource %s from server %s: %s", + uri, + self.server_id.name, + str(e), + ) + # Provide a more user-friendly error message + error_message = str(e) + if "EAI_AGAIN" in error_message or "getaddrinfo" in error_message: + raise UserError( + _( + "Network connectivity issue: Unable to connect to MCP server. \ + Please check your internet connection and proxy settings." + ) + ) from None + elif "Method not found" in error_message: + raise UserError( + _( + "The MCP server does not support the read_resource method. \ + Please update the server implementation." + ) + ) from None + else: + raise UserError( + _("Failed to read resource: %s") % error_message + ) from None diff --git a/mcp_connector/models/mcp_server.py b/mcp_connector/models/mcp_server.py new file mode 100644 index 0000000..fe48feb --- /dev/null +++ b/mcp_connector/models/mcp_server.py @@ -0,0 +1,928 @@ +# Copyright 2025 Escodoo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import asyncio +import json +import logging +import subprocess +import threading + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + +_logger = logging.getLogger(__name__) + + +class McpServer(models.Model): + _name = "mcp.server" + _description = "MCP Server" + _order = "name" + + name = fields.Char(string="Server Name", required=True, index=True) + description = fields.Text() + command = fields.Char( + required=True, + help="Command to run the MCP server (e.g., node, python)", + ) + args = fields.Text( + string="Arguments", + required=True, + help='Command arguments as a JSON array (e.g., ["/path/to/server.js"])', + ) + env_vars = fields.Text( + string="Environment Variables", + help='Environment variables as a JSON object (e.g., {"API_KEY": "abc123"})', + ) + active = fields.Boolean(default=True) + enabled = fields.Boolean( + default=False, + help="Whether the server is enabled and will be started automatically", + ) + auto_approve = fields.Text( + help="List of tools that can be auto-approved as a JSON array " + '(e.g., ["get_weather"])', + ) + capabilities = fields.Text( + help="Server capabilities as a JSON object", + readonly=True, + ) + state = fields.Selection( + [ + ("stopped", "Stopped"), + ("running", "Running"), + ("error", "Error"), + ], + string="Status", + default="stopped", + readonly=True, + ) + error_message = fields.Text(readonly=True) + last_start_time = fields.Datetime(readonly=True) + + # Related fields + tool_count = fields.Integer(compute="_compute_tool_count") + resource_count = fields.Integer(compute="_compute_resource_count") + prompt_count = fields.Integer(compute="_compute_prompt_count") + + _sql_constraints = [("name_uniq", "unique(name)", "Server name must be unique!")] + + @api.constrains("args", "env_vars", "auto_approve") + def _check_json_fields(self): + for record in self: + # Validate args is a valid JSON array + if record.args: + try: + args_json = json.loads(record.args) + if not isinstance(args_json, list): + raise ValidationError(_("Arguments must be a JSON array")) + except json.JSONDecodeError: + raise ValidationError(_("Arguments must be valid JSON")) from None + + # Validate env_vars is a valid JSON object + if record.env_vars: + try: + env_json = json.loads(record.env_vars) + if not isinstance(env_json, dict): + raise ValidationError( + _("Environment variables must be a JSON object") + ) + except json.JSONDecodeError: + raise ValidationError( + _("Environment variables must be valid JSON") + ) from None + + # Validate auto_approve is a valid JSON array + if record.auto_approve: + try: + auto_approve_json = json.loads(record.auto_approve) + if not isinstance(auto_approve_json, list): + raise ValidationError(_("Auto approve must be a JSON array")) + except json.JSONDecodeError: + raise ValidationError( + _("Auto approve must be valid JSON") + ) from None + + def _compute_tool_count(self): + for record in self: + record.tool_count = self.env["mcp.tool"].search_count( + [("server_id", "=", record.id)] + ) + + def _compute_resource_count(self): + for record in self: + record.resource_count = self.env["mcp.resource"].search_count( + [("server_id", "=", record.id)] + ) + + def _compute_prompt_count(self): + for record in self: + record.prompt_count = self.env["mcp.prompt"].search_count( + [("server_id", "=", record.id)] + ) + + def action_start(self): + self.ensure_one() + if self.state == "running": + raise UserError(_("Server is already running")) + + try: + # Parse command arguments and environment variables + args_list = json.loads(self.args) if self.args else [] + env_dict = json.loads(self.env_vars) if self.env_vars else {} + + # Start server in a separate thread + thread = threading.Thread( + target=self._run_server, args=(self.command, args_list, env_dict) + ) + thread.daemon = True + thread.start() + + # Update server state + self.write( + { + "state": "running", + "error_message": False, + "last_start_time": fields.Datetime.now(), + } + ) + + # Refresh tools and resources + try: + self._refresh_tools_and_resources() + + # Only show success notification if refresh completed without errors + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Server Started"), + "message": _('MCP server "%s" started successfully') + % self.name, + "sticky": False, + "type": "success", + }, + } + + except Exception as refresh_error: + _logger.exception( + "Error refreshing tools and resources for server %s: %s", + self.name, + str(refresh_error), + ) + # Update server state with the specific error + self.write( + { + "state": "error", + "error_message": str(refresh_error), + } + ) + + # Show error notification instead of success + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Server Error"), + "message": _('MCP server "%(name)s" failed to start: %(error)s') + % {"name": self.name, "error": str(refresh_error)}, + "sticky": True, + "type": "danger", + }, + } + except Exception as e: + self.write({"state": "error", "error_message": str(e)}) + raise UserError(_("Failed to start server: %s") % str(e)) from None + + def action_stop(self): + self.ensure_one() + if self.state != "running": + raise UserError(_("Server is not running")) + + # TODO: Implement proper server stopping mechanism + # For now, just update the state + self.write({"state": "stopped", "error_message": False}) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Server Stopped"), + "message": _('MCP server "%(name)s" stopped successfully') + % {"name": self.name}, + "sticky": False, + "type": "success", + }, + } + + def action_view_tools(self): + self.ensure_one() + return { + "name": _("Tools"), + "type": "ir.actions.act_window", + "res_model": "mcp.tool", + "view_mode": "tree,form", + "domain": [("server_id", "=", self.id)], + "context": {"default_server_id": self.id}, + } + + def action_view_resources(self): + self.ensure_one() + return { + "name": _("Resources"), + "type": "ir.actions.act_window", + "res_model": "mcp.resource", + "view_mode": "tree,form", + "domain": [("server_id", "=", self.id)], + "context": {"default_server_id": self.id}, + } + + def action_view_prompts(self): + self.ensure_one() + return { + "name": _("Prompts"), + "type": "ir.actions.act_window", + "res_model": "mcp.prompt", + "view_mode": "tree,form", + "domain": [("server_id", "=", self.id)], + "context": {"default_server_id": self.id}, + } + + def action_call_tool(self, tool_name, arguments): + """Call an MCP tool with the provided arguments. + + Args: + tool_name: The name of the tool to call + arguments: A dictionary of arguments to pass to the tool + + Returns: + The result of the tool call + """ + self.ensure_one() + + if self.state != "running": + raise UserError(_("Cannot call tool: server %s is not running") % self.name) + + # Validate that the tool exists + tool = self.env["mcp.tool"].search( + [("server_id", "=", self.id), ("name", "=", tool_name)], limit=1 + ) + + if not tool: + raise UserError( + _("Tool %(tool_name)s not found on server %(server_name)s") + % {"tool_name": tool_name, "server_name": self.name} + ) + + try: + # Run the async function in a synchronous context + result = self._run_async_in_thread( + self._async_call_tool(tool_name, arguments) + ) + return result + except Exception as e: + _logger.exception( + "Error calling tool %s on server %s: %s", tool_name, self.name, str(e) + ) + # Provide a more user-friendly error message + error_message = str(e) + if "EAI_AGAIN" in error_message or "getaddrinfo" in error_message: + raise UserError( + _( + "Network connectivity issue: Unable to connect to npm " + "registry. \ + Please check your internet connection and proxy settings." + ) + ) from None + elif "npm ERR!" in error_message: + raise UserError( + _("NPM error: %s") % error_message.split("npm ERR!")[1].strip() + ) from None + else: + raise UserError(_("Failed to call tool: %s") % error_message) from None + + def _run_server(self, command, args_list, env_dict): + """Run the MCP server process.""" + try: + # Prepare environment variables + env = dict(subprocess.os.environ) + env.update(env_dict) + + # Start the server process + process = subprocess.Popen( + [command] + args_list, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + # TODO: Implement proper process management and communication + # For now, just log the output + for line in process.stdout: + _logger.info("MCP Server %s: %s", self.name, line.strip()) + + for line in process.stderr: + _logger.error("MCP Server %s Error: %s", self.name, line.strip()) + + # Wait for process to complete + process.wait() + + # Update server state based on process exit code + if process.returncode != 0: + self.write( + { + "state": "error", + "error_message": f"Server exited with code " + f"{process.returncode}", + } + ) + else: + self.write({"state": "stopped"}) + + except Exception as e: + _logger.exception("Error running MCP server %s: %s", self.name, str(e)) + self.write({"state": "error", "error_message": str(e)}) + + def _refresh_tools(self): + """Refresh tools from the MCP server.""" + try: + tools = self._get_tools_from_server() + if tools: + # Remove existing tools for this server + self.env["mcp.tool"].search([("server_id", "=", self.id)]).unlink() + + # Create new tool records + for tool in tools: + self.env["mcp.tool"].create( + { + "server_id": self.id, + "name": tool.get("name", ""), + "description": tool.get("description", ""), + "input_schema": json.dumps(tool.get("inputSchema", {})), + "output_schema": ( + json.dumps(tool.get("outputSchema", {})) + if tool.get("outputSchema") + else None + ), + } + ) + _logger.info("Updated %d tools for server %s", len(tools), self.name) + except Exception as tool_error: + _logger.exception( + "Error getting tools from server %s: %s", self.name, str(tool_error) + ) + if self._handle_network_error(tool_error): + return + raise tool_error from None + + def _refresh_resources(self): + """Refresh resources from the MCP server.""" + try: + resources = self._get_resources_from_server() + if resources: + # Remove existing resources for this server + self.env["mcp.resource"].search([("server_id", "=", self.id)]).unlink() + + # Create new resource records + for resource in resources: + self.env["mcp.resource"].create( + { + "server_id": self.id, + "uri": resource.get("uri", ""), + "name": resource.get("name", ""), + "mime_type": resource.get( + "mimeType", "" + ), # Keep snake_case for database field + "description": resource.get("description", ""), + } + ) + _logger.info( + "Updated %d resources for server %s", len(resources), self.name + ) + except Exception as resource_error: + _logger.exception( + "Error getting resources from server %s: %s", + self.name, + str(resource_error), + ) + if self._handle_network_error(resource_error): + return + raise resource_error from None + + def _refresh_prompts(self): + """Refresh prompts from the MCP server.""" + try: + prompts = self._get_prompts_from_server() + if prompts: + # Remove existing prompts for this server + self.env["mcp.prompt"].search([("server_id", "=", self.id)]).unlink() + + # Create new prompt records + for prompt in prompts: + self.env["mcp.prompt"].create( + { + "server_id": self.id, + "name": prompt.get("name", ""), + "description": prompt.get("description", ""), + "arguments": json.dumps(prompt.get("arguments", [])), + } + ) + _logger.info( + "Updated %d prompts for server %s", len(prompts), self.name + ) + except Exception as prompt_error: + _logger.exception( + "Error getting prompts from server %s: %s", + self.name, + str(prompt_error), + ) + if self._handle_network_error(prompt_error): + return + raise prompt_error from None + + def _handle_network_error(self, error): + """Handle network-related errors.""" + if "EAI_AGAIN" in str(error) or "getaddrinfo" in str(error): + self.write( + { + "state": "error", + "error_message": _( + "Network connectivity issue: Unable to connect to MCP server. \ + Please check your internet connection and proxy settings." + ), + } + ) + return True + return False + + def _handle_refresh_error(self, error): + """Handle general refresh errors.""" + error_message = str(error) + if "EAI_AGAIN" in error_message or "getaddrinfo" in error_message: + error_message = _( + "Network connectivity issue: Unable to connect to MCP server. \ + Please check your internet connection and proxy settings." + ) + elif "npm ERR!" in error_message: + error_message = ( + _("NPM error: %s") % error_message.split("npm ERR!")[1].strip() + ) + else: + error_message = ( + _("Failed to refresh tools and resources: %s") % error_message + ) + + self.write({"state": "error", "error_message": error_message}) + # Don't raise UserError here, let the calling method handle it + return error_message + + async def _async_call_tool(self, tool_name, arguments): + """Call an MCP tool with the provided arguments using async API. + + Args: + tool_name: The name of the tool to call + arguments: A dictionary of arguments to pass to the tool + + Returns: + The result of the tool call + """ + + # Parse command and arguments + command = self.command + args = json.loads(self.args) if self.args else [] + env_vars = json.loads(self.env_vars) if self.env_vars else None + + # Log the command that will be executed + _logger.info( + "Calling tool %s on MCP server with command: %s %s", + tool_name, + command, + " ".join(args), + ) + + # Create server parameters + server_params = StdioServerParameters(command=command, args=args, env=env_vars) + + result = None + + try: + # Connect to the server via stdio + async with stdio_client(server_params) as (read, write): + # Log successful connection + _logger.info("Successfully established stdio connection to MCP server") + + try: + async with ClientSession(read, write) as session: + # Initialize the connection + await session.initialize() + _logger.info("Successfully initialized MCP session") + + # Call the tool + _logger.info( + "Calling tool %s with arguments: %s", tool_name, arguments + ) + response = await session.call_tool(tool_name, arguments) + + # Process the response + result = {"content": [], "is_error": response.isError} + + # Extract content from the response + for content_item in response.content: + if content_item.type == "text": + result["content"].append( + {"type": "text", "text": content_item.text} + ) + elif content_item.type == "image": + result["content"].append( + { + "type": "image", + "data": content_item.data, + "mime_type": content_item.mime_type, + } + ) + + _logger.info("Successfully called tool %s", tool_name) + + except Exception as e: + _logger.exception("Error during MCP session: %s", str(e)) + # Capture stderr output if available + if hasattr(read, "stderr") and read.stderr: + stderr_content = await read.stderr.read() + if stderr_content: + _logger.error( + "MCP server stderr: %s", + stderr_content.decode("utf-8", errors="replace"), + ) + raise + except Exception as e: + _logger.exception("Error connecting to MCP server: %s", str(e)) + # Check if it's a network-related error + if "EAI_AGAIN" in str(e) or "getaddrinfo" in str(e): + raise UserError( + _( + "Network error connecting to MCP server. Please check your \ + internet connection and proxy settings." + ) + ) from None + raise + + return result + + def _test_mcp_command(self, command, args, env_vars): + """Test MCP command and capture stderr for better error reporting.""" + stderr_message = "" + try: + import shutil + + # Find the correct command path + command_path = shutil.which(command) + if not command_path: + _logger.warning("Command not found: %(command)s", {"command": command}) + return stderr_message + + # Test the command to capture stderr + test_process = subprocess.Popen( + [command_path] + args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env_vars, + text=True, + ) + + # Wait a short time to see if there are immediate errors + try: + stdout, stderr = test_process.communicate(timeout=3) + if stderr: + stderr_message = stderr.strip() + _logger.error( + "MCP server stderr during test: %(stderr)s", + {"stderr": stderr_message}, + ) + except subprocess.TimeoutExpired: + # If it doesn't timeout, that's good - kill the test process + test_process.kill() + test_process.wait() + except Exception as test_error: + _logger.warning( + "Could not test MCP server command: %(error)s", + {"error": str(test_error)}, + ) + return stderr_message + + def _is_configuration_error(self, error_message): + """Check if error is related to configuration (missing env vars, + API keys, etc.).""" + return any( + keyword in error_message.lower() + for keyword in [ + "environment variable", + "api key", + "token", + "not set", + "missing", + ] + ) + + async def _get_tools_from_session(self, session): + """Get tools from MCP session.""" + _logger.info("Requesting tools from MCP server") + tools_response = await session.list_tools() + tools = [ + { + "name": tool.name, + "description": tool.description, + "inputSchema": tool.inputSchema, + "outputSchema": getattr(tool, "outputSchema", None), + } + for tool in tools_response.tools + ] + _logger.info("Received %(count)d tools from MCP server", {"count": len(tools)}) + return tools + + async def _get_resources_from_session(self, session): + """Get resources from MCP session.""" + _logger.info("Requesting resources from MCP server") + try: + resources_response = await session.list_resources() + resources = [ + { + "uri": resource.uri, + "name": resource.name, + "mimeType": resource.mimeType, + "description": resource.description, + } + for resource in resources_response.resources + ] + _logger.info( + "Received %(count)d resources from MCP server", + {"count": len(resources)}, + ) + return resources + except Exception as resource_error: + # Check if it's a "Method not found" error + if "Method not found" in str(resource_error): + _logger.warning( + "The MCP server does not support the list_resources " + "method. Skipping resources." + ) + return [] # Return empty list of resources + else: + # Re-raise other errors + raise + + async def _get_prompts_from_session(self, session): + """Get prompts from MCP session.""" + _logger.info("Requesting prompts from MCP server") + try: + prompts_response = await session.list_prompts() + prompts = [ + { + "name": prompt.name, + "description": prompt.description, + "arguments": prompt.arguments, + } + for prompt in prompts_response.prompts + ] + _logger.info( + "Received %(count)d prompts from MCP server", + {"count": len(prompts)}, + ) + return prompts + except Exception as prompt_error: + # Check if it's a "Method not found" error + if "Method not found" in str(prompt_error): + _logger.warning( + "The MCP server does not support the list_prompts " + "method. Skipping prompts." + ) + return [] # Return empty list of prompts + else: + # Re-raise other errors + raise + + async def _async_connect_and_query_mcp(self): + """Connect to the MCP server and query for tools and resources + using async API.""" + + # Parse command and arguments + command = self.command + args = json.loads(self.args) if self.args else [] + env_vars = json.loads(self.env_vars) if self.env_vars else None + + # Log the command that will be executed + _logger.info( + "Connecting to MCP server with command: %(command)s %(args)s", + {"command": command, "args": " ".join(args)}, + ) + + # Test the command and capture stderr for better error reporting + stderr_message = self._test_mcp_command(command, args, env_vars) + + # Create server parameters + server_params = StdioServerParameters(command=command, args=args, env=env_vars) + + tools = [] + resources = [] + + try: + # Connect to the server via stdio + async with stdio_client(server_params) as (read, write): + # Log successful connection + _logger.info("Successfully established stdio connection to MCP server") + + try: + async with ClientSession(read, write) as session: + # Initialize the connection + init_result = await session.initialize() + _logger.info("Successfully initialized MCP session") + + # Store server capabilities + if hasattr(init_result, "serverInfo") and hasattr( + init_result.serverInfo, "capabilities" + ): + capabilities = init_result.serverInfo.capabilities + self.write({"capabilities": json.dumps(capabilities)}) + + # Get tools, resources, and prompts + tools = await self._get_tools_from_session(session) + resources = await self._get_resources_from_session(session) + prompts = await self._get_prompts_from_session(session) + except Exception as e: + _logger.exception( + "Error during MCP session: %(error)s", {"error": str(e)} + ) + + # Use the stderr message captured during the test if available + final_message = stderr_message if stderr_message else str(e) + is_config_error = self._is_configuration_error(final_message) + + # Update server state to error + self.write({"state": "error", "error_message": final_message}) + + # Provide more specific error message based on error type + if is_config_error: + raise UserError( + _( + "MCP Server %(name)s Configuration Error: %(message)s\n" + "Please check your server configuration and required " + "environment variables." + ) + % {"name": self.name, "message": final_message} + ) from None + else: + raise UserError( + _("MCP Server %(name)s Error: %(message)s") + % {"name": self.name, "message": final_message} + ) from None + except Exception as e: + _logger.exception( + "Error connecting to MCP server: %(error)s", {"error": str(e)} + ) + # Check if it's a network-related error + if "EAI_AGAIN" in str(e) or "getaddrinfo" in str(e): + raise UserError( + _( + "Network error connecting to MCP server. Please check your \ + internet connection and proxy settings." + ) + ) from None + + # Check if it's a UserError (already formatted with stderr) + if isinstance(e, UserError): + raise + + # For other errors, try to provide more context + error_message = str(e) + is_config_error = self._is_configuration_error(error_message) + + # Update server state to error + self.write({"state": "error", "error_message": error_message}) + + if "Connection closed" in error_message: + if is_config_error: + raise UserError( + _( + "MCP Server %(name)s Configuration Error: Server closed " + "connection. Please check server configuration and required " + "environment variables." + ) + % {"name": self.name} + ) from None + else: + raise UserError( + _( + "MCP Server %(name)s Error: Server closed connection " + "unexpectedly. Please check server configuration and required " + "environment variables." + ) + % {"name": self.name} + ) from None + + if is_config_error: + raise UserError( + _( + "MCP Server %(name)s Configuration Error: %(error)s\n" + "Please check your server configuration and required " + "environment variables." + ) + % {"name": self.name, "error": str(e)} + ) from None + + raise UserError( + _("Error connecting to MCP server: %(error)s") % {"error": str(e)} + ) from None + + return tools, resources, prompts + + def _run_async_in_thread(self, coro): + """Run an async coroutine in a new event loop in the current thread.""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + except Exception as e: + # Re-raise UserError as-is (already formatted) + if isinstance(e, UserError): + raise + # For other errors, wrap them + raise UserError( + _("MCP Server %(name)s Error: %(error)s") + % {"name": self.name, "error": str(e)} + ) from None + finally: + loop.close() + + def _get_tools_from_server(self): + """Get the list of tools from the MCP server using the MCP SDK.""" + _logger.info("Getting tools from server %s", self.name) + + try: + # Run the async function in a synchronous context + tools, _, _ = self._run_async_in_thread(self._async_connect_and_query_mcp()) + return tools + except Exception as e: + _logger.exception( + "Error getting tools from server %s: %s", self.name, str(e) + ) + return [] + + def _get_resources_from_server(self): + """Get the list of resources from the MCP server using the MCP SDK.""" + _logger.info("Getting resources from server %s", self.name) + + try: + # Run the async function in a synchronous context + _, resources, _ = self._run_async_in_thread( + self._async_connect_and_query_mcp() + ) + return resources + except Exception as e: + _logger.exception( + "Error getting resources from server %s: %s", self.name, str(e) + ) + return [] + + def _get_prompts_from_server(self): + """Get the list of prompts from the MCP server using the MCP SDK.""" + _logger.info("Getting prompts from server %s", self.name) + + try: + # Run the async function in a synchronous context + _, _, prompts = self._run_async_in_thread( + self._async_connect_and_query_mcp() + ) + return prompts + except Exception as e: + _logger.exception( + "Error getting prompts from server %s: %s", self.name, str(e) + ) + return [] + + def _refresh_tools_and_resources(self): + """Refresh tools, resources, and prompts from the MCP server.""" + _logger.info( + "Refreshing tools, resources, and prompts for server %s", self.name + ) + + if self.state != "running": + _logger.warning( + "Cannot refresh tools and resources: server %s is not running", + self.name, + ) + return + + try: + self._refresh_tools() + self._refresh_resources() + self._refresh_prompts() + except Exception as e: + _logger.exception( + "Error refreshing tools and resources for server %s: %s", + self.name, + str(e), + ) + error_message = self._handle_refresh_error(e) + # Re-raise the error so it can be caught by action_start + raise UserError(error_message) from None diff --git a/mcp_connector/models/mcp_tool.py b/mcp_connector/models/mcp_tool.py new file mode 100644 index 0000000..6b89e8d --- /dev/null +++ b/mcp_connector/models/mcp_tool.py @@ -0,0 +1,118 @@ +# Copyright 2025 Escodoo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + +_logger = logging.getLogger(__name__) + + +class McpTool(models.Model): + _name = "mcp.tool" + _description = "MCP Tool" + _order = "server_id, name" + + name = fields.Char(string="Tool Name", required=True, index=True) + server_id = fields.Many2one( + "mcp.server", string="Server", required=True, ondelete="cascade", index=True + ) + server_state = fields.Selection( + related="server_id.state", string="Server Status", readonly=True + ) + description = fields.Text() + input_schema = fields.Text(help="JSON Schema for the tool parameters") + output_schema = fields.Text(help="JSON Schema for the tool output") + is_auto_approved = fields.Boolean( + string="Auto Approved", compute="_compute_is_auto_approved", store=True + ) + + _sql_constraints = [ + ( + "server_name_uniq", + "unique(server_id, name)", + "Tool name must be unique per server!", + ) + ] + + @api.constrains("input_schema", "output_schema") + def _check_schemas(self): + for record in self: + # Validate input schema + if record.input_schema: + try: + schema_json = json.loads(record.input_schema) + if not isinstance(schema_json, dict): + raise ValidationError(_("Input schema must be a JSON object")) + except json.JSONDecodeError: + raise ValidationError( + _("Input schema must be valid JSON") + ) from None + + # Validate output schema + if record.output_schema: + try: + schema_json = json.loads(record.output_schema) + if not isinstance(schema_json, dict): + raise ValidationError(_("Output schema must be a JSON object")) + except json.JSONDecodeError: + raise ValidationError( + _("Output schema must be valid JSON") + ) from None + + @api.depends("server_id", "name") + def _compute_is_auto_approved(self): + for record in self: + auto_approve = record.server_id.auto_approve + if auto_approve: + try: + auto_approve_list = json.loads(auto_approve) + record.is_auto_approved = record.name in auto_approve_list + except (json.JSONDecodeError, TypeError): + record.is_auto_approved = False + else: + record.is_auto_approved = False + + def action_call_tool(self): + """Open a wizard to call the tool with parameters.""" + self.ensure_one() + return { + "name": _("Call Tool: %s") % self.name, + "type": "ir.actions.act_window", + "res_model": "mcp.tool.call.wizard", + "view_mode": "form", + "target": "new", + "context": { + "default_tool_id": self.id, + "default_server_id": self.server_id.id, + }, + } + + def call_tool(self, arguments): + """Call the tool with the provided arguments. + + Args: + arguments (dict): The arguments to pass to the tool + + Returns: + dict: The result of the tool call + """ + self.ensure_one() + + if self.server_id.state != "running": + raise UserError(_("Server is not running")) + + # Use the action_call_tool method in the McpServer model + try: + result = self.server_id.action_call_tool(self.name, arguments) + return result + except Exception as e: + _logger.exception( + "Error calling tool %(tool_name)s: %(error)s", + {"tool_name": self.name, "error": str(e)}, + ) + raise UserError( + _("Error calling tool: %(error)s") % {"error": str(e)} + ) from None diff --git a/mcp_connector/readme/CONFIGURE.md b/mcp_connector/readme/CONFIGURE.md new file mode 100644 index 0000000..2f20d9a --- /dev/null +++ b/mcp_connector/readme/CONFIGURE.md @@ -0,0 +1,161 @@ +## Prerequisites + +Before using the MCP Connector module, ensure you have: + +1. **Python MCP SDK**: Install the required dependency: + ```bash + pip install mcp + ``` + +2. **MCP Server**: Have access to an MCP-compliant server or create your own. + +## Basic Configuration + +### 1. Install the Module + +1. Place the module in your Odoo addons directory +2. Restart your Odoo server +3. Update the apps list: `odoo-bin -u all` +4. Install the module from the Apps menu + +### 2. Configure MCP Server + +1. Navigate to **MCP Servers** +2. Click **Create** to add a new server +3. Fill in the required fields: + - **Server Name**: A descriptive name for your server + - **Command**: The executable to run (e.g., `node`, `python3`, `python`) + - **Arguments**: JSON array of command arguments + - **Environment Variables**: JSON object with environment settings + +### 3. Example Configuration + +**Command**: `npx` +**Arguments**: `["-y", "@modelcontextprotocol/server-everart"]` +**Environment Variables**: `{"EVERART_API_KEY": "your-api-key"}` + +## Advanced Configuration + +### Auto-Start Configuration + +Enable the **Auto-Start** option to automatically launch MCP servers when Odoo starts. This ensures the MCP server is always available. + +### Security Configuration + +- **Access Rights**: Configure role-based access control +- **Environment Variables**: Use secure environment variable management +- **Server Isolation**: Run servers in isolated environments when possible + +## Server Management + +### Starting and Stopping Servers + +1. **Manual Control**: Use the **Start** and **Stop** buttons in the server interface +2. **Auto-Start**: Enable auto-start for critical servers +3. **Status Monitoring**: Monitor server health through the interface + +### Troubleshooting + +1. **Check Logs**: Review server logs for error messages +2. **Verify Dependencies**: Ensure all required dependencies are installed +3. **Test Connection**: Use the test connection feature +4. **Check Permissions**: Verify file and network permissions + +## Performance Optimization + +### Resource Management + +1. **Memory Usage**: Monitor server memory consumption +2. **CPU Usage**: Track CPU utilization +3. **Connection Limits**: Set appropriate connection limits +4. **Timeout Settings**: Configure appropriate timeout values + +### Scaling + +1. **Multiple Servers**: Run multiple MCP servers for load distribution +2. **Load Balancing**: Implement load balancing strategies +3. **Caching**: Enable caching for frequently accessed resources +4. **Monitoring**: Set up comprehensive monitoring + +## Integration Examples + +### AI Image Generation (EverArt) + +```json +{ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-everart"], + "env_vars": { + "EVERART_API_KEY": "your-everart-api-key" + } +} +``` + +### SQLite Database Server + +```json +{ + "command": "uvx", + "args": ["mcp-server-sqlite", "--db-path", "./database.db"], + "env_vars": {} +} +``` + +### Puppeteer Web Automation + +```json +{ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-puppeteer"], + "env_vars": {} +} +``` + +### Memory Server + +```json +{ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "env_vars": {} +} +``` + +## Maintenance + +### Regular Updates + +1. **Module Updates**: Keep the module updated +2. **Dependency Updates**: Update MCP SDK and server dependencies +3. **Security Patches**: Apply security patches promptly +4. **Backup Configuration**: Backup server configurations + +### Monitoring and Alerts + +1. **Health Checks**: Implement regular health checks +2. **Performance Metrics**: Monitor key performance indicators +3. **Error Tracking**: Set up error tracking and alerting +4. **Log Analysis**: Regular analysis of server logs + +## Best Practices + +### Security + +1. **Environment Variables**: Never hardcode sensitive information +2. **Access Control**: Implement proper access control +3. **Network Security**: Use secure network configurations +4. **Regular Audits**: Conduct regular security audits + +### Performance + +1. **Resource Monitoring**: Monitor resource usage +2. **Optimization**: Regular performance optimization +3. **Caching**: Implement appropriate caching strategies +4. **Load Testing**: Regular load testing + +### Maintenance + +1. **Regular Updates**: Keep all components updated +2. **Backup Strategies**: Implement comprehensive backup strategies +3. **Documentation**: Maintain up-to-date documentation +4. **Testing**: Regular testing of configurations diff --git a/mcp_connector/readme/CONTRIBUTORS.md b/mcp_connector/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..425a762 --- /dev/null +++ b/mcp_connector/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +* Escodoo +* Marcel Savegnago diff --git a/mcp_connector/readme/DESCRIPTION.md b/mcp_connector/readme/DESCRIPTION.md new file mode 100644 index 0000000..aa2ef04 --- /dev/null +++ b/mcp_connector/readme/DESCRIPTION.md @@ -0,0 +1,94 @@ +## Overview + +The MCP Connector module provides seamless integration between Odoo and Model Context Protocol (MCP) servers. This powerful module enables businesses to connect, manage, and leverage external tools, resources, and AI capabilities directly within their Odoo environment. + +## What is Model Context Protocol (MCP)? + +Model Context Protocol (MCP) is a standardized protocol that enables AI applications to securely connect to data sources and tools. It provides a common interface for AI systems to access external resources, making it easier to integrate AI capabilities into existing applications. + +## Key Features + +### Server Management +- **Easy Configuration**: Set up MCP servers with simple forms and wizards +- **Command Execution**: Run servers with custom commands and arguments +- **Environment Variables**: Configure server settings through environment variables +- **Auto-Start**: Automatically start servers when Odoo starts +- **Status Monitoring**: Real-time server status and health monitoring + +### Tool Integration +- **Automatic Discovery**: Automatically discover tools exposed by MCP servers +- **Intuitive Interface**: User-friendly wizards for tool execution +- **Parameter Support**: Pass JSON parameters to tools +- **Response Handling**: Process and display tool responses +- **Error Management**: Comprehensive error handling and logging + +### Resource Handling +- **Resource Discovery**: Automatically discover available resources +- **Resource Reading**: Read resources with optional parameters +- **Template Support**: Support for templated resource outputs +- **Caching**: Intelligent caching for improved performance +- **Access Control**: Secure access to resources + +### Security +- **Access Rights**: Role-based access control +- **Secure Communication**: Encrypted communication with MCP servers +- **Audit Trail**: Complete audit trail of all operations +- **User Permissions**: Granular permission system + +## Technical Architecture + +### Threading Model +The module uses a threaded architecture to run MCP servers in the background, ensuring that Odoo remains responsive while MCP operations are being performed. + +### Protocol Compliance +The module fully implements the Model Context Protocol specification, ensuring compatibility with any MCP-compliant server. + +### Error Handling +Comprehensive error handling ensures that MCP server issues don't affect Odoo's stability. + +## Use Cases + +### AI Integration +- **Image Generation**: Connect to AI image generation services like EverArt +- **Text Processing**: Integrate with language models for text analysis +- **Translation**: Add multi-language support to Odoo +- **Content Creation**: Generate content using AI tools + +### Data Integration +- **Database Access**: Connect to SQLite databases via MCP servers +- **Web Automation**: Use Puppeteer for web scraping and automation +- **Memory Management**: Store and retrieve data using memory servers +- **Analytics**: Connect to business intelligence tools + +### Official MCP Servers +- **EverArt**: AI image generation and manipulation +- **SQLite**: Database operations and queries +- **Puppeteer**: Web automation and scraping +- **Memory**: Data storage and retrieval +- **Custom Servers**: Connect to any MCP-compliant server + +### Custom Tools +- **Internal APIs**: Connect to internal company APIs +- **Legacy Systems**: Integrate with legacy systems +- **Third-party Services**: Connect to external services +- **Custom Workflows**: Create custom business workflows + +## Benefits + +### For Developers +- **Easy Integration**: Simple API for connecting to MCP servers +- **Extensible**: Easy to add new MCP server types +- **Well Documented**: Comprehensive documentation and examples +- **Open Source**: Full source code available for customization + +### For Business Users +- **User-Friendly**: Intuitive interface for non-technical users +- **Powerful**: Access to advanced AI and external tools +- **Reliable**: Robust error handling and monitoring +- **Scalable**: Supports multiple servers and concurrent operations + +### For Organizations +- **Cost-Effective**: Open source solution with no licensing fees +- **Flexible**: Adapt to any MCP-compliant server +- **Secure**: Built-in security and access controls +- **Maintainable**: Well-structured code and documentation diff --git a/mcp_connector/readme/USAGE.md b/mcp_connector/readme/USAGE.md new file mode 100644 index 0000000..74a90c0 --- /dev/null +++ b/mcp_connector/readme/USAGE.md @@ -0,0 +1,197 @@ +## Getting Started + +### 1. First Steps + +After installing the MCP Connector module: + +1. Navigate to **MCP** in the main menu +2. You'll see three main sections: + - **Servers**: Manage MCP server configurations + - **Tools**: Interact with discovered tools + - **Resources**: Access server resources + +### 2. Setting Up Your First Server + +1. Go to **MCP Servers** +2. Click **Create** +3. Fill in the server details: + - **Name**: Give your server a descriptive name + - **Command**: The executable to run (e.g., `npx`, `uvx`) + - **Arguments**: JSON array of command arguments + - **Environment Variables**: JSON object with settings +4. Click **Save** +5. Click **Start Server** to launch it + +### 3. Discovering Tools and Resources + +Once your server is running: + +1. **Tools**: The module will automatically discover available tools +2. **Resources**: Available resources will be listed +3. **Status**: Monitor server status and health + +## Official MCP Servers + +The module supports official MCP servers from the [Model Context Protocol servers repository](https://github.com/modelcontextprotocol/servers). Here are some popular options: + +### EverArt AI Image Generation + +```json +{ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-everart"], + "env_vars": { + "EVERART_API_KEY": "your-api-key" + } +} +``` + +### SQLite Database Server + +```json +{ + "command": "uvx", + "args": ["mcp-server-sqlite", "--db-path", "./database.db"], + "env_vars": {} +} +``` + +### Puppeteer Web Automation + +```json +{ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-puppeteer"], + "env_vars": {} +} +``` + +### Memory Server + +```json +{ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "env_vars": {} +} +``` + +## Using MCP Tools + +### 1. Accessing Tools + +1. Navigate to **MCP Tools** +2. Select a tool from the list +3. Click **Execute Tool** + +### 2. Tool Parameters + +1. **Input Schema**: Review the tool's input requirements +2. **Parameters**: Fill in the required parameters as JSON +3. **Execute**: Run the tool with your parameters + +### 3. Tool Responses + +1. **Response Handling**: View tool responses in the interface +2. **Error Handling**: Review any errors or warnings +3. **Logging**: Check execution logs for debugging + +## Using MCP Resources + +### 1. Accessing Resources + +1. Navigate to **MCP Resources** +2. Select a resource from the list +3. Click **Read Resource** + +### 2. Resource Parameters + +1. **URI**: Review the resource URI +2. **Parameters**: Provide any required parameters +3. **Read**: Access the resource content + +### 3. Resource Content + +1. **Content Display**: View resource content in the interface +2. **MIME Type**: Check the content type +3. **Caching**: Resources may be cached for performance + +## Advanced Usage + +### 1. Multiple Servers + +1. **Server Management**: Configure multiple MCP servers +2. **Load Distribution**: Distribute load across servers +3. **Failover**: Implement failover strategies + +### 2. Custom Integrations + +1. **API Integration**: Connect to external APIs +2. **Database Integration**: Access database resources +3. **File System**: Work with file system resources + +### 3. Workflow Automation + +1. **Tool Chains**: Chain multiple tools together +2. **Conditional Logic**: Implement conditional execution +3. **Scheduling**: Schedule automated tasks + +## Monitoring and Maintenance + +### 1. Server Monitoring + +1. **Status Dashboard**: Monitor server health +2. **Performance Metrics**: Track performance indicators +3. **Error Logs**: Review error logs and alerts + +### 2. Tool Management + +1. **Tool Discovery**: Monitor tool discovery process +2. **Tool Updates**: Handle tool updates and changes +3. **Tool Testing**: Test tools before production use + +### 3. Resource Management + +1. **Resource Monitoring**: Monitor resource availability +2. **Cache Management**: Manage resource caching +3. **Access Control**: Control resource access permissions + +## Troubleshooting + +### 1. Common Issues + +1. **Server Connection**: Check server connectivity +2. **Tool Execution**: Verify tool parameters and permissions +3. **Resource Access**: Check resource availability and permissions + +### 2. Debugging + +1. **Log Analysis**: Review detailed logs +2. **Error Messages**: Analyze error messages +3. **Performance Issues**: Identify performance bottlenecks + +### 3. Support + +1. **Documentation**: Consult module documentation +2. **Community**: Seek help from the Odoo community +3. **Issues**: Report issues through GitHub + +## Best Practices + +### 1. Security + +1. **Access Control**: Implement proper access controls +2. **Environment Variables**: Secure sensitive configuration +3. **Network Security**: Use secure network configurations + +### 2. Performance + +1. **Resource Management**: Monitor resource usage +2. **Caching**: Implement appropriate caching +3. **Load Balancing**: Distribute load effectively + +### 3. Maintenance + +1. **Regular Updates**: Keep components updated +2. **Monitoring**: Implement comprehensive monitoring +3. **Backup**: Regular backup of configurations diff --git a/mcp_connector/security/ir.model.access.csv b/mcp_connector/security/ir.model.access.csv new file mode 100644 index 0000000..8480c8d --- /dev/null +++ b/mcp_connector/security/ir.model.access.csv @@ -0,0 +1,12 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_mcp_server_admin,mcp.server admin,model_mcp_server,base.group_system,1,1,1,1 +access_mcp_server_user,mcp.server user,model_mcp_server,base.group_user,1,0,0,0 +access_mcp_tool_admin,mcp.tool admin,model_mcp_tool,base.group_system,1,1,1,1 +access_mcp_tool_user,mcp.tool user,model_mcp_tool,base.group_user,1,0,0,0 +access_mcp_resource_admin,mcp.resource admin,model_mcp_resource,base.group_system,1,1,1,1 +access_mcp_resource_user,mcp.resource user,model_mcp_resource,base.group_user,1,0,0,0 +access_mcp_tool_call_wizard,mcp.tool.call.wizard,model_mcp_tool_call_wizard,base.group_user,1,1,1,1 +access_mcp_resource_read_wizard,mcp.resource.read.wizard,model_mcp_resource_read_wizard,base.group_user,1,1,1,1 +access_mcp_prompt_admin,mcp.prompt admin,model_mcp_prompt,base.group_system,1,1,1,1 +access_mcp_prompt_user,mcp.prompt user,model_mcp_prompt,base.group_user,1,0,0,0 +access_mcp_prompt_get_wizard,mcp.prompt.get.wizard,model_mcp_prompt_get_wizard,base.group_user,1,1,1,1 diff --git a/mcp_connector/static/description/icon.png b/mcp_connector/static/description/icon.png new file mode 100644 index 0000000..4adee3c Binary files /dev/null and b/mcp_connector/static/description/icon.png differ diff --git a/mcp_connector/static/description/index.html b/mcp_connector/static/description/index.html new file mode 100644 index 0000000..b667029 --- /dev/null +++ b/mcp_connector/static/description/index.html @@ -0,0 +1,122 @@ + + + + + MCP Connector + + + +
+

MCP Connector

+ +

Integrate Odoo with Model Context Protocol (MCP) servers to unlock the power of external AI tools, APIs, and services directly within your Odoo environment.

+ +
+ What is MCP?
+ Model Context Protocol (MCP) is a standardized protocol that enables AI applications to securely connect to data sources and tools, making it easier to integrate AI capabilities into existing applications. +
+ +

Key Features

+
    +
  • Server Management: Configure and control MCP servers with ease
  • +
  • Tool Integration: Access and utilize tools exposed by MCP servers
  • +
  • Resource Handling: Manage and interact with server resources
  • +
  • Flexible Configuration: Support for custom commands and environment variables
  • +
  • Secure Access: Built-in security with role-based access control
  • +
  • User-Friendly Interface: Intuitive wizards and comprehensive views
  • +
+ +

Use Cases

+
    +
  • AI Image Generation: Generate images from text prompts
  • +
  • Text Translation: Multi-language support for Odoo content
  • +
  • Weather Data: Real-time weather information integration
  • +
  • Business Analytics: Connect to analytics and BI tools
  • +
  • Custom APIs: Integrate with internal and external APIs
  • +
+ +

Quick Start

+
+1. Install: pip install mcp
+2. Configure your MCP server
+3. Start the server and discover tools
+4. Use tools through the intuitive interface +
+ +

Technical Details

+
    +
  • Threaded architecture for non-blocking operations
  • +
  • Full MCP protocol compliance
  • +
  • Comprehensive error handling and logging
  • +
  • Automatic tool and resource discovery
  • +
  • JSON-based parameter support
  • +
+ + +
+ + \ No newline at end of file diff --git a/mcp_connector/static/description/mcp_server_config.png b/mcp_connector/static/description/mcp_server_config.png new file mode 100644 index 0000000..a2bb2bb Binary files /dev/null and b/mcp_connector/static/description/mcp_server_config.png differ diff --git a/mcp_connector/static/description/mcp_server_discovered_resources.png b/mcp_connector/static/description/mcp_server_discovered_resources.png new file mode 100644 index 0000000..ecd6316 Binary files /dev/null and b/mcp_connector/static/description/mcp_server_discovered_resources.png differ diff --git a/mcp_connector/static/description/mcp_server_discovered_tools.png b/mcp_connector/static/description/mcp_server_discovered_tools.png new file mode 100644 index 0000000..28aed84 Binary files /dev/null and b/mcp_connector/static/description/mcp_server_discovered_tools.png differ diff --git a/mcp_connector/static/description/mcp_server_resource.png b/mcp_connector/static/description/mcp_server_resource.png new file mode 100644 index 0000000..c23c0ce Binary files /dev/null and b/mcp_connector/static/description/mcp_server_resource.png differ diff --git a/mcp_connector/static/description/mcp_server_resource_read.png b/mcp_connector/static/description/mcp_server_resource_read.png new file mode 100644 index 0000000..369b0a6 Binary files /dev/null and b/mcp_connector/static/description/mcp_server_resource_read.png differ diff --git a/mcp_connector/static/description/mcp_server_tool.png b/mcp_connector/static/description/mcp_server_tool.png new file mode 100644 index 0000000..5c4ea54 Binary files /dev/null and b/mcp_connector/static/description/mcp_server_tool.png differ diff --git a/mcp_connector/static/description/mcp_server_tool_call.png b/mcp_connector/static/description/mcp_server_tool_call.png new file mode 100644 index 0000000..fd0d12e Binary files /dev/null and b/mcp_connector/static/description/mcp_server_tool_call.png differ diff --git a/mcp_connector/static/description/mcp_servers.png b/mcp_connector/static/description/mcp_servers.png new file mode 100644 index 0000000..2429a51 Binary files /dev/null and b/mcp_connector/static/description/mcp_servers.png differ diff --git a/mcp_connector/static/description/mcp_tool_response.png b/mcp_connector/static/description/mcp_tool_response.png new file mode 100644 index 0000000..e2e69d6 Binary files /dev/null and b/mcp_connector/static/description/mcp_tool_response.png differ diff --git a/mcp_connector/tests/__init__.py b/mcp_connector/tests/__init__.py new file mode 100644 index 0000000..81adee8 --- /dev/null +++ b/mcp_connector/tests/__init__.py @@ -0,0 +1,4 @@ +from . import test_mcp_server +from . import test_mcp_tool +from . import test_mcp_resource +from . import test_integration diff --git a/mcp_connector/tests/mock_servers/__init__.py b/mcp_connector/tests/mock_servers/__init__.py new file mode 100644 index 0000000..6084401 --- /dev/null +++ b/mcp_connector/tests/mock_servers/__init__.py @@ -0,0 +1 @@ +# Mock MCP servers for testing diff --git a/mcp_connector/tests/mock_servers/integration_server.py b/mcp_connector/tests/mock_servers/integration_server.py new file mode 100644 index 0000000..ed14241 --- /dev/null +++ b/mcp_connector/tests/mock_servers/integration_server.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +""" +Mock MCP server for integration tests. +This server implements the MCP protocol to provide test tools and resources. +""" + +import json + +from mcp.server.fastmcp import FastMCP + + +def create_mock_server() -> FastMCP: + """Create a mock MCP server for testing.""" + mcp = FastMCP("Integration Test Server") + + @mcp.tool() + def test_tool(input_text: str) -> str: + """Test tool for integration testing.""" + return f"Processed: {input_text}" + + @mcp.tool() + def read_file(filename: str) -> str: + """Read file tool for testing.""" + return f"File content of {filename}: Mock content" + + @mcp.resource("test://resource/{name}") + def get_test_resource(name: str) -> str: + """Get a test resource by name.""" + return f"Test resource content for {name}" + + @mcp.resource("config://settings") + def get_settings() -> str: + """Get application settings.""" + return json.dumps({"test_mode": True, "debug": True, "version": "1.0.0"}) + + return mcp + + +def main(): + """Run the mock MCP server.""" + server = create_mock_server() + + # Run the server using stdio transport + server.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/mcp_connector/tests/mock_servers/test_server.py b/mcp_connector/tests/mock_servers/test_server.py new file mode 100644 index 0000000..0c9460f --- /dev/null +++ b/mcp_connector/tests/mock_servers/test_server.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +""" +Mock MCP server for basic tests. +This server implements the MCP protocol to provide basic test tools and resources. +""" + + +from mcp.server.fastmcp import FastMCP + + +def create_mock_server() -> FastMCP: + """Create a mock MCP server for testing.""" + mcp = FastMCP("Test Server") + + @mcp.tool() + def basic_tool(param: str) -> str: + """Basic tool for testing.""" + return f"Basic tool result: {param}" + + @mcp.tool() + def math_add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + @mcp.resource("test://basic/{resource_id}") + def get_basic_resource(resource_id: str) -> str: + """Get a basic resource by ID.""" + return f"Basic resource {resource_id} content" + + return mcp + + +def main(): + """Run the mock MCP server.""" + server = create_mock_server() + + # Run the server using stdio transport + server.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/mcp_connector/tests/test_integration.py b/mcp_connector/tests/test_integration.py new file mode 100644 index 0000000..1c2318f --- /dev/null +++ b/mcp_connector/tests/test_integration.py @@ -0,0 +1,255 @@ +# Copyright 2025 Escodoo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +class TestMcpIntegration(TransactionCase): + """Integration tests for MCP Connector module""" + + def setUp(self): + super().setUp() + # Get the path to the mock server + import os + + mock_server_path = os.path.join( + os.path.dirname(__file__), "mock_servers", "integration_server.py" + ) + + self.server = self.env["mcp.server"].create( + { + "name": "Integration Test Server", + "description": "Server for integration tests", + "command": "python", + "args": f'["{mock_server_path}"]', + "env_vars": '{"TEST_MODE": "true", "DEBUG": "1"}', + "auto_approve": '["test_tool", "read_file"]', + } + ) + + def test_server_tool_resource_integration(self): + """Test complete integration between server, tools, and resources""" + # Create tools + tool1 = self.env["mcp.tool"].create( + { + "name": "test_tool", + "description": "Test Tool for Integration", + "server_id": self.server.id, + "input_schema": ( + '{"type": "object", "properties": {"input": {"type": "string"}}}' + ), + } + ) + + tool2 = self.env["mcp.tool"].create( + { + "name": "read_file", + "description": "Read File Tool", + "server_id": self.server.id, + "input_schema": ( + '{"type": "object", "properties": {"filename": {"type": "string"}}}' + ), + } + ) + + # Create resources + resource1 = self.env["mcp.resource"].create( + { + "name": "config_file", + "description": "Configuration File", + "server_id": self.server.id, + "uri": "file:///etc/config.json", + "mime_type": "application/json", + } + ) + + resource2 = self.env["mcp.resource"].create( + { + "name": "log_file", + "description": "Log File", + "server_id": self.server.id, + "uri": "file:///var/log/app.log", + "mime_type": "text/plain", + } + ) + + # Test relationships + self.assertEqual(tool1.server_id, self.server) + self.assertEqual(tool2.server_id, self.server) + self.assertEqual(resource1.server_id, self.server) + self.assertEqual(resource2.server_id, self.server) + + # Test server counts + self.server._compute_tool_count() + self.server._compute_resource_count() + self.assertEqual(self.server.tool_count, 2) + self.assertEqual(self.server.resource_count, 2) + + def test_server_auto_approve_field(self): + """Test server auto-approve field functionality""" + # Test auto_approve field + self.server.auto_approve = '["test_tool", "read_file"]' + self.assertEqual(self.server.auto_approve, '["test_tool", "read_file"]') + + # Test JSON validation + self.server._check_json_fields() + + def test_cascade_deletion_integration(self): + """Test cascade deletion when server is deleted""" + # Create tools and resources + tool = self.env["mcp.tool"].create( + { + "name": "cascade_tool", + "description": "Tool for cascade test", + "server_id": self.server.id, + "input_schema": '{"type": "object"}', + } + ) + + resource = self.env["mcp.resource"].create( + { + "name": "cascade_resource", + "description": "Resource for cascade test", + "server_id": self.server.id, + "uri": "test://cascade", + "mime_type": "text/plain", + } + ) + + tool_id = tool.id + resource_id = resource.id + + # Delete server + self.server.unlink() + + # Check that tools and resources are also deleted + self.assertFalse(self.env["mcp.tool"].search([("id", "=", tool_id)])) + self.assertFalse(self.env["mcp.resource"].search([("id", "=", resource_id)])) + + def test_server_state_management_integration(self): + """Test server state management with tools and resources""" + # Create tools and resources + self.env["mcp.tool"].create( + { + "name": "state_tool", + "description": "Tool for state test", + "server_id": self.server.id, + "input_schema": '{"type": "object"}', + } + ) + + self.env["mcp.resource"].create( + { + "name": "state_resource", + "description": "Resource for state test", + "server_id": self.server.id, + "uri": "test://state", + "mime_type": "text/plain", + } + ) + + # Test server state changes + self.assertEqual(self.server.state, "stopped") + + # Test action_start + result = self.server.action_start() + self.assertEqual(result["type"], "ir.actions.client") + self.assertEqual(result["tag"], "display_notification") + + # Test action_stop + self.server.state = "running" + result = self.server.action_stop() + self.assertEqual(result["type"], "ir.actions.client") + self.assertEqual(result["tag"], "display_notification") + + def test_error_handling_integration(self): + """Test error handling across the integration""" + # Test server creation with invalid data + with self.assertRaises(ValidationError): + self.env["mcp.server"].create( + { + "name": "Invalid Server", + "command": "python", + "args": "invalid json", + } + ) + + def test_multiple_tools_resources(self): + """Test creating multiple tools and resources""" + # Create multiple tools and resources + tools = [] + resources = [] + + for i in range(5): + tool = self.env["mcp.tool"].create( + { + "name": f"tool_{i}", + "description": f"Tool {i}", + "server_id": self.server.id, + "input_schema": '{"type": "object"}', + } + ) + tools.append(tool) + + resource = self.env["mcp.resource"].create( + { + "name": f"resource_{i}", + "description": f"Resource {i}", + "server_id": self.server.id, + "uri": "test://resource_%d" % i, + "mime_type": "text/plain", + } + ) + resources.append(resource) + + # Test that all relationships are correct + self.server._compute_tool_count() + self.server._compute_resource_count() + self.assertEqual(self.server.tool_count, 5) + self.assertEqual(self.server.resource_count, 5) + + # Test search + tools_found = self.env["mcp.tool"].search([("server_id", "=", self.server.id)]) + self.assertEqual(len(tools_found), 5) + + resources_found = self.env["mcp.resource"].search( + [("server_id", "=", self.server.id)] + ) + self.assertEqual(len(resources_found), 5) + + def test_data_consistency_integration(self): + """Test data consistency across the integration""" + # Create initial data + tool = self.env["mcp.tool"].create( + { + "name": "consistency_tool", + "description": "Tool for consistency test", + "server_id": self.server.id, + "input_schema": '{"type": "object"}', + } + ) + + resource = self.env["mcp.resource"].create( + { + "name": "consistency_resource", + "description": "Resource for consistency test", + "server_id": self.server.id, + "uri": "test://consistency", + "mime_type": "text/plain", + } + ) + + # Test that server counts are consistent + self.server._compute_tool_count() + self.server._compute_resource_count() + self.assertEqual(self.server.tool_count, 1) + self.assertEqual(self.server.resource_count, 1) + + # Update server name and test consistency + self.server.write({"name": "Updated Server Name"}) + + # Check that relationships are still intact + self.assertEqual(tool.server_id.name, "Updated Server Name") + self.assertEqual(resource.server_id.name, "Updated Server Name") diff --git a/mcp_connector/tests/test_mcp_resource.py b/mcp_connector/tests/test_mcp_resource.py new file mode 100644 index 0000000..0f09ad8 --- /dev/null +++ b/mcp_connector/tests/test_mcp_resource.py @@ -0,0 +1,205 @@ +# Copyright 2025 Escodoo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo.tests.common import TransactionCase + + +class TestMcpResource(TransactionCase): + def setUp(self): + super().setUp() + self.server = self.env["mcp.server"].create( + { + "name": "Test Server", + "command": "python", + "args": '["test_server.py"]', + } + ) + self.resource = self.env["mcp.resource"].create( + { + "name": "test_resource", + "description": "Test Resource", + "server_id": self.server.id, + "uri": "test://resource", + "mime_type": "text/plain", + } + ) + + def test_resource_creation(self): + """Test MCP resource creation""" + self.assertEqual(self.resource.name, "test_resource") + self.assertEqual(self.resource.description, "Test Resource") + self.assertEqual(self.resource.server_id, self.server) + self.assertEqual(self.resource.uri, "test://resource") + self.assertEqual(self.resource.mime_type, "text/plain") + + def test_resource_server_relation(self): + """Test resource-server relationship""" + self.assertEqual(self.resource.server_id, self.server) + + def test_resource_uri_field(self): + """Test resource URI field""" + # Test valid URI + self.resource.uri = "https://example.com/resource" + self.assertEqual(self.resource.uri, "https://example.com/resource") + + def test_resource_uri_field_values(self): + """Test resource URI field with different values""" + # Test valid URI + self.resource.uri = "https://example.com/resource" + self.assertEqual(self.resource.uri, "https://example.com/resource") + + # Test another valid URI + self.resource.uri = "file:///path/to/resource" + self.assertEqual(self.resource.uri, "file:///path/to/resource") + + def test_resource_mime_type_field(self): + """Test resource MIME type field""" + # Test valid MIME types + valid_mimes = [ + "text/plain", + "text/html", + "application/json", + "image/png", + "video/mp4", + "application/octet-stream", + ] + + for mime in valid_mimes: + self.resource.mime_type = mime + self.assertEqual(self.resource.mime_type, mime) + + def test_resource_cascade_delete(self): + """Test resource deletion when server is deleted""" + resource_id = self.resource.id + + # Delete the server + self.server.unlink() + + # Resource should be deleted as well (cascade) + self.assertFalse(self.env["mcp.resource"].search([("id", "=", resource_id)])) + + def test_resource_search_by_name(self): + """Test searching resources by name""" + # Create another resource + self.env["mcp.resource"].create( + { + "name": "another_resource", + "description": "Another Resource", + "server_id": self.server.id, + "uri": "test://another", + "mime_type": "text/plain", + } + ) + + # Search by name + resources = self.env["mcp.resource"].search([("name", "ilike", "test")]) + self.assertEqual(len(resources), 1) + self.assertEqual(resources[0].name, "test_resource") + + # Search by server + resources = self.env["mcp.resource"].search( + [("server_id", "=", self.server.id)] + ) + self.assertEqual(len(resources), 2) + + def test_resource_search_by_uri(self): + """Test searching resources by URI""" + # Search by URI + resources = self.env["mcp.resource"].search([("uri", "ilike", "test://")]) + self.assertEqual(len(resources), 1) + self.assertEqual(resources[0].uri, "test://resource") + + def test_resource_search_by_mime_type(self): + """Test searching resources by MIME type""" + # Search by MIME type for our specific resource + resources = self.env["mcp.resource"].search( + [("mime_type", "=", "text/plain"), ("uri", "=", "test://resource")] + ) + self.assertEqual(len(resources), 1) + self.assertEqual(resources[0].mime_type, "text/plain") + + def test_resource_copy(self): + """Test resource copying""" + # Create a new server for the copy to avoid unique constraint violation + new_server = self.env["mcp.server"].create( + { + "name": "Copy Test Server", + "command": "python", + "args": '["copy_server.py"]', + } + ) + + # Copy the resource and assign to new server + copied_resource = self.resource.copy({"server_id": new_server.id}) + + # Check that the copy was created successfully + self.assertNotEqual(copied_resource.id, self.resource.id) + self.assertEqual(copied_resource.description, "Test Resource") + self.assertEqual(copied_resource.server_id, new_server) + self.assertEqual(copied_resource.uri, "test://resource") + self.assertEqual(copied_resource.mime_type, "text/plain") + # The name might be the same or have a suffix depending on Odoo version + self.assertTrue(copied_resource.name.startswith("test_resource")) + + def test_resource_write(self): + """Test resource updating""" + self.resource.write( + { + "name": "updated_resource", + "description": "Updated Resource", + "uri": "test://updated", + "mime_type": "application/json", + } + ) + + self.assertEqual(self.resource.name, "updated_resource") + self.assertEqual(self.resource.description, "Updated Resource") + self.assertEqual(self.resource.uri, "test://updated") + self.assertEqual(self.resource.mime_type, "application/json") + + def test_resource_basic_fields(self): + """Test basic resource fields""" + self.assertEqual(self.resource.name, "test_resource") + self.assertEqual(self.resource.description, "Test Resource") + self.assertEqual(self.resource.server_id, self.server) + self.assertEqual(self.resource.uri, "test://resource") + self.assertEqual(self.resource.mime_type, "text/plain") + + def test_resource_ordering(self): + """Test resource ordering by server and name""" + # Create another server + server2 = self.env["mcp.server"].create( + { + "name": "Server 2", + "command": "python", + "args": '["server2.py"]', + } + ) + + # Create resources with different servers and names + resource1 = self.env["mcp.resource"].create( + { + "name": "z_resource", + "server_id": self.server.id, + "uri": "test://z", + "mime_type": "text/plain", + } + ) + resource2 = self.env["mcp.resource"].create( + { + "name": "a_resource", + "server_id": server2.id, + "uri": "test://a", + "mime_type": "text/plain", + } + ) + + # Test ordering (order by server_id, name) + resources = self.env["mcp.resource"].search([]) + # Resources are ordered by server_id, name + # First server: self.resource, resource1 + # Second server: resource2 + self.assertIn(self.resource, resources) + self.assertIn(resource1, resources) + self.assertIn(resource2, resources) diff --git a/mcp_connector/tests/test_mcp_server.py b/mcp_connector/tests/test_mcp_server.py new file mode 100644 index 0000000..8b4bca5 --- /dev/null +++ b/mcp_connector/tests/test_mcp_server.py @@ -0,0 +1,198 @@ +# Copyright 2025 Escodoo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +class TestMcpServer(TransactionCase): + def setUp(self): + super().setUp() + # Get the path to the mock server + import os + + mock_server_path = os.path.join( + os.path.dirname(__file__), "mock_servers", "test_server.py" + ) + + self.server = self.env["mcp.server"].create( + { + "name": "Test Server", + "description": "Test MCP Server", + "command": "python", + "args": f'["{mock_server_path}"]', + "env_vars": '{"TEST_VAR": "test_value"}', + } + ) + + def test_server_creation(self): + """Test MCP server creation""" + self.assertEqual(self.server.name, "Test Server") + self.assertEqual(self.server.command, "python") + # Check that args contains the mock server path + self.assertIn("mock_servers/test_server.py", self.server.args) + self.assertEqual(self.server.env_vars, '{"TEST_VAR": "test_value"}') + self.assertTrue(self.server.active) + self.assertFalse(self.server.enabled) + self.assertEqual(self.server.state, "stopped") + + def test_server_json_fields(self): + """Test MCP server JSON fields""" + # Test valid JSON args + self.server.args = '["arg1", "arg2"]' + self.server.env_vars = '{"key": "value"}' + self.assertEqual(self.server.args, '["arg1", "arg2"]') + self.assertEqual(self.server.env_vars, '{"key": "value"}') + + def test_server_toggle_active(self): + """Test server active toggle""" + self.assertTrue(self.server.active) + self.server.active = False + self.assertFalse(self.server.active) + + def test_server_toggle_enabled(self): + """Test server enabled toggle""" + self.assertFalse(self.server.enabled) + self.server.enabled = True + self.assertTrue(self.server.enabled) + + def test_server_state_transitions(self): + """Test server state field""" + self.assertEqual(self.server.state, "stopped") + self.server.state = "running" + self.assertEqual(self.server.state, "running") + self.server.state = "error" + self.assertEqual(self.server.state, "error") + + def test_server_auto_approve_field(self): + """Test auto_approve field""" + self.server.auto_approve = '["tool1", "tool2"]' + self.assertEqual(self.server.auto_approve, '["tool1", "tool2"]') + + def test_server_invalid_json_args(self): + """Test server with invalid JSON args""" + with self.assertRaises(ValidationError): + self.env["mcp.server"].create( + { + "name": "Invalid Server", + "command": "python", + "args": "invalid json", + } + ) + + def test_server_invalid_json_env_vars(self): + """Test server with invalid JSON env_vars""" + with self.assertRaises(ValidationError): + self.env["mcp.server"].create( + { + "name": "Invalid Server", + "command": "python", + "args": '["test.py"]', + "env_vars": "invalid json", + } + ) + + def test_server_invalid_json_auto_approve(self): + """Test server with invalid JSON auto_approve""" + with self.assertRaises(ValidationError): + self.env["mcp.server"].create( + { + "name": "Invalid Server", + "command": "python", + "args": '["test.py"]', + "auto_approve": "invalid json", + } + ) + + def test_server_json_validation_args(self): + """Test JSON validation for args field""" + # Valid JSON array + self.server.args = '["arg1", "arg2", "arg3"]' + self.server._check_json_fields() + + # Invalid JSON (not an array) + with self.assertRaises(ValidationError): + self.server.args = '{"not": "array"}' + + def test_server_json_validation_env_vars(self): + """Test JSON validation for env_vars field""" + # Valid JSON object + self.server.env_vars = '{"KEY1": "value1", "KEY2": "value2"}' + self.server._check_json_fields() + + # Invalid JSON (not an object) + with self.assertRaises(ValidationError): + self.server.env_vars = '["not", "object"]' + + def test_server_json_validation_auto_approve(self): + """Test JSON validation for auto_approve field""" + # Valid JSON array + self.server.auto_approve = '["tool1", "tool2"]' + self.server._check_json_fields() + + # Invalid JSON (not an array) + with self.assertRaises(ValidationError): + self.server.auto_approve = '{"not": "array"}' + + def test_server_tool_count(self): + """Test server tool count computation""" + # Initially no tools + self.assertEqual(self.server.tool_count, 0) + + # Create a tool + self.env["mcp.tool"].create( + { + "name": "test_tool", + "description": "Test Tool", + "server_id": self.server.id, + "input_schema": '{"type": "object"}', + } + ) + + # Refresh the count + self.server._compute_tool_count() + self.assertEqual(self.server.tool_count, 1) + + def test_server_resource_count(self): + """Test server resource count computation""" + # Initially no resources + self.assertEqual(self.server.resource_count, 0) + + # Create a resource + self.env["mcp.resource"].create( + { + "name": "test_resource", + "description": "Test Resource", + "server_id": self.server.id, + "uri": "test://resource", + "mime_type": "text/plain", + } + ) + + # Refresh the count + self.server._compute_resource_count() + self.assertEqual(self.server.resource_count, 1) + + def test_server_action_start(self): + """Test server start action""" + # Test starting server + result = self.server.action_start() + + # Check that it returns a notification action + self.assertEqual(result["type"], "ir.actions.client") + self.assertEqual(result["tag"], "display_notification") + self.assertEqual(result["params"]["title"], "Server Started") + + def test_server_action_stop(self): + """Test server stop action""" + # Set server to running first + self.server.state = "running" + + # Test stopping server + result = self.server.action_stop() + + # Check that it returns a notification action + self.assertEqual(result["type"], "ir.actions.client") + self.assertEqual(result["tag"], "display_notification") + self.assertEqual(result["params"]["title"], "Server Stopped") diff --git a/mcp_connector/tests/test_mcp_tool.py b/mcp_connector/tests/test_mcp_tool.py new file mode 100644 index 0000000..40723dc --- /dev/null +++ b/mcp_connector/tests/test_mcp_tool.py @@ -0,0 +1,153 @@ +# Copyright 2025 Escodoo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +class TestMcpTool(TransactionCase): + def setUp(self): + super().setUp() + self.server = self.env["mcp.server"].create( + { + "name": "Test Server", + "command": "python", + "args": '["test_server.py"]', + } + ) + self.tool = self.env["mcp.tool"].create( + { + "name": "test_tool", + "description": "Test Tool", + "server_id": self.server.id, + "input_schema": ( + '{"type": "object", "properties": {"text": {"type": "string"}}}' + ), + } + ) + + def test_tool_creation(self): + """Test MCP tool creation""" + self.assertEqual(self.tool.name, "test_tool") + self.assertEqual(self.tool.description, "Test Tool") + self.assertEqual(self.tool.server_id, self.server) + self.assertEqual( + self.tool.input_schema, + ('{"type": "object", "properties": {"text": {"type": "string"}}}'), + ) + + def test_tool_json_schema(self): + """Test MCP tool JSON schema""" + # Test valid JSON schema + self.tool.input_schema = '{"type": "object"}' + self.assertEqual(self.tool.input_schema, '{"type": "object"}') + + def test_tool_server_relation(self): + """Test tool-server relationship""" + self.assertEqual(self.tool.server_id, self.server) + + def test_tool_invalid_json_schema(self): + """Test tool with invalid JSON schema""" + with self.assertRaises(ValidationError): + self.env["mcp.tool"].create( + { + "name": "invalid_tool", + "description": "Tool with invalid schema", + "server_id": self.server.id, + "input_schema": "invalid json", + } + ) + + def test_tool_json_validation(self): + """Test JSON validation for input_schema field""" + # Valid JSON schema + self.tool.input_schema = ( + '{"type": "object", "properties": {"name": {"type": "string"}}}' + ) + # The validation happens automatically on write + + # Invalid JSON + with self.assertRaises(ValidationError): + self.tool.input_schema = "invalid json" + + def test_tool_schema_validation(self): + """Test schema validation for input_schema field""" + # Valid JSON schema + self.tool.input_schema = ( + '{"type": "object", "properties": {"name": {"type": "string"}}}' + ) + # The validation happens automatically on write + + # Invalid JSON (not an object) + with self.assertRaises(ValidationError): + self.tool.input_schema = '["not", "object"]' + + def test_tool_cascade_delete(self): + """Test tool deletion when server is deleted""" + tool_id = self.tool.id + + # Delete the server + self.server.unlink() + + # Tool should be deleted as well (cascade) + self.assertFalse(self.env["mcp.tool"].search([("id", "=", tool_id)])) + + def test_tool_search_by_name(self): + """Test searching tools by name""" + # Create another tool + self.env["mcp.tool"].create( + { + "name": "another_tool", + "description": "Another Tool", + "server_id": self.server.id, + "input_schema": '{"type": "object"}', + } + ) + + # Search by name + tools = self.env["mcp.tool"].search([("name", "ilike", "test")]) + self.assertEqual(len(tools), 1) + self.assertEqual(tools[0].name, "test_tool") + + # Search by server + tools = self.env["mcp.tool"].search([("server_id", "=", self.server.id)]) + self.assertEqual(len(tools), 2) + + def test_tool_copy(self): + """Test tool copying""" + # Create a new server for the copy to avoid unique constraint violation + new_server = self.env["mcp.server"].create( + { + "name": "Copy Test Server", + "command": "python", + "args": '["copy_server.py"]', + } + ) + + # Copy the tool and assign to new server + copied_tool = self.tool.copy({"server_id": new_server.id}) + + # Check that the copy was created successfully + self.assertNotEqual(copied_tool.id, self.tool.id) + self.assertEqual(copied_tool.description, "Test Tool") + self.assertEqual(copied_tool.server_id, new_server) + self.assertEqual(copied_tool.input_schema, self.tool.input_schema) + # The name might be the same or have a suffix depending on Odoo version + self.assertTrue(copied_tool.name.startswith("test_tool")) + + def test_tool_write(self): + """Test tool updating""" + self.tool.write( + { + "name": "updated_tool", + "description": "Updated Tool", + "input_schema": ( + '{"type": "object", "properties": {"new_field": {"type": "string"}}}' + ), + } + ) + + self.assertEqual(self.tool.name, "updated_tool") + self.assertEqual(self.tool.description, "Updated Tool") + self.assertIn("new_field", self.tool.input_schema) diff --git a/mcp_connector/views/mcp_prompt_views.xml b/mcp_connector/views/mcp_prompt_views.xml new file mode 100644 index 0000000..f676c62 --- /dev/null +++ b/mcp_connector/views/mcp_prompt_views.xml @@ -0,0 +1,101 @@ + + + + + mcp.prompt.tree + mcp.prompt + + + + + + + + + + + + + mcp.prompt.form + mcp.prompt + +
+
+
+ +
+
+
+

+ +

+
+ + + + + + + + + +
+
+
+
+ + + + mcp.prompt.search + mcp.prompt + + + + + + + + + + + + + + + + MCP Prompts + mcp.prompt + tree,form + + {} + +

+ Create your first MCP Prompt! +

+

+ MCP Prompts are AI-powered templates that can be used to generate + structured prompts for various tasks. They are discovered from + connected MCP servers. +

+
+
+ + +
diff --git a/mcp_connector/views/mcp_resource_views.xml b/mcp_connector/views/mcp_resource_views.xml new file mode 100644 index 0000000..2522c78 --- /dev/null +++ b/mcp_connector/views/mcp_resource_views.xml @@ -0,0 +1,143 @@ + + + + + mcp.resource.form + mcp.resource + +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + +
+
+
+
+ + + + mcp.resource.tree + mcp.resource + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + mcp.server.tree + mcp.server + + + + + + + + + + + + + + + + mcp.server.search + mcp.server + + + + + + + + + + + + + + + + + + + MCP Servers + mcp.server + tree,form + + +

+ Create your first MCP server +

+

+ MCP servers allow Odoo to communicate with external Model Context Protocol servers, + enabling access to tools and resources provided by those servers. +

+
+
+
diff --git a/mcp_connector/views/mcp_tool_views.xml b/mcp_connector/views/mcp_tool_views.xml new file mode 100644 index 0000000..02f0e7b --- /dev/null +++ b/mcp_connector/views/mcp_tool_views.xml @@ -0,0 +1,120 @@ + + + + + mcp.tool.form + mcp.tool + +
+
+
+ +
+
+ + + + + + + + + + + + + + + +
+
+
+
+ + + + mcp.tool.tree + mcp.tool + + + + + + + +