diff --git a/.ansible-lint b/.ansible-lint new file mode 100644 index 0000000..55cbb4c --- /dev/null +++ b/.ansible-lint @@ -0,0 +1,6 @@ +--- +profile: production + +exclude_paths: + - .github/ + - tests/integration diff --git a/.gitignore b/.gitignore index 64885d5..e4a6736 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,11 @@ # https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore # Byte-compiled / optimized / DLL files +tests/integration/inventory +tests/integration/integration_config.yml __pycache__/ *.py[cod] *$py.class +tests/integration/integration_config.yml # C extensions *.so diff --git a/plugins/action/tools_info.py b/plugins/action/tools_info.py new file mode 100644 index 0000000..f8378be --- /dev/null +++ b/plugins/action/tools_info.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2025 Red Hat, Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from ansible.module_utils.connection import Connection +from ansible.plugins.action import ActionBase + +from ansible_collections.ansible.mcp.plugins.plugin_utils.utils import validate_connection_plugin + + +class ActionModule(ActionBase): + + def run(self, tmp=None, task_vars=None): + """Perform the process of the action plugin""" + + result = super(ActionModule, self).run(task_vars=task_vars) + v_result = validate_connection_plugin(self._play_context, "tools_info") + if v_result: + result.update(v_result) + return result + + conn = Connection(self._connection.socket_path) + tools = conn.list_tools().get("tools", []) + + return dict(changed=False, tools=tools) diff --git a/plugins/modules/tools_info.py b/plugins/modules/tools_info.py new file mode 100644 index 0000000..1d0fcd2 --- /dev/null +++ b/plugins/modules/tools_info.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2025 Red Hat, Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +DOCUMENTATION = """ +module: tools_info +author: Aubin Bikouo (@abikouo) +short_description: Retrieve a list of supported tools from an MCP server +description: + - This module is used to discover available tools from an MCP server. + - The module sends a tools/list request to the server. +version_added: 1.0.0 +options: {} +""" + +EXAMPLES = """ +- name: Retrieve list of supported tools from an MCP server. + ansible.mcp.tools_info: +""" + +RETURN = """ +tools: + description: List of supported tools. + returned: success + type: list + elements: dict + sample: { + "name": "get_weather", + "title": "Weather Information Provider", + "description": "Get current weather information for a location", + "inputSchema": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "City name or zip code" + } + }, + "required": ["location"] + } + } +""" diff --git a/plugins/plugin_utils/transport.py b/plugins/plugin_utils/transport.py index 3141885..6367175 100644 --- a/plugins/plugin_utils/transport.py +++ b/plugins/plugin_utils/transport.py @@ -196,10 +196,14 @@ def request(self, data: dict) -> dict: try: # Send request to the server self._stdin_write(data) + except Exception as e: + raise AnsibleConnectionFailure(f"Error sending request to MCP server: {str(e)}") + + try: # Read response return self._stdout_read() except Exception as e: - raise AnsibleConnectionFailure(f"Error sending request to MCP server: {str(e)}") + raise AnsibleConnectionFailure(f"Error reading server response: {str(e)}") def close(self) -> None: """Close the server connection.""" diff --git a/plugins/plugin_utils/utils.py b/plugins/plugin_utils/utils.py new file mode 100644 index 0000000..359fabe --- /dev/null +++ b/plugins/plugin_utils/utils.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2025 Red Hat, Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +from typing import Any + + +def validate_connection_plugin(play_context: Any, module_name: str) -> dict: + """Ensure the action module is running with the mcp connection plugin. + + Args: + play_content: The object containing the playbook context. + module_name: The name of the module being executed. + Returns: + An optional dictionary with the error message. + """ + + result: dict[str, Any] = {} + connection_name = play_context.connection.split(".")[-1] + if connection_name != "mcp": + # It is supported only with mcp connection plugin + result["failed"] = True + result["msg"] = ( + f"connection type {play_context.connection} is not valid for {module_name} module," + " please use fully qualified name of mcp connection type" + ) + return result diff --git a/tests/integration/targets/generate_inventory/alias b/tests/integration/targets/generate_inventory/alias new file mode 100644 index 0000000..87e7bda --- /dev/null +++ b/tests/integration/targets/generate_inventory/alias @@ -0,0 +1 @@ +disabled \ No newline at end of file diff --git a/tests/integration/targets/generate_inventory/tasks/main.yml b/tests/integration/targets/generate_inventory/tasks/main.yml new file mode 100644 index 0000000..30e9c3a --- /dev/null +++ b/tests/integration/targets/generate_inventory/tasks/main.yml @@ -0,0 +1,14 @@ +--- +- name: Create temporary file for inventory + ansible.builtin.tempfile: + suffix: "inventory.yml" + register: tmp_inventory + +- name: Set variable to the inventory file + ansible.builtin.set_fact: + generate_inventory_file_path: "{{ tmp_inventory.path }}" + +- name: Generate inventory file in the expected location + ansible.builtin.template: + src: inventory.yml.j2 + dest: "{{ generate_inventory_file_path }}" diff --git a/tests/integration/targets/generate_inventory/templates/inventory.yml.j2 b/tests/integration/targets/generate_inventory/templates/inventory.yml.j2 new file mode 100644 index 0000000..b26b3c3 --- /dev/null +++ b/tests/integration/targets/generate_inventory/templates/inventory.yml.j2 @@ -0,0 +1,20 @@ +all: + children: + mcp_servers: + hosts: +{% for host_name in ansible_mcp_hosts %} + {{ host_name.name }}: + ansible_connection: ansible.mcp.mcp + ansible_mcp_server_name: {{ host_name.server_name }} + ansible_mcp_server_args: {{ host_name.server_args | default([]) }} +{% if host_name.server_env is defined %} + ansible_mcp_server_env: +{% for key, value in host_name.server_env.items() %} + {{ key }}: '{{ value }}' +{% endfor %} +{% endif %} +{% if host_name.bearer_token is defined %} + ansible_mcp_bearer_token: '{{ host_name.bearer_token }}' +{% endif %} + ansible_mcp_manifest_path: "{{ host_name.manifest_path | default('/opt/mcp/mcpservers.json') }}" +{% endfor %} \ No newline at end of file diff --git a/tests/integration/targets/tools_info/.gitignore b/tests/integration/targets/tools_info/.gitignore new file mode 100644 index 0000000..611281b --- /dev/null +++ b/tests/integration/targets/tools_info/.gitignore @@ -0,0 +1 @@ +inventory.yml \ No newline at end of file diff --git a/tests/integration/targets/tools_info/manifest.json b/tests/integration/targets/tools_info/manifest.json new file mode 100644 index 0000000..1ff39c0 --- /dev/null +++ b/tests/integration/targets/tools_info/manifest.json @@ -0,0 +1,7 @@ +{ + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "description": "GitHub MCP Server - Access GitHub repositories, issues, and pull requests" + } +} \ No newline at end of file diff --git a/tests/integration/targets/tools_info/runme.sh b/tests/integration/targets/tools_info/runme.sh new file mode 100755 index 0000000..25471b5 --- /dev/null +++ b/tests/integration/targets/tools_info/runme.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -eux + +function cleanup() { + rm -f ./inventory.yml + exit 1 +} + +ANSIBLE_ROLES_PATH="../" +export ANSIBLE_ROLES_PATH + +trap 'cleanup "${@}"' ERR + +# Configure test environment +ansible-playbook setup.yml "$@" + +# Run tests +ansible-playbook test.yml -i inventory.yml "$@" + +# Remove inventory file +rm -f ./inventory.yml \ No newline at end of file diff --git a/tests/integration/targets/tools_info/setup.yml b/tests/integration/targets/tools_info/setup.yml new file mode 100644 index 0000000..5f5aa52 --- /dev/null +++ b/tests/integration/targets/tools_info/setup.yml @@ -0,0 +1,24 @@ +--- +- hosts: localhost + gather_facts: true + + vars: + ansible_mcp_hosts: + - name: github + server_name: github + bearer_token: "{{ github_mcp_pat }}" + manifest_path: "{{ playbook_dir }}/manifest.json" + + tasks: + - ansible.builtin.import_role: + name: generate_inventory + + - name: Copy temporary inventory file into expected location + ansible.builtin.copy: + src: "{{ generate_inventory_file_path }}" + dest: "{{ playbook_dir }}/inventory.yml" + + - name: Delete path to the inventory + ansible.builtin.file: + state: absent + path: "{{ generate_inventory_file_path }}" diff --git a/tests/integration/targets/tools_info/test.yml b/tests/integration/targets/tools_info/test.yml new file mode 100644 index 0000000..509541e --- /dev/null +++ b/tests/integration/targets/tools_info/test.yml @@ -0,0 +1,16 @@ +--- +- name: Run ansible.mcp.tools_info tests + hosts: github + gather_facts: false + + tasks: + - name: List tools from MCP servers + ansible.mcp.tools_info: + register: result + + - name: Validate that the module response is as expected + ansible.builtin.assert: + that: + - '"tools" in result' + - result.tools | length > 0 + - result.tools | selectattr('name', 'equalto', 'pull_request_read') | list | length == 1