Skip to content

Muppy MCP — what an agent can do, and where it stops

Muppy ships an MCP server. An AI agent connected to it (Claude on claude.ai, Claude Desktop, Claude Code, or any MCP client) reads the same records you see in the GUI, runs a whitelist of methods on them, and is told to wait for a human on everything else. This page says exactly where the line runs.

There are two servers, and this page is about the first:

Server Provider code Who it is for What it sees
Muppy (Infrastructure) muppy Muppy administrators Hosts, PostgreSQL clusters, Kubernetes clusters and Releases, networks, App Servers, the task queue
Manganese manganese Tenants of a Manganese instance Their own App Servers only, through the mgx_* methods — see Setting up Manganese

Connecting

The server is part of the Muppy web process: it answers at the Muppy URL, and it is down whenever the web server is. The endpoint of the infrastructure server is:

https://<your Muppy>/mcp/muppy/local

Authentication is OAuth 2.1 with PKCE. On the first request the client sends you to the Muppy login, then to a consent screen that lists the scopes it asks for. Approve only what the agent needs:

Scope What it unlocks Approve it
mcp:discovery the list of domains, models, fields and methods always
mcp:read searching and reading records always
mcp:source reading the Python source of an exposed method for development
mcp:execute calling an exposed method — subject to the rules below when the agent must act
mcp:write creating, modifying, deleting records — subject to the rules below rarely; see Data

A scope is the ceiling. What the agent can actually do under it is decided per model and per method, by the rules of engagement.

What the server exposes

Everything goes through fourteen generic tools: discovery (list_domains, list_models, get_model_metadata, server_info), reading (search_objects, read_objects, search_read_objects, and read_huge_content / grep_huge_content for long texts such as pod logs or a Helm console output), source (get_method_source_code), writing (write_objects, create_object, delete_objects) and call_method.

Records are grouped in domains, one per area of Muppy:

Domain Contents
muppy-core hosts, their users, partitions, firewall rules, network interfaces, systemd units, software releases, the LXC images and profiles a container is created from, the OVH accounts and their catalog
muppy-application App Servers and App Definitions, their environment variables, upgrades, install tasks, Code Servers
muppy-k8s clusters, packages, profiles, Releases, dashboards, config journals, the Kubernetes objects Muppy syncs (pods, deployments, services, secrets…)
muppy-postgresql PostgreSQL clusters and databases
muppy-network DNS domains and records, Traefik
imq the task queue: messages, their processing log, workers, queues
common-tools vaults, S3 buckets, smart configs
mcp-permissions the agent's own pending permission requests
muppy-meta Odoo introspection: models, fields, views, menus, modules

An agent starts with list_domains, then list_models on a domain, then get_model_metadata on a model: the metadata carries the description of every exposed field and method, written for agents. Secret fields (passwords, tokens, keys, kubeconfigs) are declared as such and never returned, whatever the scope.

The rules of engagement

Three rules decide what happens when an agent tries to act. They are the same on every domain of the infrastructure server.

Data is read-only

Records can be searched and read. Writing, creating and deleting are closed on every model. When you want an agent to change data, you open the model yourself, for as long as you need, from Settings ▸ MCP ▸ Exposed Models — and even then the write goes through a permission request you approve. The profiles never grant a write silently.

Methods run freely on dev and test, and ask everywhere else

Every record that belongs to an environment carries a qualifier (Development, Test, Staging, Production, Infrastructure…), and each qualifier has a category. A method the agent calls on a record is judged on that category:

Category of the record Decision
dev, test executed at once
prod, staging, demo, infra, decom, tmpl a permission request is created; nothing runs until you approve it
unknown — the record has no qualifier, or none can be derived (an IMQ message, a Kubernetes object that belongs to no Release) a permission request, the same way

A record inherits the qualifier of what it belongs to: a pod is judged on its Release, a systemd unit on its host, a Vault link on its App Server.

Hosts and OVH accounts are the exception, because neither is a dev or prod thing by itself: each carries an AI Managed switch instead. Grant for Duration on the form opens it for a chosen time (the grant records who opened it and until when) and Revoke now closes it. While the switch is on or the grant runs, every method on that host or account executes at once; otherwise each call is a permission request. The _agent_* methods (run a command, read or write a file over SSH) follow that switch, and refuse outright on a host that is not in the managed state.

Every exec is journaled

A method whose name ends in _exec — running a command on a host, on an App Server, or inside a Kubernetes pod — is treated apart, because an exec is not one operation but whatever the agent writes. Every call writes a permission request, whether or not anyone had to approve it: the command, its output and its exit code, on the record it ran against. A call that ran at once is marked as a journal entry and names no approver; a call that was held is the same record, with your decision on it. There is one screen for both, under MCP ▸ Permission Requests, and the Journal entries filter separates them.

A password passed on a command line is masked on that screen. The output is kept up to 512 KiB per call — above that the end of it survives, which is where a command says how it went.

Journal entries are deleted after a while (Settings ▸ MCP, 180 days by default); requests you decided are kept.

A permission request is approved by a human, in Muppy

When a call is held, the agent receives a pending_approval answer with the request id, the URL of the request, and the reason. You receive a notification with an Open Request button; the request is also listed under MCP ▸ Permission Requests. It shows the record, the method, and its arguments decoded in words, so that what you approve is what will run. On approval Muppy executes the call with the arguments the agent sent; the agent reads the outcome with get_status on the request (domain mcp-permissions), or lists what it is still waiting for with get_pending_permissions. A request that nobody answers expires.

Creating a container

An agent creates a container, or a virtual machine, with one call: create_lxc on the LXD host that will run it (domain muppy-core, model mpy.host). It is the Create LXC wizard of the host form, described in Creating a Container or VM, in one call: Muppy launches the container, registers it as a host, writes on it its qualifier, its auto-decommission date and its AI gate, then enrolls it once its SSH answers.

call_method  domain: muppy-core   model: mpy.host   method: create_lxc
             ids: [<id of the LXD host>]
             kwargs: {"lxc_name": "tst-api-1", "machine_type": "lxc",
                      "cpu": 2, "ram": 4, "disk": 20,
                      "qualifier_id": <id of the Development qualifier>}
Parameter Required What it is
lxc_name yes Letters, digits and -, starting with a letter, 50 characters at most. Refused when the LXD host already has a guest of that name.
machine_type yes lxc for a container, vm for a virtual machine.
cpu yes vCPU. A fraction between 0 and 1 gives a container a share of one core; 0 sets no limit (containers only).
ram yes GiB, fractions allowed; 0 sets no limit (containers only).
disk yes Root disk in GiB; 0 keeps the storage pool default.
qualifier_id yes The container's environment, an mpy.qualifier. A Decommissioned or Template qualifier is refused.
lxc_image_id no An image of the catalog (mpy.lxc_image). Omitted: the LXD host's Default LXC Image.
lxc_profile_id no A profile of that LXD host (mpy.lxc_profile, whose host_id is the LXD host). Omitted: its Default LXC Profile.

The list is closed: any other argument makes the call fail. An agent gives no launch flags (Muppy computes them from the machine type and the sizes), no image as text, no port, no date and no AI gate, and it cannot ask for a synchronous run. Each of these changes what the container is or who controls it, so they stay with a person, in the wizard.

The call is always asynchronous. A creation lasts longer than an HTTP request is allowed to, so the call queues a task and returns at once:

{"lxd_host_id": 12, "lxd_host_name": "nucus", "lxc_name": "tst-api-1",
 "imq_message_id": 531204, "imq_message_uuid": "…"}

The agent follows the message with get_task_status on imq.message (domain imq); its processing log carries the whole trace of the creation. Once the message is done, the container is the host named lxc_name whose LXD host is the one called.

A refusal comes before anything is queued, and leaves no message: a host that is not an initialized LXD host, a name that is invalid or already taken, an id that names no record, a profile of another host, sizes a virtual machine cannot boot with.

The gate on the call, and on the container

create_lxc follows the LXD host's AI gate, like every method on a host:

  • on an AI Managed LXD host, the call runs at once, and its IMQ message is its trace;
  • on a closed one, the call becomes a permission request, and nothing is created until you approve it. The request shows every argument on its own line, names the qualifier, the image and the profile, and says which date and which gate the container will get; an argument create_lxc does not take is shown as what the call will fail on;
  • Grant for Duration on the LXD host opens it for a while: the calls made during the grant run at once.

The new container inherits the LXD host's AI Managed switch, and only that switch. A Grant for Duration is not handed down: a container created on a closed LXD host, or on one open only for a while, is born closed, and the agent's calls on it are permission requests until you open it. The agent never chooses that gate.

The date

The qualifier decides the container's auto-decommission date: a Development or Test qualifier dates it the default horizon ahead, 30 days unless changed in Settings ▸ Muppy Core ▸ Auto-Decommission, and any other qualifier leaves it undated. On that date Muppy destroys the container. You change the date afterwards with Manage Decommission on the container's form.

Creating an OVH instance

An agent creates an OVH Public Cloud instance with one call: create_ovh_instance on the OVH account it is billed to (domain muppy-core, model mpy.cloud_provider_account). It is the Commission OVH Public Cloud Instance(s) wizard, described in Commission an OVH Instance, in one call and for one instance: Muppy creates the instance at OVH, registers it as a host, writes its auto-decommission date, waits until it is ACTIVE, binds its public IP, waits for its SSH, and enrolls it when asked.

The instance receives the Muppy key, the SSH key Muppy connects with, and no other: the call takes no SSH key. That is Muppy's rule for a new machine: it trusts only the Muppy key, whose private half never leaves Muppy, so every access to it goes through Muppy — its AI gate, its journal, its enrollment. An agent cannot hand the instance a key of its own. When the OVH project does not hold the Muppy key yet, the commission registers it there first. A person gets access to the instance at enrollment, which installs the default keys.

Each call is a real expense

The instance is billed by OVH by the hour, at its flavor's price, from its creation until it is destroyed: on its decommission date by the auto-decommission sweep, or earlier by a person.

call_method  domain: muppy-core   model: mpy.cloud_provider_account
             method: create_ovh_instance   ids: [<id of the OVH account>]
             kwargs: {"name": "tst-api-1",
                      "ovh_project_id": 41, "ovh_region_id": 57,
                      "ovh_flavor_id": 1290, "ovh_image_id": 8412,
                      "decommission_date": "2026-10-10 18:00:00"}

The ids are those of the account's catalog, mpy.ovh_cloud_api_object in the same domain, which Sync Projects and Sync Projects Data fill on the account's form. An agent searches it on the account (cpa_id) and the type (obj_type): the project, then a region of that project, then a flavor and an image of that project listed for that region. A flavor's json_data gives its vCPU, RAM and disk.

Parameter Required What it is
name yes The instance's name at OVH and its host's in Muppy: lowercase letters, digits and -, starting with a letter, not ending with -, 63 characters at most. Refused when a host of that name exists, or while a commission of that name is still queued or running.
ovh_project_id yes The project the instance is created in, of this account's catalog.
ovh_region_id yes A region of that project.
ovh_flavor_id yes A flavor of that project, listed for that region: it sets the size and the hourly price.
ovh_image_id yes An image of that project, listed for that region.
decommission_date yes When Muppy destroys the instance, in UTC, YYYY-MM-DD or YYYY-MM-DD HH:MM:SS: in the future, and at most the ceiling ahead (below).
auto_enroll no true to enroll the host once its SSH answers, ending ==Managed==. Default false.

The list is closed: any other argument makes the call fail. An agent gives no SSH key, no count, no monthly billing and no synchronous run. The instance receives the Muppy key, so that Muppy can always log in to it; each call is one instance, so that what is spent is what was asked; the billing is hourly, because a monthly instance is billed for the month whatever its date; and a commission lasts longer than an HTTP request is allowed to.

The call is always asynchronous. It queues the commission and returns at once:

{"cloud_provider_account_id": 1, "cloud_provider_account_name": "hankluum",
 "name": "tst-api-1", "imq_message_id": 531318, "imq_message_uuid": "…"}

The agent follows the message with get_task_status on imq.message (domain imq); its processing log carries each step of the commission. Once the message is done, the instance is the host named name.

A refusal comes before anything is queued, and before anything is asked of OVH: an account that is not a connected OVH account, no Muppy key or one without its private key, a name that is invalid or taken — by a host, or by a commission still queued or running, which a retried call would otherwise double — an id that names no catalog object, an object no longer in the catalog, of another account, of another type or of another project, a flavor or image not offered in the region, a date that is missing, unreadable, past or beyond the ceiling.

The date and its ceiling

The date is required: it is what makes the expense stop. It lies at most the Manganese Ceiling ahead, 90 days unless changed in Settings ▸ Manganese ▸ App Server Lifetime. On that date the auto-decommission sweep deletes the instance at OVH and removes its host. The sweep is a scheduled action an administrator enables: while it is disabled, the date records an intent and nothing is destroyed, so the instance is billed until a person decommissions it. You change the date afterwards with Manage Decommission on the host's form.

The gate on the account

create_ovh_instance follows the OVH account's AI gate. The account carries an AI Managed switch on its form, under OVH Application, closed when the account is created:

  • on an AI Managed account, the call runs at once, and its IMQ message is its trace;
  • on a closed one, the call becomes a permission request, and nothing is created, at OVH or in Muppy, until you approve it. The request names the project, the region, the flavor with its vCPU, RAM and disk, the image and the Muppy key the instance receives, and says the date, the enrollment, and one instance, billed hourly; an argument create_ovh_instance does not take is shown as what the call will fail on;
  • Grant for Duration on the account opens it for a while: the calls made during the grant run at once.

The host created this way is born closed, like any host Muppy registers: the agent's calls on it are permission requests until you open it.

What it does not do, by design

  • No shell on the Muppy server itself, no upgrade, no restart of Muppy: the server is the process the agent talks to.
  • No kubeconfig, no kubectl, no helm handed out. Kubernetes is reached through Muppy's own methods: a Release syncs its objects (sync_k8s_objects, the RBAC objects its Parts Config declares included), a pod gives its logs (get_logs, with grep_pattern and tail_lines), an object can be deleted or patched (api_delete, api_patch) under the rules above, a Release is created from a profile (create_release) or moved to another one (upgrade_to_profile).

    A command can be run inside a pod, and only that way: _agent_pod_exec on the pod takes a list of words — there is no shell unless the agent asks for one and is seen doing it — and gives back what the command printed with its exit code. It runs at once on a dev or test Release and is a permission request anywhere else, and it is journaled either way. It is what lets an agent reach the application it just deployed; it is not a way around get_logs, api_patch or a health check, each of which does its own job with a bounded effect. - No ad-hoc HTTP request either. To look at what a deployed application answers, an agent uses a health check: it creates one on the Release (setup_health_check, with the path and the parser it wants), runs it (health_check), and reads the newest result, whose raw_value is the response body verbatim next to the parsed verdict and the alerts. The check then keeps running on its schedule, so a probe leaves a history behind rather than nothing. - No secret leaves through the MCP. A Vault is readable as a record, its password field is not. - Tenant isolation is the other server's job. The infrastructure server is for Muppy administrators; a tenant works through Manganese and its mgx_* methods.

The agent's working pattern

  1. Discover before reading: list_domains → list_models → get_model_metadata. The descriptions say which method does what, and which arguments it takes.
  2. Read with search_read_objects and an Odoo domain filter; page long fields with read_huge_content and search them with grep_huge_content instead of reading them whole.
  3. Diagnose Kubernetes from Muppy's side: sync_k8s_objects on the Release, then the objects' status and conditions, then get_logs on the pod; the config journal keeps the Helm command, exit code and console of every install and upgrade. To read what the application itself answers, set up and run a health check on the Release and read its result.
  4. Reach the instance you deployed: a Release you created carries the email of its first user, and Muppy never returns that user's password. Give yourself one from inside the running application — _agent_pod_exec on the Release's gui pod, with ik-cli user setup and a password you choose — then open the Release's own URL. The call is journaled, with the password masked. Set a random one again when you are done.
  5. Act with call_method on records of dev or test environments; anywhere else, expect pending_approval, tell the user which request to open, and only then retry or poll get_status. Never work around a held request by another route.
  6. Long tasks are asynchronous: many methods dispatch a message on the task queue and return at once; follow it in the imq domain (imq.message, its processing log). create_lxc and create_ovh_instance are among them, always.

Where this is configured

Settings ▸ MCP holds the providers, the instances, the domains, the exposed models and methods, and the permission requests. The exposed models and methods are generated from profiles shipped with Muppy; what you change there by hand is reset when the profile is applied again at the next upgrade, so a durable change is a change of profile.