|
| 1 | +# -*- coding: utf-8 -*- |
| 2 | +# Copyright (c) 2025 Red Hat, Inc. |
| 3 | +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) |
| 4 | + |
| 5 | + |
| 6 | +DOCUMENTATION = r""" |
| 7 | +--- |
| 8 | +name: mcp |
| 9 | +author: |
| 10 | + - Alina Buzachis (@alinabuzachis) |
| 11 | +version_added: 1.0.0 |
| 12 | +short_description: Persistent connection to an Model Context Protocol (MCP) server |
| 13 | +description: |
| 14 | + - This connection plugin allows for a persistent connection to an Model Context Protocol (MCP) server. |
| 15 | + - It is designed to run once per host for the duration of a playbook, allowing tasks to communicate with a single, long-lived server session. |
| 16 | + - Both stdio and Streamable HTTP transport methods are supported. |
| 17 | + - All tasks using this connection plugin are run on the Ansible control node. |
| 18 | +options: |
| 19 | + server_name: |
| 20 | + description: |
| 21 | + - The name of the MCP server. |
| 22 | + type: str |
| 23 | + required: true |
| 24 | + vars: |
| 25 | + - name: ansible_mcp_server_name |
| 26 | + server_args: |
| 27 | + description: |
| 28 | + - Additional command line arguments to pass to the server when using stdio transport. |
| 29 | + type: list |
| 30 | + elements: str |
| 31 | + vars: |
| 32 | + - name: ansible_mcp_server_args |
| 33 | + env: |
| 34 | + - name: MCP_BEARER_TOKEN |
| 35 | + server_env: |
| 36 | + description: |
| 37 | + - Additional environment variables to pass to the server when using stdio transport. |
| 38 | + - These are merged with the current environment. |
| 39 | + - Ignored when using http transport. |
| 40 | + type: dict |
| 41 | + vars: |
| 42 | + - name: ansible_mcp_server_env |
| 43 | + bearer_token: |
| 44 | + description: |
| 45 | + - Bearer token for authenticating to the MCP server when using http transport. |
| 46 | + - Ignored when using stdio transport. |
| 47 | + type: str |
| 48 | + vars: |
| 49 | + - name: ansible_mcp_bearer_token |
| 50 | + manifest_path: |
| 51 | + description: |
| 52 | + - Path to MCP manifest JSON file to resolve server executable paths for stdio. |
| 53 | + type: str |
| 54 | + default: "/opt/mcp/mcpservers.json" |
| 55 | + vars: |
| 56 | + - name: ansible_mcp_manifest_path |
| 57 | + validate_certs: |
| 58 | + description: |
| 59 | + - Whether to validate SSL certificates when using http transport. |
| 60 | + type: bool |
| 61 | + default: true |
| 62 | + vars: |
| 63 | + - name: ansible_mcp_validate_certs |
| 64 | + persistent_connect_timeout: |
| 65 | + description: |
| 66 | + - Timeout in seconds for initial connection to persistent transport. |
| 67 | + type: int |
| 68 | + default: 30 |
| 69 | + env: |
| 70 | + - name: ANSIBLE_PERSISTENT_CONNECT_TIMEOUT |
| 71 | + vars: |
| 72 | + - name: ansible_connect_timeout |
| 73 | + persistent_command_timeout: |
| 74 | + description: |
| 75 | + - Timeout for persistent connection commands in seconds. |
| 76 | + type: int |
| 77 | + default: 30 |
| 78 | + env: |
| 79 | + - name: ANSIBLE_PERSISTENT_COMMAND_TIMEOUT |
| 80 | + vars: |
| 81 | + - name: ansible_command_timeout |
| 82 | + persistent_log_messages: |
| 83 | + description: |
| 84 | + - Enable logging of messages from persistent connection. |
| 85 | + - Be sure to fully understand the security implications of enabling this |
| 86 | + option as it could create a security vulnerability by logging sensitive information in log file. |
| 87 | + type: boolean |
| 88 | + default: False |
| 89 | + env: |
| 90 | + - name: ANSIBLE_PERSISTENT_LOG_MESSAGES |
| 91 | + vars: |
| 92 | + - name: ansible_persistent_log_messages |
| 93 | +""" |
| 94 | + |
| 95 | + |
| 96 | +import json |
| 97 | +import os |
| 98 | +import time |
| 99 | + |
| 100 | +from functools import wraps |
| 101 | +from typing import Any, Dict |
| 102 | + |
| 103 | +from ansible.errors import AnsibleConnectionFailure |
| 104 | +from ansible.utils.display import Display |
| 105 | +from ansible_collections.ansible.utils.plugins.plugin_utils.connection_base import ( |
| 106 | + PersistentConnectionBase, |
| 107 | +) |
| 108 | + |
| 109 | +from ansible_collections.ansible.mcp.plugins.plugin_utils.client import MCPClient |
| 110 | +from ansible_collections.ansible.mcp.plugins.plugin_utils.transport import ( |
| 111 | + Stdio, |
| 112 | + StreamableHTTP, |
| 113 | + Transport, |
| 114 | +) |
| 115 | + |
| 116 | + |
| 117 | +display = Display() |
| 118 | + |
| 119 | + |
| 120 | +def ensure_connected(func): |
| 121 | + """Decorator ensuring that a connection is established before a method runs.""" |
| 122 | + |
| 123 | + @wraps(func) |
| 124 | + def wrapper(self, *args, **kwargs): |
| 125 | + # Check the connection status |
| 126 | + if not self.connected: |
| 127 | + display.vvv( |
| 128 | + f"MCP connection not established. Calling _connect() for method: {func.__name__}" |
| 129 | + ) |
| 130 | + # If not connected, establish the connection |
| 131 | + try: |
| 132 | + self._connect() |
| 133 | + except Exception as e: |
| 134 | + raise AnsibleConnectionFailure(f"Failed to connect to MCP server: {e}") |
| 135 | + # Call the original method |
| 136 | + return func(self, *args, **kwargs) |
| 137 | + |
| 138 | + return wrapper |
| 139 | + |
| 140 | + |
| 141 | +class Connection(PersistentConnectionBase): |
| 142 | + """ |
| 143 | + Ansible persistent connection plugin for the Model Context Protocol (MCP) server. |
| 144 | + """ |
| 145 | + |
| 146 | + transport = "ansible.mcp.mcp" |
| 147 | + has_pipelining = False |
| 148 | + |
| 149 | + def __init__(self, play_context, new_stdin, *args, **kwargs): |
| 150 | + super(Connection, self).__init__(play_context, new_stdin, *args, **kwargs) |
| 151 | + self._client = None |
| 152 | + self._connected = False |
| 153 | + |
| 154 | + @property |
| 155 | + def connected(self) -> bool: |
| 156 | + """Return True if connected to MCP server.""" |
| 157 | + return not self._conn_closed and self._connected and self._client is not None |
| 158 | + |
| 159 | + def _connect(self): |
| 160 | + """ |
| 161 | + Establishes the connection and performs the MCP initialization handshake. |
| 162 | + This runs only once per host/plugin instance. |
| 163 | + """ |
| 164 | + if self.connected: |
| 165 | + display.vvv("[mcp] Already connected, skipping _connect()") |
| 166 | + return |
| 167 | + |
| 168 | + server_name = self.get_option("server_name") |
| 169 | + manifest_path = self.get_option("manifest_path") or "/opt/mcp/mcpservers.json" |
| 170 | + |
| 171 | + server_info = self._load_server_from_manifest(server_name, manifest_path) |
| 172 | + transport = self._create_transport(server_name, server_info) |
| 173 | + |
| 174 | + # Initialize MCP client |
| 175 | + self._client = MCPClient(transport) |
| 176 | + |
| 177 | + timeout = self.get_option("persistent_connect_timeout") |
| 178 | + start_time = time.time() |
| 179 | + while True: |
| 180 | + try: |
| 181 | + self._client.initialize() |
| 182 | + break |
| 183 | + except Exception as e: |
| 184 | + if time.time() - start_time > timeout: |
| 185 | + raise AnsibleConnectionFailure( |
| 186 | + f"MCP connection timed out after {timeout}s: {e}" |
| 187 | + ) |
| 188 | + time.sleep(1) |
| 189 | + |
| 190 | + self._connected = True |
| 191 | + display.vvv(f"[mcp] Connection to '{server_name}' successfully initialized") |
| 192 | + |
| 193 | + def _load_server_from_manifest(self, server_name: str, manifest_path: str) -> dict: |
| 194 | + """Load the MCP server info from manifest JSON.""" |
| 195 | + if not os.path.exists(manifest_path): |
| 196 | + raise AnsibleConnectionFailure(f"MCP manifest not found at {manifest_path}") |
| 197 | + |
| 198 | + try: |
| 199 | + with open(manifest_path, "r", encoding="utf-8") as f: |
| 200 | + manifest = json.load(f) |
| 201 | + except json.JSONDecodeError as e: |
| 202 | + raise AnsibleConnectionFailure(f"[mcp] Failed to parse MCP manifest JSON: {e}") |
| 203 | + |
| 204 | + if server_name not in manifest: |
| 205 | + raise AnsibleConnectionFailure(f"MCP server '{server_name}' not found in manifest") |
| 206 | + |
| 207 | + return manifest[server_name] |
| 208 | + |
| 209 | + def _create_transport(self, server_name: str, server_info: dict) -> Transport: |
| 210 | + """Create the appropriate transport based on manifest server info.""" |
| 211 | + transport_type = server_info.get("type") |
| 212 | + |
| 213 | + if transport_type == "stdio": |
| 214 | + if "command" not in server_info: |
| 215 | + raise AnsibleConnectionFailure( |
| 216 | + f"[mcp] Manifest for '{server_name}' missing 'command' for stdio transport" |
| 217 | + ) |
| 218 | + manifest_args = server_info.get("args", []) |
| 219 | + plugin_args = self.get_option("server_args") or [] |
| 220 | + cmd = [server_info["command"]] + manifest_args + plugin_args |
| 221 | + env = self.get_option("server_env") or {} |
| 222 | + display.vvv(f"[mcp] Starting stdio MCP server '{server_name}': {' '.join(cmd)}") |
| 223 | + return Stdio(cmd=cmd, env=env) |
| 224 | + |
| 225 | + elif transport_type == "http": |
| 226 | + url = server_info.get("url") |
| 227 | + |
| 228 | + if not url: |
| 229 | + raise AnsibleConnectionFailure( |
| 230 | + f"[mcp] Manifest for '{server_name}' missing 'url' for http transport" |
| 231 | + ) |
| 232 | + |
| 233 | + headers = {} |
| 234 | + token = self.get_option("bearer_token") |
| 235 | + if token: |
| 236 | + headers["Authorization"] = f"Bearer {token}" |
| 237 | + display.vvv(f"[mcp] Connecting to HTTP MCP server '{server_name}': {url}") |
| 238 | + return StreamableHTTP( |
| 239 | + url=url, headers=headers, validate_certs=self.get_option("validate_certs") |
| 240 | + ) |
| 241 | + |
| 242 | + else: |
| 243 | + raise AnsibleConnectionFailure( |
| 244 | + f"Invalid transport type '{transport_type}' for server '{server_name}'" |
| 245 | + ) |
| 246 | + |
| 247 | + def close(self) -> None: |
| 248 | + """Terminate the persistent connection and reset state.""" |
| 249 | + display.vvv("[mcp] Closing MCP connection") |
| 250 | + |
| 251 | + self._close_client() |
| 252 | + super().close() # sets _conn_closed, _connected |
| 253 | + |
| 254 | + def _close_client(self) -> None: |
| 255 | + """Close the MCPClient if it exists and reset the reference.""" |
| 256 | + if not self._client: |
| 257 | + display.vvv("[mcp] No MCP client to close") |
| 258 | + return |
| 259 | + |
| 260 | + try: |
| 261 | + self._client.close() |
| 262 | + display.vvv("[mcp] MCP client successfully closed") |
| 263 | + except Exception as e: |
| 264 | + display.warning(f"[mcp] Error closing MCP client: {e}") |
| 265 | + finally: |
| 266 | + self._client = None |
| 267 | + |
| 268 | + @ensure_connected |
| 269 | + def list_tools(self) -> Dict[str, Any]: |
| 270 | + """Retrieves the list of tools from the MCP server.""" |
| 271 | + return self._client.list_tools() |
| 272 | + |
| 273 | + @ensure_connected |
| 274 | + def call_tool(self, tool: str, **kwargs: Any) -> Dict[str, Any]: |
| 275 | + """Calls a specific tool on the MCP server.""" |
| 276 | + return self._client.call_tool(tool, **kwargs) |
| 277 | + |
| 278 | + @ensure_connected |
| 279 | + def validate(self, tool: str, **kwargs: Any) -> None: |
| 280 | + """Validates arguments against a tool's schema (client-side validation).""" |
| 281 | + return self._client.validate(tool, **kwargs) |
| 282 | + |
| 283 | + @ensure_connected |
| 284 | + def server_info(self) -> Dict[str, Any]: |
| 285 | + """Returns the cached server information from the initialization step.""" |
| 286 | + return self._client.server_info |
0 commit comments