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..a15f7f05 --- /dev/null +++ b/docs/12-mcp-architecture.md @@ -0,0 +1,483 @@ +# 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>=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. + +--- + +## 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..dfdd48c3 --- /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 +from mcp import 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: