From 64284c16570bbd4e2f0a560cba022f812977e52d Mon Sep 17 00:00:00 2001 From: "Bradley A. Thornton" Date: Thu, 18 Jun 2026 10:02:54 -0700 Subject: [PATCH 1/3] Add MCP server for AI-agent access to AAP Gateway resources Introduces an MCP (Model Context Protocol) server that dynamically generates one tool per ansible.platform resource from existing DOCUMENTATION metadata. ~800 lines of Python expose 22 Gateway resources to AI agents with the same schema, validation, and idempotent behavior as the Ansible modules. Dual-mode tools: 'execute' calls the Gateway API directly via PlatformService; 'emit' returns equivalent Ansible task YAML for playbook authoring. Packages: - packages/sdk/ (ansible-platform-sdk): Makes the collection pip-installable under its ansible_collections namespace for non-Ansible consumers. - packages/mcp-server/ (ansible-platform-mcp): MCP server with stdio transport, compatible with Cursor, Claude Desktop, and any MCP client. Docs: - docs/11-mcp-vision.md: Value proposition and strategic rationale. - docs/12-mcp-architecture.md: Detailed design and module-level documentation. Bugfix: - Fix version_parse() fallback in registry.py when the packaging library is not installed (missing staticmethod wrapper on dynamic class method). --- docs/11-mcp-vision.md | 279 +++++++++++ docs/12-mcp-architecture.md | 441 ++++++++++++++++++ packages/mcp-server/README.md | 165 +++++++ packages/mcp-server/pyproject.toml | 63 +++ .../src/ansible_platform_mcp/__init__.py | 3 + .../src/ansible_platform_mcp/config.py | 75 +++ .../src/ansible_platform_mcp/discovery.py | 210 +++++++++ .../src/ansible_platform_mcp/emitter.py | 76 +++ .../src/ansible_platform_mcp/executor.py | 81 ++++ .../src/ansible_platform_mcp/schema.py | 185 ++++++++ .../src/ansible_platform_mcp/server.py | 174 +++++++ packages/sdk/README.md | 61 +++ packages/sdk/pyproject.toml | 44 ++ .../sdk/src/ansible_collections/__init__.py | 1 + .../ansible_collections/ansible/__init__.py | 1 + .../ansible/platform/__init__.py | 6 + .../ansible/platform/plugins | 1 + plugins/plugin_utils/platform/registry.py | 2 +- 18 files changed, 1867 insertions(+), 1 deletion(-) create mode 100644 docs/11-mcp-vision.md create mode 100644 docs/12-mcp-architecture.md create mode 100644 packages/mcp-server/README.md create mode 100644 packages/mcp-server/pyproject.toml create mode 100644 packages/mcp-server/src/ansible_platform_mcp/__init__.py create mode 100644 packages/mcp-server/src/ansible_platform_mcp/config.py create mode 100644 packages/mcp-server/src/ansible_platform_mcp/discovery.py create mode 100644 packages/mcp-server/src/ansible_platform_mcp/emitter.py create mode 100644 packages/mcp-server/src/ansible_platform_mcp/executor.py create mode 100644 packages/mcp-server/src/ansible_platform_mcp/schema.py create mode 100644 packages/mcp-server/src/ansible_platform_mcp/server.py create mode 100644 packages/sdk/README.md create mode 100644 packages/sdk/pyproject.toml create mode 100644 packages/sdk/src/ansible_collections/__init__.py create mode 100644 packages/sdk/src/ansible_collections/ansible/__init__.py create mode 100644 packages/sdk/src/ansible_collections/ansible/platform/__init__.py create mode 120000 packages/sdk/src/ansible_collections/ansible/platform/plugins diff --git a/docs/11-mcp-vision.md b/docs/11-mcp-vision.md new file mode 100644 index 00000000..efad75dd --- /dev/null +++ b/docs/11-mcp-vision.md @@ -0,0 +1,279 @@ +# One SDK, Two Surfaces: The Case for MCP-Native `ansible.platform` + +## The Thesis + +A good MCP tool and a good Ansible module are the same thing: typed inputs, deterministic +behavior, idempotent state management, structured output. The `ansible.platform` SDK +already implements all of these properties for 22 AAP Gateway resources. A thin MCP +transport layer exposes every resource to AI agents — with the same schema, the same +validation, and the same deterministic behavior — without duplicating a single line of +business logic. + +The investment is not "build an MCP server." The investment is "add a second front door +to an SDK that already exists." + +--- + +## The Problem + +Playbooks are the right tool for planned, repeatable automation. They are version-controlled, +peer-reviewed, and executed in CI/CD pipelines. Nothing in this document proposes replacing +them. + +But the way engineers interact with infrastructure is changing. The pattern is shifting from +"write a playbook, run it, read the output" to "tell an agent what you need, review what it +did." This is not a future prediction — it is happening now across every major cloud provider, +CI platform, and developer tool. + +Today, an engineer who wants to create a user, assign it to an organization, and grant a role +has two options: + +1. **Write a playbook** — 15-40 lines of YAML, test it, commit it, run it. Correct, but slow + for ad-hoc work. +2. **Hit the API directly** — curl or a script. Fast, but no idempotency, no validation, no + name-to-ID resolution. Error-prone. + +There is no third option. There is no way to say "create user jdoe in the engineering org +with the Team Admin role" and have a system that understands AAP's resource model do it +correctly, idempotently, and with full audit trail. + +The MCP ecosystem is filling this gap for databases, cloud providers, and SaaS platforms. +Enterprise automation has no serious entry. AAP can own this space. + +--- + +## Why This Codebase Is Already There + +The `ansible.platform` SDK was not designed for MCP — but its architecture satisfies every +requirement for dynamic tool generation: + +**1. Universal execution interface.** `PlatformService.execute()` takes three arguments — +an operation string (`create`, `update`, `delete`, `find`), a module name (`user`, +`organization`, `team`), and a plain Python dict of parameters. It returns a plain dict. +No Ansible runtime, no playbook context, no YAML parsing required. + +```python +service = PlatformService(GatewayConfig(base_url=url, username=user, password=pw)) +result = service.execute("create", "user", {"username": "jdoe", "email": "jdoe@example.com"}) +``` + +**2. Self-describing modules.** Every module carries a `DOCUMENTATION` YAML string that +declares field names, types, required flags, choices, defaults, and descriptions. The +`_build_argspec_from_docs()` method already parses this into a structured dict at runtime. +This is the same metadata an MCP tool needs for its `inputSchema`. + +**3. Typed dataclasses.** The `ansible_models/` layer uses standard Python dataclasses with +type hints (`str`, `Optional[bool]`, `List[str]`). These map directly to JSON Schema +types — the format MCP uses for tool definitions. `dataclasses.fields()` and +`typing.get_type_hints()` extract everything needed for schema generation. + +**4. Auto-discovery.** `APIVersionRegistry` scans the filesystem to discover all available +resources. No hardcoded module list. Add a new resource to the collection and the MCP +server picks it up on next startup. + +The SDK's layered architecture means the MCP server is a **consumer** of the existing code, +not a fork. It imports `PlatformService`, `APIVersionRegistry`, and `GatewayConfig` — the +same classes the connection plugin uses today. + +``` + ┌─────────────────────┐ + │ ansible.platform │ + │ SDK │ + │ │ + │ PlatformService │ + │ APIVersionRegistry │ + │ DynamicClassLoader │ + │ TransformMixins │ + │ GatewayConfig │ + └────────┬────────────┘ + │ + ┌────────────┼────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Ansible │ │ MCP │ │ Future │ + │ Action │ │ Server │ │ Consumers │ + │ Plugins │ │ │ │ │ + └──────────┘ └──────────┘ └──────────┘ + │ │ + ▼ ▼ + Playbooks AI Agents +``` + +--- + +## Dual-Mode Tools: Execute or Emit + +Every MCP tool supports two modes, selectable per invocation: + +| Mode | What Happens | When to Use | +|------|-------------|-------------| +| **Execute** | SDK calls the Gateway API directly, returns structured result | Ad-hoc operations, interactive troubleshooting, agent-driven workflows | +| **Emit** | Returns the equivalent `ansible.platform` task YAML | Playbook authoring, code review workflows, auditable change management | + +This is the central differentiator. The MCP server does not compete with playbooks — it +makes them easier to write and provides a direct-execution path when a playbook is +unnecessary. + +**Execute mode** — the agent creates the user now: + +```json +{ + "tool": "ansible_platform_user", + "arguments": { + "mode": "execute", + "operation": "create", + "username": "jdoe", + "email": "jdoe@example.com", + "first_name": "Jane", + "state": "present" + } +} +``` + +Returns: +```json +{ + "changed": true, + "user": {"id": 591, "username": "jdoe", "email": "jdoe@example.com", "first_name": "Jane"} +} +``` + +**Emit mode** — the agent produces the playbook task for review: + +```json +{ + "tool": "ansible_platform_user", + "arguments": { + "mode": "emit", + "operation": "create", + "username": "jdoe", + "email": "jdoe@example.com", + "first_name": "Jane", + "state": "present" + } +} +``` + +Returns: +```yaml +- name: Create user jdoe + ansible.platform.user: + username: jdoe + email: jdoe@example.com + first_name: Jane + state: present +``` + +The engineer pastes this into a playbook, commits it, and runs it through the normal +CI/CD pipeline. The agent helped author the task — the human controls when and how it +executes. + +The mode selection is explicit in every tool call. There is no ambiguity about whether +the agent is making changes or producing artifacts. + +--- + +## Surface Area Compounds + +The MCP server does not have its own module inventory. It reads the collection's +`APIVersionRegistry` at startup and generates tools for every discovered resource. + +Today, `ansible.platform` covers 22 Gateway resources: + +| Domain | Resources | +|--------|-----------| +| Identity | `user`, `organization`, `team` | +| Authentication | `authenticator`, `authenticator_map`, `authenticator_user` | +| Access Control | `role_definition`, `role_user_assignment`, `role_team_assignment` | +| Services | `service`, `service_cluster`, `service_type`, `service_key`, `service_node` | +| Platform Config | `http_port`, `route`, `ui_plugin_route`, `settings`, `feature_flag` | +| Security | `ca_certificate`, `token` | +| Applications | `application` | + +Each resource generates tools for `create`, `update`, `delete`, and `find` — 88 MCP tools +from 22 resource definitions. + +The roadmap extends this to the full AAP platform: + +| Component | Resources | Status | +|-----------|-----------|--------| +| **Gateway** | Users, orgs, teams, auth, RBAC, services, routes | 22 resources today | +| **Controller** | Job templates, inventories, credentials, projects, workflows | Planned | +| **EDA** | Rulebook activations, decision environments, event streams | Planned | + +As Controller and EDA resources are added to the `ansible.platform` collection using the +same SDK pattern (Ansible model, API model, transform mixin, action plugin), the MCP server +picks them up automatically. No MCP-specific development required per resource. + +One schema definition. One test suite. One release. Both surfaces ship together. + +--- + +## Meeting Engineers Where They Are + +The same SDK serves different interaction patterns without forcing engineers to change +how they work: + +**Playbook authors** keep writing YAML. Nothing changes. The collection works exactly as +it does today. The MCP server is a separate process that happens to share the same SDK. + +**Platform engineers** get a conversational interface to AAP. "Set up a new team called +platform-ops in the Red Hat org with viewer permissions" becomes a tool call, not a +30-line playbook for a one-time operation. + +**SREs** troubleshoot live Gateway state interactively. "List all authenticators" or "show +me the service clusters" without opening a terminal, writing a playbook, or hitting the +API with curl. + +**CI/CD pipelines** call MCP tools directly for lightweight operations that don't justify +a full playbook — rotating a token, toggling a feature flag, checking whether a service +node exists before deploying to it. + +**Internal developer platforms** integrate AAP Gateway as a tool-equipped backend for +self-service portals, chatbots, or developer copilots. The MCP server provides the +structured interface; the platform provides the UX. + +--- + +## What It Takes + +The MCP server is a consumer of the existing SDK. It requires no changes to the +`ansible.platform` collection. + +| Component | Description | Effort | +|-----------|-------------|--------| +| MCP server skeleton | stdio/SSE transport using the Python `mcp` SDK | ~200 lines | +| Tool schema generator | Parse `DOCUMENTATION` YAML into JSON Schema `inputSchema` | ~150 lines | +| Dual-mode handler | Execute via `PlatformService` or emit Ansible task YAML | ~100 lines | +| Auth configuration | Reuse `GatewayConfig` — URL, credentials, SSL, timeouts | ~50 lines | + +Total: approximately 300-500 lines of Python wrapping an SDK that already handles +authentication, session management, retries, idempotency, name-to-ID resolution, +API version detection, and error classification. + +The `PlatformService` constructor authenticates and detects the API version at init time. +For an MCP server, initialization is deferred to first tool call (lazy) or tied to server +startup (eager, requires Gateway connectivity). Both approaches work; the choice is +operational. + +--- + +## The Bigger Picture + +This positions AAP as the first enterprise automation platform with native AI agent +integration — not through a chatbot wrapper or a prompt-engineering layer, but through +a structured tool interface backed by the same deterministic engine that runs production +playbooks. + +The pattern is replicable. Any Ansible collection built with the SDK pattern +(typed dataclasses, self-describing documentation, `execute()` entry point) can generate +an MCP server. `ansible.platform` is the proof of concept; the architecture is the +template. + +The MCP ecosystem is early. Infrastructure automation tools are conspicuously absent. +The collections that show up first with well-typed, idempotent, dual-mode tools will +define how agents interact with enterprise infrastructure for the next decade. + +The SDK already exists. The tools are already defined. The only question is whether to +add the second front door. diff --git a/docs/12-mcp-architecture.md b/docs/12-mcp-architecture.md new file mode 100644 index 00000000..29d88e79 --- /dev/null +++ b/docs/12-mcp-architecture.md @@ -0,0 +1,441 @@ +# MCP Server: Design & Architecture + +This document describes the design, architecture, and implementation details +of the `ansible-platform-mcp` server — a Model Context Protocol (MCP) server +that exposes every `ansible.platform` resource as an AI-agent tool. + +For the business rationale and value proposition, see +[11-mcp-vision.md](11-mcp-vision.md). + +--- + +## Design Principles + +1. **Zero per-resource code.** No hand-written tool definitions. Every tool is + generated at startup from the collection's own `DOCUMENTATION` metadata. + Adding a module to the collection automatically adds a tool to the MCP server. + +2. **SDK reuse, not reimplementation.** The MCP server imports and calls the + same `PlatformService` that powers the Ansible action plugins. There is no + second HTTP client, no duplicated transform logic, no separate auth flow. + +3. **Dual-mode by default.** Every tool supports `execute` (call the Gateway + API now) and `emit` (return the equivalent Ansible task YAML). The mode is + explicit in every invocation — there is no ambiguity about whether the agent + is making changes or producing artifacts. + +4. **Lazy Gateway connection.** The server starts and serves tool listings and + `emit` requests without any Gateway connection. `PlatformService` is + initialized on the first `execute` call, so misconfigured credentials never + prevent tool discovery. + +--- + +## Package Structure + +The MCP server ships as two pip packages in the `packages/` directory: + +``` +packages/ +├── sdk/ # ansible-platform-sdk +│ ├── pyproject.toml +│ └── src/ansible_collections/ # Namespace package +│ └── ansible/platform/ +│ └── plugins -> (symlink) # Points to ../../../../../../plugins +│ +└── mcp-server/ # ansible-platform-mcp + ├── pyproject.toml + └── src/ansible_platform_mcp/ + ├── __init__.py # Package version + ├── config.py # Environment → GatewayConfig + ├── discovery.py # AST-based module metadata extraction + ├── schema.py # Ansible argspec → JSON Schema + ├── executor.py # PlatformService wrapper (execute mode) + ├── emitter.py # Ansible task YAML generator (emit mode) + └── server.py # MCP protocol, tool registry, dispatch +``` + +**`ansible-platform-sdk`** makes the collection pip-installable by packaging +`plugins/` under the `ansible_collections.ansible.platform` namespace via a +build-time symlink. The namespace `__init__.py` files use `pkgutil.extend_path` +so the SDK coexists with galaxy-installed collections. + +**`ansible-platform-mcp`** declares `ansible-platform-sdk` as a dependency. +`pip install ansible-platform-mcp` installs everything needed — SDK, MCP +framework, and the server itself. + +--- + +## Module Lifecycle + +### Startup: Discovery → Schema → Registry + +``` +┌─────────────────┐ ┌──────────────┐ ┌──────────────────┐ +│ discovery.py │───▶│ schema.py │───▶│ server.py │ +│ │ │ │ │ │ +│ For each .py in │ │ Convert │ │ Build MCP Tool │ +│ plugins/modules │ │ Ansible opts │ │ objects, store in │ +│ │ │ to JSON │ │ tool_registry │ +│ • ast.parse() │ │ Schema │ │ │ +│ • Extract DOCS │ │ │ │ Register │ +│ • Merge frags │ │ Add synthetic│ │ list_tools and │ +│ • Strip auth │ │ operation + │ │ call_tool │ +│ │ │ mode params │ │ handlers │ +└─────────────────┘ └──────────────┘ └──────────────────┘ +``` + +**1. Discovery (`discovery.py`)** + +Scans `plugins/modules/*.py` and extracts the `DOCUMENTATION` YAML string +from each file using `ast.parse()` — no imports, no side effects. + +For each module: +- Parses `DOCUMENTATION` via `yaml.safe_load()` +- Resolves `extends_documentation_fragment` references by loading fragment + files from `plugins/doc_fragments/` (also via AST) +- Merges fragment options with module options (module wins on conflict) +- Strips server-level auth options (`aap_hostname`, `aap_username`, etc.) + since those are configured via environment variables, not per-tool +- Returns a `ModuleInfo` dataclass: name, description, options dict, `has_state` + +**2. Schema conversion (`schema.py`)** + +Converts each module's Ansible options dict into a JSON Schema `inputSchema` +suitable for MCP tool definitions: + +| Ansible type | JSON Schema type | +|-------------|-----------------| +| `str` | `string` | +| `int` | `integer` | +| `float` | `number` | +| `bool` | `boolean` | +| `list` | `array` | +| `dict` | `object` | +| `raw` | `oneOf[string, object]` | + +Additional mappings: +- `choices` → `enum` +- `default` → `default` +- `required: true` → added to `required` array +- `elements` → `items` type for arrays +- `suboptions` → nested `properties` for objects and array items +- `aliases` → appended to the `description` string + +Two synthetic parameters are injected into every tool schema: + +- **`operation`** (required, enum): `create | update | delete | find` for + stateful resources, `update` only for singletons like `settings`. + Replaces the Ansible `state` parameter with operation semantics natural + to agent tooling. + +- **`mode`** (optional, enum, default `execute`): `execute | emit`. + Controls whether the tool calls the Gateway API or returns Ansible YAML. + +**3. Tool registry (`server.py`)** + +`_build_tool_registry()` calls discovery and schema, then constructs an +`mcp.types.Tool` for each module. The registry is a dict mapping +`ansible_platform_{resource}` → `(Tool, ModuleInfo)`. + +The low-level MCP server registers two handlers: + +- `list_tools()` → returns all `Tool` objects from the registry +- `call_tool(name, arguments)` → dispatches to executor or emitter + +### Runtime: Tool Invocation + +``` + ┌─────────────────────────────┐ + │ call_tool() │ + │ │ + │ 1. Pop operation & mode │ + │ 2. Dispatch by mode: │ + │ │ + ┌─────┴──────┐ ┌──────────┴────┐ + │ execute │ │ emit │ + │ │ │ │ + ▼ │ ▼ │ + ┌──────────────┐ │ ┌───────────────┐ │ + │ executor.py │ │ │ emitter.py │ │ + │ │ │ │ │ │ + │ Map op → │ │ │ Map op → │ │ + │ state │ │ │ state │ │ + │ │ │ │ │ │ + │ Auto-lookup │ │ │ Build task │ │ + │ for delete/ │ │ │ dict with │ │ + │ update if no │ │ │ module FQCN │ │ + │ ID provided │ │ │ │ │ + │ │ │ │ yaml.dump() │ │ + │ PlatformSvc │ │ │ │ │ + │ .execute() │ │ └───────┬───────┘ │ + └──────┬───────┘ │ │ │ + │ │ │ │ + ▼ │ ▼ │ + ┌──────────────┐ │ YAML text content │ + │ AAP Gateway │ │ │ + │ REST API │ │ │ + └──────┬───────┘ │ │ + │ │ │ + ▼ │ │ + JSON result dict │ │ + │ │ │ + └───────────┴─────────────────────────────┘ + │ + ▼ + TextContent response + back to MCP client +``` + +--- + +## Module Details + +### `config.py` — Environment to GatewayConfig + +`ServerConfig` is a frozen dataclass that reads six environment variables: + +| Variable | Purpose | Default | +|----------|---------|---------| +| `AAP_GATEWAY_URL` | Gateway base URL | (required for execute) | +| `AAP_USERNAME` | Basic auth username | `None` | +| `AAP_PASSWORD` | Basic auth password | `None` | +| `AAP_TOKEN` | OAuth / PAT token | `None` | +| `AAP_VALIDATE_CERTS` | TLS verification | `true` | +| `AAP_REQUEST_TIMEOUT` | HTTP timeout (seconds) | `10` | + +`to_gateway_config()` converts to the SDK's `GatewayConfig` dataclass — +the same configuration object used by the Ansible connection plugin. + +### `discovery.py` — Module Metadata Extraction + +Key design decisions: + +- **AST, not import**: Module files are parsed with `ast.parse()` to extract + the `DOCUMENTATION` string constant. This avoids importing modules (which + would require `ansible-core` and all its dependencies) and has no side + effects. + +- **Fragment merging**: `extends_documentation_fragment` references are + resolved by loading fragment files from `plugins/doc_fragments/`. Fragments + use class-level `DOCUMENTATION` constants, so the AST walker handles both + module-level and class-level assignments. + +- **Auth stripping**: Server-level options (`aap_hostname`, `aap_username`, + etc.) are removed from tool schemas. These are configured once via + environment variables, not passed per-tool-call. + +### `schema.py` — Argspec to JSON Schema + +The converter handles the full range of Ansible option specifications: + +- Scalar types with choices and defaults +- Lists with typed elements (including `elements: dict` with suboptions) +- Nested dicts with suboptions (recursive) +- `raw` type (accepts string or object) +- Aliases (surfaced in description text) + +The `state` option is removed from every schema and replaced with the +`operation` parameter. This bridges Ansible's declarative model (`state: +present/absent/exists`) with the imperative model natural to tool invocation +(`create/update/delete/find`). + +### `executor.py` — Gateway Operations + +`GatewayExecutor` wraps `PlatformService` with two additions: + +1. **Lazy initialization**: `PlatformService` is created on first `execute()` + call, not at server startup. This means the MCP server starts instantly and + serves `emit` and `list_tools` requests without Gateway connectivity. + +2. **Auto-lookup for delete/update**: The SDK's `_delete_resource()` and + `_update_resource()` methods require a numeric resource `id`. MCP tool + callers typically provide a `name` or `username`. The executor automatically + performs a `find` lookup before delete/update when no `id` is present, + mirroring what the Ansible action plugin does internally. + +Operation mapping: + +| MCP operation | SDK operation | Ansible state | +|--------------|--------------|---------------| +| `create` | `create` | `present` | +| `update` | `update` | `present` | +| `delete` | `delete` | `absent` | +| `find` | `find` | `exists` | + +All SDK calls are dispatched via `asyncio.to_thread()` since `PlatformService` +uses synchronous `requests.Session` internally. This keeps the async MCP +server responsive during HTTP round-trips. + +### `emitter.py` — Ansible Task YAML + +Generates valid Ansible task YAML that can be pasted directly into a playbook: + +```yaml +- name: Create user jdoe + ansible.platform.user: + username: jdoe + email: jdoe@example.com + state: present +``` + +The task name is auto-generated from the operation verb and the first +recognizable lookup field (`name`, `username`, `slug`, or `id`). + +### `server.py` — MCP Protocol Layer + +Uses the low-level `mcp.server.lowlevel.Server` rather than the `FastMCP` +decorator API. This gives full control over: + +- Dynamic tool registration (tools are built from discovery, not decorators) +- Custom schema injection (JSON Schema from the converter, not type hints) +- Error handling (SDK exceptions are caught and returned as structured + JSON error objects, not MCP protocol errors) + +Transport is stdio, compatible with Cursor, Claude Desktop, and any MCP +client that supports the stdio transport. + +--- + +## Data Flow: End-to-End Example + +**Agent request**: "Create user jdoe with email jdoe@example.com" + +``` +1. Client sends tools/call: + {name: "ansible_platform_user", + arguments: {operation: "create", mode: "execute", + username: "jdoe", email: "jdoe@example.com"}} + +2. server.py handle_call_tool(): + - Pops operation="create", mode="execute" + - Delegates to executor.execute("create", "user", {username, email}) + +3. executor.py: + - Maps "create" → state="present" + - Builds ansible_data = {username: "jdoe", email: "jdoe@example.com", + state: "present"} + - Calls PlatformService.execute("create", "user", ansible_data) + +4. PlatformService (SDK): + - Loads AnsibleUser, APIUser_v1, UserTransformMixin via DynamicClassLoader + - Forward transform: AnsibleUser → APIUser_v1 (field mapping, validation) + - HTTP POST to /api/gateway/v1/users/ with API-format payload + - Reverse transform: API response → AnsibleUser dict + - Returns {username: "jdoe", id: 1000, changed: true, ...} + +5. server.py: + - Serializes result as JSON TextContent + - Returns to MCP client +``` + +**Same request in emit mode**: + +``` +1. Client sends tools/call: + {name: "ansible_platform_user", + arguments: {operation: "create", mode: "emit", + username: "jdoe", email: "jdoe@example.com"}} + +2. server.py handle_call_tool(): + - Pops operation="create", mode="emit" + - Delegates to emitter.emit_task("user", "create", {username, email}) + +3. emitter.py: + - Maps "create" → state="present" + - Builds YAML task with FQCN ansible.platform.user + - Returns formatted YAML string + +4. server.py: + - Returns YAML as TextContent (no Gateway call made) +``` + +--- + +## SDK Package (`ansible-platform-sdk`) + +The SDK package makes the `ansible.platform` collection pip-installable under +its full namespace path (`ansible_collections.ansible.platform`). + +### How it works + +The collection's source lives at the repository root under `plugins/`. The SDK +package creates the Python namespace structure via a symlink at build time: + +``` +packages/sdk/src/ +└── ansible_collections/ __init__.py (pkgutil.extend_path) + └── ansible/ __init__.py (pkgutil.extend_path) + └── platform/ __init__.py + └── plugins → ../../../../../../plugins (symlink) +``` + +- **Editable installs** (`pip install -e`): Python follows the symlink at + import time, reading directly from the working tree. +- **Wheel builds**: Hatchling resolves the symlink and copies the full + `plugins/` tree into the wheel. +- **Coexistence**: The `pkgutil.extend_path` calls in the namespace + `__init__.py` files allow the pip-installed SDK to coexist with a + galaxy-installed collection. Python finds whichever appears first on + `sys.path`. + +### What's included + +Everything under `plugins/`: + +| Directory | Contents | +|-----------|----------| +| `plugin_utils/platform/` | `GatewayConfig`, `PlatformService`, HTTP clients, exceptions | +| `plugin_utils/manager/` | `PlatformManager` orchestrating CRUD operations | +| `plugin_utils/api/v*/` | Versioned API models and transform mixins | +| `plugin_utils/ansible_models/` | Typed dataclasses for each resource | +| `modules/` | Module stubs with embedded `DOCUMENTATION` metadata | +| `doc_fragments/` | Shared option definitions (auth, state) | +| `action/` | Action plugins (base execution logic) | +| `connection/` | Persistent connection plugin | + +--- + +## Testing with the Mock Server + +The repository includes a mock AAP Gateway server at +`tools/mock_gateway_server.py` that provides in-memory CRUD for all supported +resource types. No real AAP instance is required for development or testing. + +```bash +# Start the mock server +python tools/mock_gateway_server.py --port 9080 + +# In another terminal, run the MCP server against it +AAP_GATEWAY_URL=http://127.0.0.1:9080 \ +AAP_USERNAME=admin \ +AAP_PASSWORD=password \ +AAP_VALIDATE_CERTS=false \ +ansible-platform-mcp +``` + +The mock server accepts any `Authorization` header, stores data in memory +(resets on restart), and supports all endpoints under `/api/gateway/v{1,2}/`. + +--- + +## Scaling the Pattern + +The architecture is intentionally generic. Any Ansible collection built with +the platform SDK pattern can generate an MCP server: + +1. **Self-describing modules** — `DOCUMENTATION` YAML with typed options +2. **SDK execution interface** — `service.execute(operation, module, params)` +3. **Typed dataclasses** — Ansible models, API models, transform mixins + +As Controller and EDA resources are added to the `ansible.platform` collection, +the MCP server picks them up automatically. No per-resource MCP code is needed. + +| Component | Resources | MCP Tools | +|-----------|-----------|-----------| +| **Gateway** (today) | 22 resources | 22 tools | +| **Controller** (planned) | Job templates, inventories, credentials, projects, workflows | Auto-generated | +| **EDA** (planned) | Rulebook activations, decision environments, event streams | Auto-generated | + +The total implementation: **~800 lines of Python** to expose 22 tools, with +zero lines of per-resource boilerplate. diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md new file mode 100644 index 00000000..8708bc32 --- /dev/null +++ b/packages/mcp-server/README.md @@ -0,0 +1,165 @@ +# ansible-platform-mcp + +MCP server that exposes every [ansible.platform](https://github.com/ansible/ansible.platform) +collection resource as an AI-agent tool — with the same schema, validation, and +idempotent behavior as the Ansible modules. + +## Installation + +From PyPI (when published): + +```bash +pip install ansible-platform-mcp +``` + +This pulls in `ansible-platform-sdk` automatically — the same SDK that powers +the Ansible modules, packaged for direct use by Python applications. + +From source (development): + +```bash +git clone https://github.com/ansible/ansible.platform.git +cd ansible.platform +pip install -e packages/sdk -e packages/mcp-server +``` + +## Configuration + +Set environment variables for Gateway connectivity: + +```bash +export AAP_GATEWAY_URL=https://gateway.example.com +export AAP_USERNAME=admin +export AAP_PASSWORD=secret +# or +export AAP_TOKEN=your-token-here + +# Optional +export AAP_VALIDATE_CERTS=true # default: true +export AAP_REQUEST_TIMEOUT=10 # default: 10 seconds +``` + +Gateway credentials are only required for `execute` mode. The `emit` mode +(Ansible task YAML generation) works without a Gateway connection. + +## Usage + +### stdio (default transport) + +```bash +ansible-platform-mcp +``` + +### Cursor + +Add to `.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "ansible-platform": { + "command": "ansible-platform-mcp", + "env": { + "AAP_GATEWAY_URL": "https://gateway.example.com", + "AAP_USERNAME": "admin", + "AAP_PASSWORD": "secret" + } + } + } +} +``` + +### Claude Desktop + +Add to Claude Desktop MCP settings: + +```json +{ + "mcpServers": { + "ansible-platform": { + "command": "ansible-platform-mcp", + "env": { + "AAP_GATEWAY_URL": "https://gateway.example.com", + "AAP_USERNAME": "admin", + "AAP_PASSWORD": "secret" + } + } + } +} +``` + +## Tools + +Each `ansible.platform` module becomes an MCP tool named +`ansible_platform_{resource}` (e.g. `ansible_platform_user`, +`ansible_platform_organization`). + +Every tool accepts: + +- **`operation`** (required): `create`, `update`, `delete`, or `find` +- **`mode`** (optional, default `execute`): `execute` or `emit` +- Resource-specific parameters matching the module's argument spec + +### Execute mode + +Calls the AAP Gateway API directly and returns the structured result: + +```json +{ + "tool": "ansible_platform_user", + "arguments": { + "operation": "create", + "mode": "execute", + "username": "jdoe", + "email": "jdoe@example.com" + } +} +``` + +### Emit mode + +Returns the equivalent Ansible task YAML for use in a playbook: + +```json +{ + "tool": "ansible_platform_user", + "arguments": { + "operation": "create", + "mode": "emit", + "username": "jdoe", + "email": "jdoe@example.com" + } +} +``` + +Output: + +```yaml +- name: Create user jdoe + ansible.platform.user: + username: jdoe + email: jdoe@example.com + state: present +``` + +## Architecture + +The MCP server reuses the `ansible.platform` SDK directly — no code +duplication, no REST client reimplementation. As the collection gains modules, +the MCP server gains tools automatically. + +``` +┌──────────────┐ ┌──────────────────────┐ ┌─────────────┐ +│ AI Agent │────▶│ ansible-platform-mcp│────▶│ AAP Gateway │ +│ (Cursor, │ MCP │ │ SDK │ │ +│ Claude, │◀────│ discover / schema / │◀────│ │ +│ etc.) │ │ execute / emit │ │ │ +└──────────────┘ └──────────────────────┘ └─────────────┘ +``` + +See [docs/12-mcp-architecture.md](../../docs/12-mcp-architecture.md) for the +detailed design and architecture documentation. + +## License + +GPL-3.0-or-later — same as the `ansible.platform` collection. diff --git a/packages/mcp-server/pyproject.toml b/packages/mcp-server/pyproject.toml new file mode 100644 index 00000000..8fdedfe4 --- /dev/null +++ b/packages/mcp-server/pyproject.toml @@ -0,0 +1,63 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ansible-platform-mcp" +version = "0.1.0" +description = "MCP server that exposes Ansible Automation Platform Gateway resources as AI-agent tools" +readme = "README.md" +license = "GPL-3.0-or-later" +requires-python = ">=3.11" +authors = [ + { name = "Ansible Platform Collection Contributors" }, +] +keywords = [ + "ansible", + "aap", + "mcp", + "model-context-protocol", + "automation", + "ai-agent", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: System :: Systems Administration", + "Topic :: Software Development :: Libraries :: Application Frameworks", +] +dependencies = [ + "ansible-platform-sdk>=0.1.0", + "mcp>=1.27,<2", +] + +[project.urls] +Homepage = "https://github.com/ansible/ansible.platform" +Documentation = "https://github.com/ansible/ansible.platform/tree/main/packages/mcp-server" +Repository = "https://github.com/ansible/ansible.platform" +Issues = "https://github.com/ansible/ansible.platform/issues" +Changelog = "https://github.com/ansible/ansible.platform/blob/main/packages/mcp-server/CHANGELOG.md" + +[project.scripts] +ansible-platform-mcp = "ansible_platform_mcp.server:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/ansible_platform_mcp"] + +[tool.ruff] +line-length = 160 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true diff --git a/packages/mcp-server/src/ansible_platform_mcp/__init__.py b/packages/mcp-server/src/ansible_platform_mcp/__init__.py new file mode 100644 index 00000000..5cdd3cf9 --- /dev/null +++ b/packages/mcp-server/src/ansible_platform_mcp/__init__.py @@ -0,0 +1,3 @@ +"""MCP server for Ansible Automation Platform Gateway.""" + +__version__ = "0.1.0" diff --git a/packages/mcp-server/src/ansible_platform_mcp/config.py b/packages/mcp-server/src/ansible_platform_mcp/config.py new file mode 100644 index 00000000..9c77eb9b --- /dev/null +++ b/packages/mcp-server/src/ansible_platform_mcp/config.py @@ -0,0 +1,75 @@ +"""MCP server configuration from environment variables. + +Maps environment variables to GatewayConfig for the ansible.platform SDK. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Optional + +from ansible_collections.ansible.platform.plugins.plugin_utils.platform.config import GatewayConfig + + +@dataclass(frozen=True) +class ServerConfig: + """MCP server configuration resolved from environment variables. + + Env vars: + AAP_GATEWAY_URL Gateway base URL (required for execute mode) + AAP_USERNAME Basic-auth username + AAP_PASSWORD Basic-auth password + AAP_TOKEN OAuth / personal access token + AAP_VALIDATE_CERTS SSL verification (default: true) + AAP_REQUEST_TIMEOUT HTTP request timeout in seconds (default: 10) + """ + + gateway_url: Optional[str] + username: Optional[str] + password: Optional[str] + token: Optional[str] + validate_certs: bool + request_timeout: float + + @classmethod + def from_env(cls) -> ServerConfig: + """Build configuration from environment variables.""" + validate_raw = os.environ.get("AAP_VALIDATE_CERTS", "true").lower() + validate_certs = validate_raw not in ("false", "0", "no") + + timeout_raw = os.environ.get("AAP_REQUEST_TIMEOUT", "10") + try: + request_timeout = float(timeout_raw) + except ValueError: + request_timeout = 10.0 + + return cls( + gateway_url=os.environ.get("AAP_GATEWAY_URL"), + username=os.environ.get("AAP_USERNAME"), + password=os.environ.get("AAP_PASSWORD"), + token=os.environ.get("AAP_TOKEN"), + validate_certs=validate_certs, + request_timeout=request_timeout, + ) + + def to_gateway_config(self) -> GatewayConfig: + """Convert to a GatewayConfig instance. + + Raises: + ValueError: If gateway_url is not set. + """ + if not self.gateway_url: + raise ValueError( + "AAP_GATEWAY_URL environment variable is required for execute mode. " + "Set it to the base URL of your AAP Gateway (e.g. https://gateway.example.com)." + ) + + return GatewayConfig( + base_url=self.gateway_url, + username=self.username, + password=self.password, + oauth_token=self.token, + verify_ssl=self.validate_certs, + request_timeout=self.request_timeout, + ) diff --git a/packages/mcp-server/src/ansible_platform_mcp/discovery.py b/packages/mcp-server/src/ansible_platform_mcp/discovery.py new file mode 100644 index 00000000..8a0ad94c --- /dev/null +++ b/packages/mcp-server/src/ansible_platform_mcp/discovery.py @@ -0,0 +1,210 @@ +"""Discover ansible.platform modules and extract DOCUMENTATION metadata. + +Uses ast.parse() to extract DOCUMENTATION strings without importing the modules, +then resolves extends_documentation_fragment references to merge shared options. +""" + +from __future__ import annotations + +import ast +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + +logger = logging.getLogger(__name__) + +# Auth-related options that are server-level config, not per-tool parameters +_AUTH_OPTION_NAMES = frozenset( + { + "aap_hostname", + "aap_username", + "aap_password", + "aap_token", + "aap_validate_certs", + "aap_request_timeout", + "gateway_hostname", + "gateway_username", + "gateway_password", + "gateway_token", + "gateway_validate_certs", + "gateway_request_timeout", + "validate_certs", + "request_timeout", + "persistent_manager_idle_timeout", + } +) + + +@dataclass +class ModuleInfo: + """Parsed metadata for a single ansible.platform module.""" + + name: str + short_description: str + description: str + options: dict[str, Any] = field(default_factory=dict) + has_state: bool = False + + +def _extract_string_constant(source: str, var_name: str) -> str | None: + """Extract a module-level string constant via AST without importing.""" + try: + tree = ast.parse(source) + except SyntaxError: + return None + + for node in ast.iter_child_nodes(tree): + if not isinstance(node, ast.Assign): + continue + for target in node.targets: + if isinstance(target, ast.Name) and target.id == var_name: + if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str): + return node.value.value + return None + + +def _load_doc_fragment(fragment_name: str, doc_fragments_dir: Path) -> dict[str, Any]: + """Load options from a documentation fragment file. + + Args: + fragment_name: Fragment reference (e.g. 'ansible.platform.auth') + doc_fragments_dir: Path to plugins/doc_fragments/ + + Returns: + Options dict from the fragment, or empty dict. + """ + if "." in fragment_name: + parts = fragment_name.split(".") + frag_file = parts[-1] + else: + frag_file = fragment_name + + frag_path = doc_fragments_dir / f"{frag_file}.py" + if not frag_path.exists(): + logger.debug("Fragment file not found: %s", frag_path) + return {} + + source = frag_path.read_text(encoding="utf-8") + doc_string = _extract_string_constant(source, "DOCUMENTATION") + if not doc_string: + # Fragments use class-level DOCUMENTATION; parse the class body + try: + tree = ast.parse(source) + except SyntaxError: + return {} + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + for item in node.body: + if isinstance(item, ast.Assign): + for target in item.targets: + if isinstance(target, ast.Name) and target.id == "DOCUMENTATION": + if isinstance(item.value, ast.Constant) and isinstance(item.value.value, str): + doc_string = item.value.value + break + + if not doc_string: + return {} + + try: + parsed = yaml.safe_load(doc_string) + except yaml.YAMLError: + return {} + + return parsed.get("options", {}) if isinstance(parsed, dict) else {} + + +def discover_modules(collection_root: Path | None = None) -> dict[str, ModuleInfo]: + """Discover all ansible.platform modules and extract their metadata. + + Args: + collection_root: Path to the collection root. Auto-detected if None. + + Returns: + Dict mapping module name to ModuleInfo. + """ + if collection_root is None: + # Auto-detect: walk up from this file to find galaxy.yml + current = Path(__file__).resolve() + for parent in current.parents: + if (parent / "galaxy.yml").exists(): + collection_root = parent + break + if collection_root is None: + raise FileNotFoundError( + "Cannot auto-detect collection root. " + "Ensure the MCP server is run from within the ansible.platform repository " + "or pass collection_root explicitly." + ) + + modules_dir = collection_root / "plugins" / "modules" + doc_fragments_dir = collection_root / "plugins" / "doc_fragments" + + if not modules_dir.is_dir(): + raise FileNotFoundError(f"Modules directory not found: {modules_dir}") + + # Pre-load all doc fragments + fragment_cache: dict[str, dict[str, Any]] = {} + + modules: dict[str, ModuleInfo] = {} + + for module_path in sorted(modules_dir.glob("*.py")): + if module_path.name.startswith("_") or module_path.name == "__init__.py": + continue + + module_name = module_path.stem + source = module_path.read_text(encoding="utf-8") + doc_string = _extract_string_constant(source, "DOCUMENTATION") + + if not doc_string: + logger.debug("No DOCUMENTATION in %s, skipping", module_path.name) + continue + + try: + doc_data = yaml.safe_load(doc_string) + except yaml.YAMLError as exc: + logger.warning("Failed to parse DOCUMENTATION in %s: %s", module_path.name, exc) + continue + + if not isinstance(doc_data, dict): + continue + + # Merge doc fragment options first, then module options (module wins) + merged_options: dict[str, Any] = {} + fragments = doc_data.get("extends_documentation_fragment", []) + if isinstance(fragments, str): + fragments = [fragments] + + for frag_name in fragments: + if frag_name not in fragment_cache: + fragment_cache[frag_name] = _load_doc_fragment(frag_name, doc_fragments_dir) + merged_options.update(fragment_cache[frag_name]) + + module_options = doc_data.get("options", {}) + if isinstance(module_options, dict): + merged_options.update(module_options) + + # Strip auth options -- these are server-level config + tool_options = {k: v for k, v in merged_options.items() if k not in _AUTH_OPTION_NAMES} + + # Build description from list or string + raw_desc = doc_data.get("description", []) + if isinstance(raw_desc, list): + description = " ".join(str(d) for d in raw_desc) + else: + description = str(raw_desc) + + has_state = "state" in tool_options + + modules[module_name] = ModuleInfo( + name=module_name, + short_description=doc_data.get("short_description", f"Manage {module_name} resources"), + description=description, + options=tool_options, + has_state=has_state, + ) + + logger.info("Discovered %d modules", len(modules)) + return modules diff --git a/packages/mcp-server/src/ansible_platform_mcp/emitter.py b/packages/mcp-server/src/ansible_platform_mcp/emitter.py new file mode 100644 index 00000000..8f19e6b7 --- /dev/null +++ b/packages/mcp-server/src/ansible_platform_mcp/emitter.py @@ -0,0 +1,76 @@ +"""Generate Ansible task YAML from MCP tool arguments. + +Produces valid ansible.platform task YAML that can be pasted directly +into a playbook. Handles operation-to-state mapping and parameter formatting. +""" + +from __future__ import annotations + +from typing import Any + +import yaml + +# Operation -> Ansible state mapping +_OPERATION_TO_STATE: dict[str, str] = { + "create": "present", + "update": "present", + "delete": "absent", + "find": "exists", +} + +# Operation -> human-readable verb for task name +_OPERATION_VERBS: dict[str, str] = { + "create": "Create", + "update": "Update", + "delete": "Delete", + "find": "Check", +} + + +def emit_task(module_name: str, operation: str, params: dict[str, Any]) -> str: + """Generate Ansible task YAML for a given module operation. + + Args: + module_name: Resource module name (e.g. 'user'). + operation: Operation name ('create', 'update', 'delete', 'find'). + params: Resource parameters (mode/operation already stripped). + + Returns: + YAML string representing an Ansible task list. + """ + state = _OPERATION_TO_STATE.get(operation, "present") + verb = _OPERATION_VERBS.get(operation, operation.title()) + + # Build a readable task name from the resource and a lookup field + lookup_value = _guess_resource_label(params, module_name) + if lookup_value: + task_name = f"{verb} {module_name} {lookup_value}" + else: + task_name = f"{verb} {module_name}" + + # Build module arguments + module_args: dict[str, Any] = {} + module_args.update(params) + module_args["state"] = state + + # Remove None values to keep YAML clean + module_args = {k: v for k, v in module_args.items() if v is not None} + + task = { + "name": task_name, + f"ansible.platform.{module_name}": module_args, + } + + return yaml.dump([task], default_flow_style=False, sort_keys=False, allow_unicode=True) + + +def _guess_resource_label(params: dict[str, Any], module_name: str) -> str | None: + """Extract a human-readable label from params for the task name. + + Checks common lookup fields in priority order. + """ + for field in ("name", "username", "slug", "id"): + val = params.get(field) + if val is not None: + return str(val) + return None diff --git a/packages/mcp-server/src/ansible_platform_mcp/executor.py b/packages/mcp-server/src/ansible_platform_mcp/executor.py new file mode 100644 index 00000000..a19606df --- /dev/null +++ b/packages/mcp-server/src/ansible_platform_mcp/executor.py @@ -0,0 +1,81 @@ +"""Lazy PlatformService wrapper for executing operations against AAP Gateway. + +Delegates to PlatformService.execute() for all CRUD operations. +The ansible-platform-sdk package provides the collection imports. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from ansible_collections.ansible.platform.plugins.plugin_utils.manager.platform_manager import PlatformService + +from .config import ServerConfig + +logger = logging.getLogger(__name__) + +_OPERATION_TO_STATE: dict[str, str] = { + "create": "present", + "update": "present", + "delete": "absent", + "find": "exists", +} + + +class GatewayExecutor: + """Lazy wrapper around PlatformService for executing Gateway operations. + + The PlatformService is not created until the first execute() call, + so the MCP server can start (and serve emit-mode requests) without + a live Gateway connection. + """ + + def __init__(self, config: ServerConfig) -> None: + self._config = config + self._service: PlatformService | None = None + + def _get_service(self) -> PlatformService: + """Initialize PlatformService on first use.""" + if self._service is not None: + return self._service + + gateway_config = self._config.to_gateway_config() + self._service = PlatformService(gateway_config) + logger.info("PlatformService initialized for %s", gateway_config.base_url) + return self._service + + def execute(self, operation: str, module_name: str, params: dict[str, Any]) -> dict[str, Any]: + """Execute an operation against the Gateway. + + Args: + operation: One of 'create', 'update', 'delete', 'find'. + module_name: Resource module name (e.g. 'user'). + params: Resource parameters as a plain dict. + + Returns: + Result dict from PlatformService.execute(). + + Raises: + ValueError: If operation is unknown or gateway URL is not configured. + """ + service = self._get_service() + + state = _OPERATION_TO_STATE.get(operation) + if state is None: + raise ValueError(f"Unknown operation: {operation!r}. Must be one of: {list(_OPERATION_TO_STATE)}") + + ansible_data = dict(params) + ansible_data["state"] = state + + # Delete and update require the resource ID. If the caller didn't + # supply one, look up the resource first so the SDK can proceed. + if operation in ("delete", "update") and not ansible_data.get("id"): + existing = service.execute("find", module_name, dict(ansible_data)) + resource_id = existing.get("id") + if not resource_id: + raise ValueError(f"Cannot {operation}: resource not found for lookup") + ansible_data["id"] = resource_id + + logger.info("Executing %s on %s", operation, module_name) + return service.execute(operation, module_name, ansible_data) diff --git a/packages/mcp-server/src/ansible_platform_mcp/schema.py b/packages/mcp-server/src/ansible_platform_mcp/schema.py new file mode 100644 index 00000000..de290dbd --- /dev/null +++ b/packages/mcp-server/src/ansible_platform_mcp/schema.py @@ -0,0 +1,185 @@ +"""Convert Ansible module argspec options to JSON Schema inputSchema. + +Maps Ansible DOCUMENTATION option types to JSON Schema types and handles +choices, defaults, required fields, list elements, suboptions, and aliases. +""" + +from __future__ import annotations + +from typing import Any + +# Ansible type string -> JSON Schema type +_TYPE_MAP: dict[str, str] = { + "str": "string", + "int": "integer", + "float": "number", + "bool": "boolean", + "list": "array", + "dict": "object", + "path": "string", + "raw": "string", +} + + +def _option_to_json_schema(option_spec: dict[str, Any]) -> dict[str, Any]: + """Convert a single Ansible option spec to a JSON Schema property. + + Args: + option_spec: Ansible option dict (type, description, choices, default, etc.) + + Returns: + JSON Schema property definition. + """ + ansible_type = option_spec.get("type", "str") + schema: dict[str, Any] = {} + + # Handle 'raw' as oneOf string or object + if ansible_type == "raw": + schema["oneOf"] = [{"type": "string"}, {"type": "object"}] + else: + json_type = _TYPE_MAP.get(ansible_type, "string") + schema["type"] = json_type + + # Description + raw_desc = option_spec.get("description", []) + if isinstance(raw_desc, list): + desc = " ".join(str(d) for d in raw_desc) + else: + desc = str(raw_desc) + + # Append aliases to description + aliases = option_spec.get("aliases", []) + if aliases: + desc += f" (aliases: {', '.join(aliases)})" + + if desc: + schema["description"] = desc + + # Choices -> enum + choices = option_spec.get("choices") + if choices: + schema["enum"] = list(choices) + + # Default value + if "default" in option_spec: + schema["default"] = option_spec["default"] + + # List items + if ansible_type == "list": + elements = option_spec.get("elements", "str") + if elements == "dict": + suboptions = option_spec.get("suboptions", {}) + if suboptions: + schema["items"] = _options_to_json_schema(suboptions) + else: + schema["items"] = {"type": "object"} + else: + schema["items"] = {"type": _TYPE_MAP.get(elements, "string")} + + # Dict suboptions (nested object) + if ansible_type == "dict": + suboptions = option_spec.get("suboptions", {}) + if suboptions: + nested = _options_to_json_schema(suboptions) + schema["properties"] = nested.get("properties", {}) + if nested.get("required"): + schema["required"] = nested["required"] + + return schema + + +def _options_to_json_schema(options: dict[str, Any]) -> dict[str, Any]: + """Convert an Ansible options dict to a JSON Schema object. + + Args: + options: Dict of option_name -> option_spec. + + Returns: + JSON Schema object with properties and required list. + """ + properties: dict[str, Any] = {} + required: list[str] = [] + + for name, spec in options.items(): + if not isinstance(spec, dict): + continue + properties[name] = _option_to_json_schema(spec) + if spec.get("required"): + required.append(name) + + schema: dict[str, Any] = { + "type": "object", + "properties": properties, + } + if required: + schema["required"] = required + + return schema + + +def build_tool_schema( + module_name: str, + options: dict[str, Any], + has_state: bool, +) -> dict[str, Any]: + """Build a complete MCP tool inputSchema for a module. + + Adds synthetic 'operation' and 'mode' parameters alongside + the module's own resource options. + + Args: + module_name: Module name (e.g. 'user'). + options: Module options dict (auth options already stripped). + has_state: Whether the module has a state option. + + Returns: + Complete JSON Schema suitable for MCP Tool.inputSchema. + """ + base_schema = _options_to_json_schema(options) + properties = base_schema.get("properties", {}) + required = list(base_schema.get("required", [])) + + # Remove 'state' from properties -- operation replaces it + properties.pop("state", None) + if "state" in required: + required.remove("state") + + # Add operation parameter + if has_state: + operations = ["create", "update", "delete", "find"] + else: + operations = ["update"] + + properties["operation"] = { + "type": "string", + "enum": operations, + "description": ( + "Operation to perform. " + "'create' ensures the resource exists (idempotent). " + "'update' modifies an existing resource. " + "'delete' removes the resource. " + "'find' returns the current state without changes." + ), + } + + # Add mode parameter + properties["mode"] = { + "type": "string", + "enum": ["execute", "emit"], + "default": "execute", + "description": ( + "Execution mode. " + "'execute' calls the AAP Gateway API directly and returns the result. " + "'emit' returns the equivalent ansible.platform task YAML for use in a playbook." + ), + } + + # operation is required, mode is not (defaults to execute) + if "operation" not in required: + required.append("operation") + + return { + "type": "object", + "properties": properties, + "required": required, + } diff --git a/packages/mcp-server/src/ansible_platform_mcp/server.py b/packages/mcp-server/src/ansible_platform_mcp/server.py new file mode 100644 index 00000000..e05f1f8f --- /dev/null +++ b/packages/mcp-server/src/ansible_platform_mcp/server.py @@ -0,0 +1,174 @@ +"""MCP server for ansible.platform. + +Dynamically generates one tool per collection resource from DOCUMENTATION +metadata. Each tool supports dual-mode operation: 'execute' calls the +AAP Gateway API directly; 'emit' returns the equivalent Ansible task YAML. + +Uses the low-level MCP server for full control over tool listing and dispatch. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from pathlib import Path +from typing import Any + +import mcp.server.stdio +import mcp.types as types +from mcp.server.lowlevel import NotificationOptions, Server +from mcp.server.models import InitializationOptions + +from . import __version__ +from .config import ServerConfig +from .discovery import ModuleInfo, discover_modules +from .emitter import emit_task +from .executor import GatewayExecutor +from .schema import build_tool_schema + +logger = logging.getLogger(__name__) + +TOOL_PREFIX = "ansible_platform_" + + +def _build_tool_registry( + collection_root: Path | None = None, +) -> dict[str, tuple[types.Tool, ModuleInfo]]: + """Discover modules and build MCP Tool objects with JSON Schema. + + Returns: + Dict mapping tool name -> (Tool definition, ModuleInfo). + """ + modules = discover_modules(collection_root) + registry: dict[str, tuple[types.Tool, ModuleInfo]] = {} + + for mod in modules.values(): + tool_name = f"{TOOL_PREFIX}{mod.name}" + input_schema = build_tool_schema(mod.name, mod.options, mod.has_state) + + description = mod.short_description + if mod.has_state: + description += " Operations: create, update, delete, find." + else: + description += " Operation: update." + description += " Set mode to 'emit' to return Ansible task YAML instead of executing." + + tool = types.Tool( + name=tool_name, + description=description, + inputSchema=input_schema, + ) + registry[tool_name] = (tool, mod) + + logger.info("Built %d MCP tools from collection modules", len(registry)) + return registry + + +def create_server(collection_root: Path | None = None) -> tuple[Server, dict[str, tuple[types.Tool, ModuleInfo]]]: + """Create and configure the MCP server. + + Args: + collection_root: Path to the ansible.platform collection root. + + Returns: + Tuple of (Server, tool_registry). + """ + server = Server("ansible-platform") + config = ServerConfig.from_env() + executor = GatewayExecutor(config) + tool_registry = _build_tool_registry(collection_root) + + @server.list_tools() + async def handle_list_tools() -> list[types.Tool]: + return [tool for tool, _mod in tool_registry.values()] + + @server.call_tool() + async def handle_call_tool( + name: str, arguments: dict[str, Any] | None + ) -> list[types.TextContent]: + if name not in tool_registry: + raise ValueError(f"Unknown tool: {name}") + + arguments = dict(arguments or {}) + _tool, mod = tool_registry[name] + + operation = arguments.pop("operation", None) + if not operation: + raise ValueError("'operation' argument is required") + + mode = arguments.pop("mode", "execute") + + if mode == "emit": + yaml_text = emit_task(mod.name, operation, arguments) + return [types.TextContent(type="text", text=yaml_text)] + + elif mode == "execute": + if not config.gateway_url: + raise ValueError( + "Cannot execute: AAP_GATEWAY_URL environment variable is not set. " + "Use mode='emit' to generate Ansible task YAML without a Gateway connection." + ) + try: + result = await asyncio.to_thread( + executor.execute, operation, mod.name, arguments + ) + except Exception as exc: + error_result = { + "error": str(exc), + "error_type": type(exc).__name__, + "operation": operation, + "resource": mod.name, + } + return [types.TextContent( + type="text", + text=json.dumps(error_result, indent=2), + )] + + return [types.TextContent( + type="text", + text=json.dumps(result, indent=2, default=str), + )] + + else: + raise ValueError(f"Unknown mode: {mode!r}. Must be 'execute' or 'emit'.") + + return server, tool_registry + + +async def run(collection_root: Path | None = None) -> None: + """Run the MCP server over stdio.""" + server, tool_registry = create_server(collection_root) + + logger.info( + "Starting ansible-platform MCP server v%s with %d tools", + __version__, + len(tool_registry), + ) + + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="ansible-platform", + server_version=__version__, + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +def main() -> None: + """Entry point for the ansible-platform-mcp command.""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", + ) + asyncio.run(run()) + + +if __name__ == "__main__": + main() diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 00000000..10784780 --- /dev/null +++ b/packages/sdk/README.md @@ -0,0 +1,61 @@ +# ansible-platform-sdk + +The engine behind the [ansible.platform](https://github.com/ansible/ansible.platform) +Ansible collection, packaged for direct use by Python applications. + +This package makes `ansible_collections.ansible.platform` importable via pip, +so non-Ansible consumers — MCP servers, CLIs, custom integrations — can use the +same SDK that powers the Ansible modules: entity discovery, typed data models, +API versioning, and idempotent CRUD operations against AAP Gateway. + +## Installation + +```bash +pip install ansible-platform-sdk +``` + +## What's included + +Everything under the collection's `plugins/` directory: + +- **`plugin_utils/platform/`** — `GatewayConfig`, `PlatformService`, HTTP clients +- **`plugin_utils/manager/`** — `PlatformManager` orchestrating CRUD operations +- **`plugin_utils/api/`** — Versioned API models and transform mixins +- **`plugin_utils/ansible_models/`** — Typed dataclasses for each resource +- **`modules/`** — Module stubs with embedded `DOCUMENTATION` metadata +- **`doc_fragments/`** — Shared option definitions + +## Usage + +```python +from ansible_collections.ansible.platform.plugins.plugin_utils.platform.config import GatewayConfig +from ansible_collections.ansible.platform.plugins.plugin_utils.manager.platform_manager import PlatformService + +config = GatewayConfig( + base_url="https://gateway.example.com", + username="admin", + password="secret", +) +service = PlatformService(config) +result = service.execute("create", "user", { + "username": "jdoe", + "email": "jdoe@example.com", + "state": "present", +}) +``` + +## Relationship to the Ansible collection + +This package and `ansible-galaxy collection install ansible.platform` install +the same code. Use whichever distribution mechanism fits your workflow: + +| Method | Best for | +|--------|----------| +| `pip install ansible-platform-sdk` | Python apps, MCP servers, CI pipelines | +| `ansible-galaxy collection install ansible.platform` | Ansible playbooks, roles, EEs | + +Both can coexist. Python resolves whichever appears first on `sys.path`. + +## License + +GPL-3.0-or-later — same as the `ansible.platform` collection. diff --git a/packages/sdk/pyproject.toml b/packages/sdk/pyproject.toml new file mode 100644 index 00000000..3429ea68 --- /dev/null +++ b/packages/sdk/pyproject.toml @@ -0,0 +1,44 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ansible-platform-sdk" +version = "0.1.0" +description = "Ansible Automation Platform Gateway SDK — the engine behind the ansible.platform collection" +readme = "README.md" +license = "GPL-3.0-or-later" +requires-python = ">=3.11" +authors = [ + { name = "Ansible Platform Collection Contributors" }, +] +keywords = [ + "ansible", + "aap", + "gateway", + "automation", + "sdk", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: System :: Systems Administration", +] +dependencies = [ + "requests", + "pyyaml", +] + +[project.urls] +Homepage = "https://github.com/ansible/ansible.platform" +Repository = "https://github.com/ansible/ansible.platform" +Issues = "https://github.com/ansible/ansible.platform/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src/ansible_collections"] diff --git a/packages/sdk/src/ansible_collections/__init__.py b/packages/sdk/src/ansible_collections/__init__.py new file mode 100644 index 00000000..8db66d3d --- /dev/null +++ b/packages/sdk/src/ansible_collections/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/packages/sdk/src/ansible_collections/ansible/__init__.py b/packages/sdk/src/ansible_collections/ansible/__init__.py new file mode 100644 index 00000000..8db66d3d --- /dev/null +++ b/packages/sdk/src/ansible_collections/ansible/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/packages/sdk/src/ansible_collections/ansible/platform/__init__.py b/packages/sdk/src/ansible_collections/ansible/platform/__init__.py new file mode 100644 index 00000000..9303f9e9 --- /dev/null +++ b/packages/sdk/src/ansible_collections/ansible/platform/__init__.py @@ -0,0 +1,6 @@ +"""Ansible Automation Platform Gateway SDK. + +This package makes the ansible.platform collection importable via pip, +allowing non-Ansible consumers (MCP servers, CLIs, etc.) to use the +same SDK that powers the Ansible modules. +""" diff --git a/packages/sdk/src/ansible_collections/ansible/platform/plugins b/packages/sdk/src/ansible_collections/ansible/platform/plugins new file mode 120000 index 00000000..d693ddcb --- /dev/null +++ b/packages/sdk/src/ansible_collections/ansible/platform/plugins @@ -0,0 +1 @@ +../../../../../../plugins \ No newline at end of file diff --git a/plugins/plugin_utils/platform/registry.py b/plugins/plugin_utils/platform/registry.py index bdd4a9e9..b4ab53fa 100644 --- a/plugins/plugin_utils/platform/registry.py +++ b/plugins/plugin_utils/platform/registry.py @@ -40,7 +40,7 @@ def __gt__(self, other): def version_parse(v: str): return SimpleVersion(v) - version = type("version", (), {"parse": version_parse})() + version = type("version", (), {"parse": staticmethod(version_parse)})() class APIVersionRegistry: From d8f47dc2a5e97db71296609c573d1c2961287b0c Mon Sep 17 00:00:00 2001 From: "Bradley A. Thornton" Date: Thu, 18 Jun 2026 10:07:33 -0700 Subject: [PATCH 2/3] Fix ruff format and pylint consider-using-from-import in server.py --- .../src/ansible_platform_mcp/server.py | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/mcp-server/src/ansible_platform_mcp/server.py b/packages/mcp-server/src/ansible_platform_mcp/server.py index e05f1f8f..dfdd48c3 100644 --- a/packages/mcp-server/src/ansible_platform_mcp/server.py +++ b/packages/mcp-server/src/ansible_platform_mcp/server.py @@ -16,7 +16,7 @@ from typing import Any import mcp.server.stdio -import mcp.types as types +from mcp import types from mcp.server.lowlevel import NotificationOptions, Server from mcp.server.models import InitializationOptions @@ -84,9 +84,7 @@ async def handle_list_tools() -> list[types.Tool]: return [tool for tool, _mod in tool_registry.values()] @server.call_tool() - async def handle_call_tool( - name: str, arguments: dict[str, Any] | None - ) -> list[types.TextContent]: + async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.TextContent]: if name not in tool_registry: raise ValueError(f"Unknown tool: {name}") @@ -110,9 +108,7 @@ async def handle_call_tool( "Use mode='emit' to generate Ansible task YAML without a Gateway connection." ) try: - result = await asyncio.to_thread( - executor.execute, operation, mod.name, arguments - ) + result = await asyncio.to_thread(executor.execute, operation, mod.name, arguments) except Exception as exc: error_result = { "error": str(exc), @@ -120,15 +116,19 @@ async def handle_call_tool( "operation": operation, "resource": mod.name, } - return [types.TextContent( + return [ + types.TextContent( + type="text", + text=json.dumps(error_result, indent=2), + ) + ] + + return [ + types.TextContent( type="text", - text=json.dumps(error_result, indent=2), - )] - - return [types.TextContent( - type="text", - text=json.dumps(result, indent=2, default=str), - )] + text=json.dumps(result, indent=2, default=str), + ) + ] else: raise ValueError(f"Unknown mode: {mode!r}. Must be 'execute' or 'emit'.") From 60f740cc320c44fe3f4b14d5778ba91142d7e7f6 Mon Sep 17 00:00:00 2001 From: "Bradley A. Thornton" Date: Thu, 18 Jun 2026 10:10:35 -0700 Subject: [PATCH 3/3] Add PyPI distribution and install matrix to architecture doc Explains how both packages are published to PyPI, how the SDK is embedded in the MCP server's dependency chain, and the three install paths (PyPI production, editable dev, Galaxy for Ansible users). --- docs/12-mcp-architecture.md | 48 ++++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/docs/12-mcp-architecture.md b/docs/12-mcp-architecture.md index 29d88e79..a15f7f05 100644 --- a/docs/12-mcp-architecture.md +++ b/docs/12-mcp-architecture.md @@ -60,9 +60,51 @@ packages/ build-time symlink. The namespace `__init__.py` files use `pkgutil.extend_path` so the SDK coexists with galaxy-installed collections. -**`ansible-platform-mcp`** declares `ansible-platform-sdk` as a dependency. -`pip install ansible-platform-mcp` installs everything needed — SDK, MCP -framework, and the server itself. +**`ansible-platform-mcp`** declares `ansible-platform-sdk>=0.1.0` as a pip +dependency. `pip install ansible-platform-mcp` installs everything needed — +the full SDK, the MCP framework, and the server itself. + +### PyPI Distribution + +Both packages are published to PyPI independently: + +``` +ansible-platform-sdk → pip install ansible-platform-sdk +ansible-platform-mcp → pip install ansible-platform-mcp (pulls in sdk automatically) +``` + +The SDK is embedded in the MCP server's dependency chain — not vendored or +copied, but declared as a standard pip dependency. When pip resolves +`ansible-platform-mcp`, it pulls `ansible-platform-sdk` from PyPI, which +contains the full `ansible_collections.ansible.platform.plugins` tree built +from the same repository. The end user runs one command: + +```bash +pip install ansible-platform-mcp +``` + +This installs: +1. `ansible-platform-sdk` — the Gateway SDK (PlatformService, data models, + transforms, module DOCUMENTATION metadata) +2. `mcp` — the MCP Python framework (protocol handling, stdio transport) +3. `requests` + `pyyaml` — transitive dependencies from the SDK +4. `ansible-platform-mcp` — the server itself (~800 lines) + +No `ansible-galaxy`, no `ansible-core`, no Ansible runtime required. +The SDK is a pure Python package that happens to also be usable as an +Ansible collection when installed via Galaxy. + +### Development vs. Production Install + +| Scenario | Command | What happens | +|----------|---------|-------------| +| **Production** (PyPI) | `pip install ansible-platform-mcp` | Pulls SDK + MCP from PyPI | +| **Development** (monorepo) | `pip install -e packages/sdk -e packages/mcp-server` | Editable install, symlinks to working tree | +| **Ansible users** | `ansible-galaxy collection install ansible.platform` | Standard Galaxy install (MCP not included) | + +The same source code ships through two channels. Ansible users get the +collection via Galaxy. Python/AI consumers get the SDK + MCP server via pip. +Both are built from the same `plugins/` directory in the same repository. ---