Skip to content

Commit abad8a5

Browse files
Add the mcp connection plugin (#9)
* add MCPClient class * minor fix * modified based on feedback * modified based on feedback * restructure validate, other minox fixes * move tests to unit/plugins/module_utils * linter fix * Add mcp connection plugin Signed-off-by: Alina Buzachis <[email protected]> * Update: mcp connection plugin Signed-off-by: Alina Buzachis <[email protected]> * Update Signed-off-by: Alina Buzachis <[email protected]> * Fixes Signed-off-by: Alina Buzachis <[email protected]> * Add ansible.utils dependency Signed-off-by: Alina Buzachis <[email protected]> * Reaname Signed-off-by: Alina Buzachis <[email protected]> * Minor fix Signed-off-by: Alina Buzachis <[email protected]> * Apply suggestions Signed-off-by: Alina Buzachis <[email protected]> * Rebase and remove mcp_ prefix from parameters Signed-off-by: Alina Buzachis <[email protected]> --------- Signed-off-by: Alina Buzachis <[email protected]> Co-authored-by: Mandar Kulkarni <[email protected]>
1 parent 3f8d4d6 commit abad8a5

File tree

4 files changed

+586
-1
lines changed

4 files changed

+586
-1
lines changed

plugins/connection/mcp.py

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
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

Comments
 (0)