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_lxcdoes 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_instancedoes 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, nohelmhanded 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, withgrep_patternandtail_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_execon 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 adevortestRelease 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 aroundget_logs,api_patchor 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, whoseraw_valueis 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 itsmgx_*methods.
The agent's working pattern¶
- Discover before reading:
list_domains→list_models→get_model_metadata. The descriptions say which method does what, and which arguments it takes. - Read with
search_read_objectsand an Odoo domain filter; page long fields withread_huge_contentand search them withgrep_huge_contentinstead of reading them whole. - Diagnose Kubernetes from Muppy's side:
sync_k8s_objectson the Release, then the objects' status and conditions, thenget_logson 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. - 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_execon the Release'sguipod, withik-cli user setupand 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. - Act with
call_methodon records ofdevortestenvironments; anywhere else, expectpending_approval, tell the user which request to open, and only then retry or pollget_status. Never work around a held request by another route. - Long tasks are asynchronous: many methods dispatch a message on the task queue and
return at once; follow it in the
imqdomain (imq.message, its processing log).create_lxcandcreate_ovh_instanceare 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.