meshadm
v0.4.6
Published
MCP Mesh platform CLI — a thin, agent-friendly client over the MCP Mesh console API.
Maintainers
Readme
meshadm
npm install -g meshadmStatus: released. Published versions are stamped Go binaries, not the 0.0.0
name reservation that first held the npm name. meshadm version prints the version,
commit and build date it was built from; npm view meshadm version is the latest
published. This file deliberately names no version — it went stale twice that way. It is built and verified by .github/workflows/release-tools.yml, which is
dispatch-only: merging to main publishes nothing.
How it is packaged
Five npm packages, one version, published together:
| Package | Contents |
|---------|----------|
| @mcpmesh/meshadm-darwin-arm64 | one Go binary, os/cpu declared |
| @mcpmesh/meshadm-darwin-amd64 | " |
| @mcpmesh/meshadm-linux-amd64 | " |
| @mcpmesh/meshadm-linux-arm64 | " |
| meshadm | bin/meshadm.js, the launcher — the four above as exact-pinned optionalDependencies |
npm installs the one platform package matching your machine and skips the rest;
bin/meshadm.js resolves it and execs the binary, forwarding stdin, signals, and the exit
code unchanged. There is no postinstall download: the binaries ship inside the packages,
so installs work offline and there is no install-time network to trust. No Homebrew tap, and
no GitHub Release assets — this repo is private, so npm is the only public channel available.
Windows is not built. Use WSL2, where meshadm is the linux build.
Build the whole release set locally with
scripts/ci/build-meshadm-packages.sh --version <x.y.z>.
The Go program lives in this directory (main.go, internal/, man/)
and implements the foundation — contexts, service-principal auth, the output/exit-code
contract, the embedded manual — plus a generated API client and, on top of it,
meshadm reads the whole console surface and writes most of it:
| Noun | Read verbs | Write verbs |
|------|-----------|-------------|
| todo | (no subcommands) — the live onboarding board: every task, its state, and what it is waiting on. --env narrows, --blocked shows every app's operator asks. Exits 1 when work is available and 10 when everything is blocked | |
| status | (no subcommands) — the deploy-status read, and the one to poll after a sync: every component's live Argo sync and health. --env scopes it to one app-environment; without it the columns are the worst-of across all of them. It is the same command as component list — same call, same flags, same output | |
| app | list, describe, config describe, platform-env-allowlist list, database-quota describe | config set — every environment realm at once, platform-env-allowlist allow, platform-env-allowlist revoke, database-quota set (--quota required), database-quota reset — the only way to clear the override |
| env | list, config describe, namespace-preflight | create — provisions a Keycloak realm, and the delete path is not its mirror, config set, set-public-url, set-data-services — declares which managed data services the app-env uses; --none and --clear are different acts |
| component | list | declare, update, restart — deletes pods, one at a time, waiting for each replacement to be Ready; it never patches the Deployment, so Argo stays the only writer of desired state |
| mesh-core | | provision |
| oidc-client | list, derived-redirect-uris | create — reveal-once secret, import |
| role | list, describe | create, update, delete — destructive |
| permission | list | |
| manifest | download | apply (--dry-run) |
| user | list, describe | |
| idp | list, registration, login-methods | set <providerId>, set-registration, set-password |
| route | describe | set, republish |
| branding | describe | set |
| theme | describe, status, installed, download, starter | upload, delete — destructive |
| email-template | list, describe, preview, starter | set <typeKey>, delete — destructive |
| data | list, describe <postgres\|redis\|minio> | postgres provision, redis provision, minio provision — reveal-once credentials |
| harbor | describe, preflight | provision — reveal-once secrets, repair-pull-secret |
| argo | list-registrations — registration rows only, no sync and no health; the old argo list spelling stays as a permanent alias | register, register-all, adopt, sync, deregister — destructive |
| onboarding-token | describe | mint — reveal-once secret |
| bundle | download | |
| values | (no subcommands) — prints the app-env's env-var list + helm values snippet, no secrets | |
| secrets | pubkey — the cluster's public sealing certificate | |
| platform-env | list — operator-only | |
That table is the whole console surface meshadm covers, and internal/cmd/readme_test.go
fails the build when it drifts from the command tree — it has gone stale before, and a
table that under-reports verbs is worse than no table, because it tells an agent a command
does not exist and the agent believes it. Four nouns are deliberately absent because they
call no console API: auth (tokens and identity, below), config (contexts on disk), man
and version. Most verbs also have aliases the table does not repeat; meshadm <noun> --help
is the authority on those.
Two per-app guardrails sit under app, and both bound an app that can build its own
environments. app platform-env-allowlist decides where the app may create
app-environments — the platform-environment on a create request is caller-supplied, so
without it an app principal could name prod from a dev-realm credential and have a realm
provisioned on the production Keycloak. app database-quota decides how many managed
databases the app may hold, in addition to the cluster connection budget rather than
instead of it: the budget bounds the shared pool, the quota bounds one app's share, and both
must pass. Reading either is app-scoped — an agent that knows it may only bind dev, or that
it is at its database quota, can act instead of tripping over a 403. Changing either is
platform-admin only and requires a platform-realm credential, because a principal that
could raise its own ceiling would not be bounded by it. Two things that read backwards if you
skim them: an empty allowlist means nothing permitted, not no restriction, and no
quota override means the platform default, never unlimited.
database-quota set requires --quota and will not infer it. The endpoint reads an
absent quota as "clear the override and revert to the default", answers 200, and looks
exactly like a successful set — a mistyped field has already changed a ceiling that way. So
meshadm splits the intents: set --quota <n> sends a number and exits 2 without calling
the console when the flag is missing, and reset is the only thing that clears an override.
A forgotten flag must never be a silent write.
Destructive verbs exist now, and nothing client-side gates them. role delete,
theme delete, email-template delete and argo deregister are implemented, and none of
them has a --yes, a --confirm or a prompt — nor may they grow one. The grant is the
boundary: Delete is a separate permission per section, withheld by default, so a
principal that lacks it gets a 403 at the endpoint. A confirmation flag is a string the
caller supplies about itself, which for an agent that writes its own argv is theatre, and
theatre that reads like a control is worse than nothing. All four answer 204, so meshadm
emits its own receipt (deleted, kind, name, app, env, status) rather than
letting --output json print null. See internal/cmd/deletion.go.
argo sync replaces a technique agents are already using. Syncing an Argo Application by
kubectl patching .operation onto the CR works, and that is the problem: Argo does not
authenticate a direct CR write, so initiatedBy.username there is self-declared and
unverifiable. Prod syncs on this cluster are attributed to a claude-code account that
exists in neither argocd-cm nor argocd-rbac-cm — the manual sync policy on those
Applications is a deliberate human gate, and it held only where an agent's own instructions
happened to forbid mutating kubectl. meshadm argo sync routes the same write through the
console, where the caller is authenticated, checked for APP_ARGO_MANAGE on that app,
attributed from the token, and audited. It also cannot name an Application: you pass an
app-environment, and the console derives the names from your app's own registration rows, so
one app can never sync another's — or the control plane's own. It syncs mesh-core first
and refuses to touch the app while core is unhealthy, because agents re-register against the
mesh registry on startup. See meshadm man argo.
Still unimplemented: app create (platform-realm and human by design — it is the trust
anchor, see docs/MESHADM-DESIGN.md §3), any data * deprovision, and any delete for
components, users or OIDC clients.
Reveal-once secrets go to a file, never stdout
A handful of console endpoints return material that exists exactly once and that no read
endpoint can give back. meshadm handles all of them the same way, and the mechanism lives
in one file (internal/cmd/secret.go):
- The secret is written to a 0600 file as a JSON object —
--secret-file <path>, or~/.meshadm/secrets/<app>-<env>-<what>.secret.jsonby default, in a0700directory. That default is pinned to$HOME: never the working directory (a git checkout, one ignore rule from a commit), never a repo-local.meshadm/(config.yamlfollows the walk-up; reveal-once secrets do not), never/tmp(predictable-by-name on a shared machine).--secret-fileis the only thing that moves it.meshadm man irreversiblerecords why plaintext-0600-in-$HOMEis the bar and where its limit is. - stdout carries a receipt, through the normal
--output json|yaml|tablepath: what was created, the file, and per secret a sha256 prefix plus byte length — never the value. - In
--output jsonthe secret field is redacted with a marker, not omitted:"clientSecret": "***REDACTED-BY-MESHADM***". An absent field means the response carried no secret (a PKCE client has none); the marker means there is one and it is in the file. An agent must be able to tell those apart. --show-secretis the explicit opt-in that puts values on stdout, and it warns on stderr. The file is written either way.- meshadm refuses to overwrite an existing secret file (
O_EXCL, exit 5) — that file may be the only copy of an earlier reveal.--overwrite-secret-filesays otherwise. - Multiple secrets go into one file. Redis returns a data-node password and a sentinel-discovery password; they are one credential in two halves, useless apart.
Why a file. gcloud iam service-accounts keys create --key-file= writes the key to a
path and never prints it; AWS prints access keys to stdout and has been criticised for it for
a decade. The difference matters more for an agent than for a person: an agent's stdout is
logged, echoed into transcripts and pasted into issues by tooling that has no idea one
field of that JSON was the only copy of a password.
Wired today — seven verbs, one mechanism:
| Verb | Secret fields | Default file (in ~/.meshadm/secrets/) |
|------|---------------|--------------|
| auth bootstrap | clientSecret | <app>-<env>-service-principal.secret.json |
| oidc-client create | clientSecret | <app>-<env>-<client>.secret.json |
| data postgres provision | password | <app>-<env>-postgres.secret.json |
| data redis provision | password, sentinelPassword | <app>-<env>-redis.secret.json |
| data minio provision | secretKey | <app>-<env>-minio.secret.json |
| harbor provision | ciRobotSecret, pullRobotSecret | <app>-harbor.secret.json |
| onboarding-token mint | token | <app>-onboarding-token.secret.json |
Inheriting it unchanged when it lands: app create (clientSecret, operator-only).
data postgres provision can be refused by the connection budget. max_connections is
one number shared across every tenant on the cluster, and CONNECTION LIMIT is a per-role
cap rather than a reservation — so over-committing does not fail at provision time, it fails
later at CONNECT time for whoever asks last. The console meters it and answers 409 with
error: connection_budget_exceeded. meshadm gives that its own reason rather than flattening
it into a generic conflict, and keeps the numbers: table format prints
committed=380 requested=50 max_connections=400 remaining=20, and --output json carries
them under error.details as numbers.
data minio provision mints a bucket-scoped key, and its secretKey is never stored.
The bucket is t-<data-slug> (hyphens preserved — an S3 bucket name has to survive being a
DNS label, so it is not Postgres's t_<slug_>), and the key's policy names that one bucket
and omits s3:ListAllMyBuckets, so it cannot even enumerate what it may not read. MinIO's
ROOT credentials stay on the platform side and are never returned. Because the secret key is
generated at provision time and kept nowhere, the 0600 file meshadm writes is the only
copy that will exist — so a failure to write that file fails the command, and re-running
the provision mints a new key rather than re-revealing the old one. data minio provision
accepts APP_DATA_PROVISION, which is what lets an app's own service principal call it.
Deprovision is deliberately not a meshadm verb: its endpoint is gated on
APP_CREATE/TENANT_CREATE only, so an app may create its own storage and may not destroy it.
Every whole-document write has its read. env config set and manifest apply both
REPLACE the whole document, so env config describe and manifest download exist to make
read-modify-write possible — without them the only available move is to overwrite what you
cannot see. The read's output is the write's input, unedited:
meshadm env config describe --output yaml > config.yaml # edit it
meshadm env config set --file config.yaml
meshadm manifest download --file manifest.yaml # edit it
meshadm manifest apply --file manifest.yaml --dry-runThe manifest read is download, not describe, because the console returns a YAML
DOCUMENT with comments (application/x-yaml) that apply sends back verbatim — so it takes
the same non-JSON path as theme download / bundle download: the document goes to a file,
a receipt (path, size, sha256) goes through the renderer, and --file - streams it for
piping. describe is an alias.
Scope is named with --app and --env on every noun, and both default to the active
context. A positional argument is always the object's own identity — role describe <name>,
user describe <userId>, idp set google — never the app.
How a write takes its input, decided by the API's own request schema and not per verb:
| Body the API declares | How meshadm takes it | Verbs |
|-----------------------|----------------------|-------|
| a document | -f/--file <path>, JSON or YAML, - reads stdin | role create/update, app config set, env config set, route set, branding set, oidc-client create |
| raw bytes | -f/--file, sent verbatim | manifest apply (YAML body), email-template set and theme upload (multipart zip) |
| scalars | flags | component declare/update, idp set --enabled, idp set-registration, idp set-password, env set-public-url, env set-data-services, oidc-client import, argo register, harbor provision, data postgres/redis provision |
A key the API does not declare is rejected before the request, because the console
ignores unknown properties — a typo'd field would otherwise report success having applied
nothing. --dry-run exists only on manifest apply, where the API implements it; no
other verb offers one, because faking it client-side would be a CLI-side guarantee the
platform never made.
cd tools/meshadm
go build -o meshadm .
./meshadm man
./meshadm app list --output json
./meshadm role list --app happyfeet --env dev --output jsonThe sealing certificate is the opposite kind of secret
meshadm secrets pubkey prints the cluster's sealed-secrets public certificate. It sits
next to the reveal-once machinery above and is its mirror image, which is why the noun is
secrets and not part of it: reveal-once handles a credential nobody may see twice, and this
handles a certificate everybody may see forever. Nothing here reads a secret value.
meshadm secrets pubkey > sealed-secrets-pub.pem
kubeseal --cert sealed-secrets-pub.pem < secret.yaml > sealed.yamlThe default output is the raw PEM on stdout, so > and a pipe both work; --output
json|yaml puts it inside a document with its size and sha256 instead, which is meshadm
man's rule rather than a new one. --file <path> writes it and prints a receipt, and
refuses to overwrite an existing file (exit 5, --overwrite says otherwise) — not because
the bytes are precious, but because that path usually sits in a working tree beside secrets
sealed with a particular certificate. It is the only file meshadm writes 0644: everything
else it produces is 0600 because it holds a credential, and marking a public key the same
way would say "secret" about the one artifact that is not.
There is no --app and no --env, on purpose. One sealed-secrets-controller in
kube-system serves every namespace on the cluster, so there is one certificate and it
unseals everywhere; a per-environment flavour would have to return a cert some controller
does not hold the private half of. The certificate is fetched live on every call — the
controller rotates roughly monthly and keeps the old private keys, so anything already sealed
keeps working, but the copy in an onboarding bundle you downloaded in July is not the one to
seal with today. That staleness is the reason this command exists.
A failed fetch is never a success. The console answers 503 with
error: sealed_secrets_cert_unavailable rather than 200 with an empty body, and meshadm
exits 9 with a clean stdout. Sealing against an empty or non-PEM input produces a
SealedSecret that no controller can open, kubeseal accepts it, Argo syncs it, and the
first sign of trouble is a Deployment waiting on a Secret that will never arrive. meshadm man
irreversible has the rest.
The API client is generated
internal/api/openapi.gen.go is generated by oapi-codegen
from api/openapi.json at the repo root — the spec springdoc emits from the console's own
controllers. Do not edit it, and do not hand-write a path or a DTO beside it. A
hand-maintained client is a second source of truth about the wire format; generated, a
controller change the CLI does not track fails to compile. See docs/MESHADM-DESIGN.md §6.
Regenerating, after a controller or DTO change:
./mvnw -pl apps/auth-manager -am -B verify # emits target/openapi.json
scripts/ci/verify-openapi-snapshot.sh --write # refreshes api/openapi.json
cd tools/meshadm && go generate ./... # regenerates the clientCommit all three. scripts/ci/verify-openapi-snapshot.sh runs in CI (in the jvm job,
which already emits the spec) and fails when the committed snapshot drifts from what the
console emits — so an API change shows up as a reviewable spec diff in the PR that causes
it. The generated code is committed too, per Go convention: go build must work with no
generator, no JDK and no Postgres.
Everything hand-written in internal/api/ is in client.go, and it is deliberately small:
construction, bearer-token injection from internal/auth's cache, and pulling the console's
own message out of an error body. None of it knows a path or a field name.
What it is
meshadm is the CLI for the MCP Mesh platform. It is a thin client over the
existing console API — the same bundle / adopt / provision / reveal operations the
console already exposes — and nothing more.
It is explicitly not a second control plane. The console owns the model of apps, envs and realms; a CLI that grows its own opinions about that model becomes a split brain.
Correction: device-code was never the blocking prerequisite
An earlier version of this file claimed "there is no non-browser token flow … meshadm needs
device-code or PKCE against the platform realm before it can call a single endpoint."
That was wrong on both counts, and docs/MESHADM-DESIGN.md §2.4 records the correction:
- The console is a plain multi-issuer OAuth2 resource server and accepts a raw
Authorization: Bearer <jwt>. The cookie-to-Bearer translation happens at the edge, not in the API. - The platform realm is the wrong target anyway. An agent's credential must be issued by the
app's own realm — a platform-realm token carrying
APP_*passes the console's app-scoped check for every app.
So the flow is client_credentials against the app's realm, which is what
meshadm auth login does. Device-code is human ergonomics and ships later.
The real prerequisite was console-side: materializing the app-level permission catalog into
app-env realms and minting a per-app service principal there. That shipped — it is what
meshadm auth bootstrap spends an onboarding token on.
auth status and auth whoami are different questions
auth is the one noun outside the table above, and its two identity verbs are easy to
confuse:
| Verb | Where the answer comes from | What it tells you |
|------|-----------------------------|-------------------|
| auth status | local, no network call — the cached token, decoded | issuer, realm, subject, expiry, and the resource_access.usermanagement.roles claim verbatim. Answers with the control plane unreachable, and is still the first thing to run when a call 403s |
| auth whoami | GET /api/v1/me — the console | context, realm, user, isPlatformAdmin/isTenantAdmin, tenant, and the permissions the console recognises |
The lists differ on purpose. The console filters your token's claims to the permissions it
knows about, so a name in auth status but missing from auth whoami is one it does not
recognise — a grant that looks present and grants nothing, which is invisible to a local
decode. Neither list is scoped: both say a permission is held somewhere, not that it
resolves on the app you addressed.
Breaking, as of this release:
whoamiused to be an alias ofauth status. It is its own command now and it makes a network call; the alias is gone. A script that ranmeshadm auth whoamifor an offline decode wantsmeshadm auth status, which is unchanged.
Where it lives
tools/meshadm/ in mcp-mesh-org/mesh-control-plane,
not its own repo — it tracks the console API, so it has to move in lockstep with it. See
docs/REPO-RENAME-PLAN.md §2.
License
Apache-2.0.
