# How it Works

## The 30-second version

Rocannon is a Python program that, at startup, walks the Ansible module catalog, reads each module's schema via `ansible-doc -j`, and turns every module into a typed Python function. It hands those functions to FastMCP, which exposes them as MCP tools over stdio or HTTP.

Source | What Rocannon reads | Tool name
--- | --- | ---
Ansible collection | `ansible-doc -j <module>` | `ansible.builtin.copy`, `community.docker.docker_container`, …

No bundled LLM. No opinionated provider matrix. No inventory manager. Rocannon's job is the glue between "what ansible-doc ships" and "what an MCP client can call."

## End-to-end: one tool call

Concrete example: the agent calls `ansible.builtin.command(target="webhosts", cmd="systemctl restart nginx")`.

    MCP client (Claude Code, Cursor, mcphost)
        │ stdio JSON-RPC  tools/call
        ▼
    FastMCP server  (src/rocannon/server.py)
        │ middleware stack:
        │   1. correlation ID assigned
        │   2. structured log emitted
        │   3. audit record opened + response-size limit applied
        │   4. concurrency semaphores acquired (global + per-host)
        │   5. tool handler runs
        │   6. audit record completed (latency, status, redacted args)
        ▼
    Typed tool function  (built at startup from ansible-doc schema)
        │ subprocess
        ▼
    ansible-runner  →  ansible-playbook  →  SSH  →  target host

The result bubbles back up: ansible-runner parses the JSON event stream, the executor returns a structured dict, middleware records the audit entry, FastMCP serialises to MCP's tool-result format, the client gets structured JSON.

## Module reflection

At startup, `register_ansible_modules` runs `ansible-doc -j <names...>` in batches of 256 modules (to amortise the 200ms Python startup cost), then for each module:

- Parses parameter names, types, required flags, choices, and descriptions
- Builds a typed Python function with a dynamic `inspect.Signature` matching the module's documented parameters
- Adds a `target` parameter (the inventory host or group pattern)
- Injects `check` and `diff` parameters for modules that declare support in their ansible-doc attributes
- Attaches safety hints: read-only for `_info`/`_facts` modules; destructive + open-world for `command`/`shell`/`raw`
- Tags the tool by collection and namespace for client-side filtering

Parameter name collisions. Some module parameters collide with Python keywords (`from`, `type`) or with reserved slots (`target`). The registration layer mangles those (e.g. `type` → `param_type`) and de-mangles on the way out. The mangled name is what the MCP schema exposes.

## Middleware stack

The server composes seven middleware layers, innermost to outermost:

Layer | What it does
--- | ---
Audit | Writes a per-call JSON record (tool, target, latency, status, redacted args) to the `rocannon.audit` logger. Feeds the in-memory run history.
Concurrency | Two semaphores: a global cap and a per-host cap. Prevents fan-out floods on both the server and individual targets.
Ping | HTTP transport only. Keepalive for long-lived sessions.
Structured logging | Optional JSON logging (gated by `ROCANNON_LOG_FORMAT=json`).
Response limit | Caps tool response size before it reaches the MCP client. Ansible command/shell stdout can be enormous; an LLM context blown by a single dump is a real failure mode.
Retry | Re-runs on transient transport-level exceptions only. Module-level Ansible failures return a structured dict and are not retried.
Error handling | Outermost layer. Catches and formats exceptions consistently so the client always receives a well-formed response.

OpenTelemetry tracing is optional. If `opentelemetry-api` is importable, the server emits a `tools/call <name>` span per call with module name, target, and latency. If it isn't, the import fails silently. No runtime cost when disabled.

## Playbook save and replay

A saved session is written as a standard Ansible playbook: a list of plays, one per recorded step, with the play's `hosts:` set to the step's target. The file runs directly under `ansible-playbook` with no Rocannon in the loop.

    # Rocannon session: restart-stack
    # Restart the web tier and verify

    - name: ansible.builtin.command on webhosts
      hosts: webhosts
      gather_facts: false
      tasks:
      - name: ansible.builtin.command
        ansible.builtin.command:
          cmd: systemctl restart nginx

    - name: ansible.builtin.wait_for on webhosts
      hosts: webhosts
      gather_facts: false
      tasks:
      - name: ansible.builtin.wait_for
        ansible.builtin.wait_for:
          host: 127.0.0.1
          port: 80

Two server tools handle recording:

- `save_playbook(name, description, steps)`: writes a named playbook from explicit steps
- `commit_session(name, description, since)`: materialises the current session's successful calls from the in-memory ring buffer

On the next server start, saved playbooks are parsed back and registered as MCP prompts named `playbook_<name>`. Hand-edited Ansible playbooks load the same way: any task whose module key is a registered tool becomes a step.

## The REPL

The REPL constructs the same FastMCP server as `mcp serve` and drives it in-process via `fastmcp.Client` without a JSON-RPC transport. The middleware stack, audit log, and run history all apply the same way.

This means: whatever tools an operator can call from the REPL are exactly the tools an LLM sees through MCP. There is no REPL-only surface.

`.ai` mode uses LiteLLM, so the backend is operator-chosen via `ROCANNON_AI_MODEL` (Ollama, OpenAI, Anthropic, watsonx, vLLM). There is no opinionated default.

## What is deliberately out of scope

Not in scope | Why
--- | ---
Bundled LLM | Rocannon is an MCP server. Pick your own client.
Opinionated model list | LiteLLM handles backend selection; Rocannon stays neutral.
Inventory management UI | Ansible inventories are YAML/INI files, same as always.
Policy engine | Authorisation is the MCP client's responsibility.
Terraform / Helm / Salt | Those tools have dedicated MCP servers and different shapes. Rocannon stays narrow: every Ansible module, nothing else.
OpenAPI-to-MCP | FastMCP already does this. Rocannon's contribution is the typed-tools-from-Ansible-catalog path.

## Code map

    src/rocannon/
    ├── cli.py          Typer entrypoint + FQCN dispatch
    ├── config.py       Pydantic Config model + YAML profile loader
    ├── profiles.py     Profile discovery, registry, RuntimeContext
    ├── ansible.py      Module reflection + typed tool registration
    ├── server.py       FastMCP server + middleware wiring
    ├── schema.py       ansible-doc parsing, module spec expansion
    ├── executor.py     ansible-runner Python-API wrapper
    ├── playbook.py     Session → Ansible playbook save/load
    ├── repl.py         Operator REPL + optional .ai mode
    ├── inventory.py    ansible-inventory subprocess wrapper
    ├── history.py      In-memory ring buffer (feeds save/replay)
    ├── correlation.py  Per-call correlation IDs
    └── redaction.py    Secrets redaction in audit records