Connect Claude Code to this repo's OpenShift clusters, AAP instances, RHDH portal, Automation Orchestrator, and Grafana Cloud over MCP — up to ten servers, one skill. Generates per-environment kubeconfigs for OpenShift, auto-creates bearer tokens for AAP, extracts portal MCP tokens, registers the AO and Grafana Cloud MCP servers, then verifies every server answers. TRIGGER when: the user asks to set up, connect, refresh or fix the MCP servers, says an openshift-sandbox, openshift-demo, opensh...
Installs into .claude/skills of the current project.
Are you the author of Sales Demos Mcp?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/ericcames-sales-demos-mcp)
---
name: sales-demos-mcp
description: "Connect Claude Code to this repo's OpenShift clusters, AAP instances, RHDH portal, Automation Orchestrator, and Grafana Cloud over MCP — up to ten servers, one skill. Generates per-environment kubeconfigs for OpenShift, auto-creates bearer tokens for AAP, extracts portal MCP tokens, registers the AO and Grafana Cloud MCP servers, then verifies every server answers. TRIGGER when: the user asks to set up, connect, refresh or fix the MCP servers, says an openshift-sandbox, openshift-demo, openshift-edge, aap-sandbox, aap-demo, portal-sandbox, portal-demo, ao-sandbox, ao-demo, or grafana MCP server is failing or shows no tools, or has just repointed an environment or rotated a token. SKIP: if the user wants to install OpenShift Virtualization or apply AAP configuration — that is sales-demos-setup — or wants to deploy the AAP MCP server into a cluster, which is playbooks/mcp_server.yml run by sales-demos-setup."
---
# sales-demos-mcp
Makes the clusters, AAP instances, RHDH portal, and Grafana Cloud directly
queryable from Claude Code. Before this, every question about an environment
cost a `curl`, a vault read and a JSON parse; after it, `namespaces_list`,
`job_templates_list`, or a portal catalog query is a tool call.
**No playbook, by design.** This touches the laptop — it writes kubeconfigs,
creates tokens, and configures your MCP client. It must never run from AAP,
which is the same reasoning that keeps `sales-demos-collections-sync`,
`sales-demos-first-time` and `sales-demos-ee-build` playbook-free.
## What it sets up
**Up to ten servers — nine per-environment, one global/external:**
| Server | Auth | Access | Source |
|---|---|---|---|
| `openshift-sandbox` | kubeconfig | read-write | `.mcp.json` (committed) |
| `openshift-demo` | kubeconfig | read-only | `.mcp.json` (committed) |
| `openshift-edge` | kubeconfig | read-write | `.mcp.json` (committed) |
| `aap-sandbox` | bearer token | read-write (server-side, `group_vars/sandbox/mcp.yml`) | `.mcp.json` (committed) |
| `aap-demo` | bearer token | **read-only** (server-side, `group_vars/demo/mcp.yml`) | `.mcp.json` (committed) |
| `portal-sandbox` | static token | read-only | `.mcp.json` (committed) |
| `portal-demo` | static token | read-only | `.mcp.json` (committed) |
| `ao-sandbox` | JWT (auto-refreshed) | read-only | `claude mcp add --scope local` |
| `ao-demo` | JWT (auto-refreshed) | read-only | `claude mcp add --scope local` |
| `grafana` | service account token | read-only | `claude mcp add --scope local` |
**There is no `aap-edge`, and that is not an oversight to fix in passing.**
`edge` runs AAP, so the server is plausible, but adding it is a posture
decision, not a usage-line fix — the same call #405 made about that script.
**There is no `ao-edge` either, for a different reason: AO is not installed on
`edge`.** Measured 2026-09-11 — `openshift-edge` has no Route in the
`automation-orchestrator` namespace. Posture is not the blocker here: the AO
server is read-only everywhere, and `make-ao-mcp.sh` accepts any environment
name. `.claude/settings.json` already allowlists `mcp__ao-edge__*` (added with
the server in #465), so once `/sales-demos-orchestrator` has run against `edge`,
`bash utilities/make-ao-mcp.sh edge` is the whole job. Until then the script
stops at the Route lookup, which is the correct failure.
**One server per environment, named after it, is the whole design.** #16 is the
precedent: when two environments were not kept distinct, `--limit demo`
silently resolved to sandbox's hostname and sandbox's token with no warning at
all. A single server whose target changed underneath you would reintroduce
exactly that, so the environment is in the server's *name* and you pick it by
picking the tool.
`demo` is read-only on both servers, because it is the environment customers
watch, and **the two guards live in different places and must move together**:
- **OpenShift** — `--read-only` on `openshift-demo` in `.mcp.json`, enforced by
the client. You can see it in the tracked file.
- **AAP** — `aap_mcp_allow_write_operations` in `inventory/group_vars/<env>/mcp.yml`
(`true` for sandbox, `false` for demo), enforced by the server. It is *not*
visible in `.mcp.json`, which is why this table names the file. Nothing
overrides it during setup: `setup.yml` deploys demo read-only from the start.
Changing the AAP value means editing `mcp.yml` and re-running `mcp_server.yml`
(or the **AAP Ecosystem - Install MCP Server** template), which deletes and
recreates the server so the new permission actually takes effect. The token and
URL do not change, so no Claude restart is needed. See #102.
### Grafana Cloud server
`grafana` is a **single server**, not per-environment — Grafana Cloud is an
external SaaS instance that survives RHDP rebuilds. That is the whole point of
choosing it over self-hosted (#260). The service account token has the Viewer
role (read-only, matching the governance thesis). See
[`grafana-plan`](https://ericcames.github.io/sales.demos-docs/plan/grafana-plan/).
### OpenShift servers
`.mcp.json` is committed and defines the three OpenShift servers. `demo` has
`--read-only`, which removes the nine mutating tools and keeps every
investigative one — including `vm_guest_info` and `vm_troubleshoot`. `sandbox`
and `edge` carry all 25.
The `kubevirt` toolset is enabled because this repo is an OpenShift
Virtualization demo. It supplies `vm_create`, `vm_clone`, `vm_lifecycle`,
`vm_guest_info` and `vm_troubleshoot`.
### AAP servers
The AAP MCP servers **run in the cluster** (deployed by `playbooks/mcp_server.yml`,
which `setup.yml` calls). The client side needs a bearer token, and tokens must
not go in tracked files.
`.mcp.json` defines `aap-sandbox` and `aap-demo` as stdio servers (#515). Each
entry calls `utilities/aap-mcp-stdio.sh <env>`, which reads the token and route
URL from gitignored files in `.aap/` and bridges stdio to the remote server via
`npx supergateway`. The credential stays out of the tracked file — same pattern
as the kubeconfig paths for OpenShift servers.
**A local-scope entry of the same name outranks `.mcp.json`** (precedence is
local > project > user). Before #515 these servers were registered with
`claude mcp add --scope local` as HTTP entries with the URL inline, and one left
behind keeps the server on its old cluster through every regeneration and
restart. `make-aap-mcp.sh` removes it and then asserts, through
`claude mcp get`, that the project entry is the one that wins; it fails rather
than trust the removal (#603).
`utilities/make-aap-mcp.sh` automates the full flow: resolve credentials from
the vault, create a personal access token via the gateway API, find the MCP
route, and write `.aap/<env>.token` and `.aap/<env>.url` — the full `/mcp`
endpoint, since the route root answers `404` (#603). Token scope is always
`write` — server-side enforcement (`aap_mcp_allow_write_operations`) is the
real guard, not the token scope.
**These tokens do not clean themselves up.** They are the documented exception
in CLAUDE.md — an MCP client needs a durable credential, so the `always:` block
rule does not apply. The script prints cleanup instructions; retiring them is
manual.
## Preflight Check
Every one must pass.
```bash
ENV=${ENV:-sandbox}
test -s "$HOME/secrets/.vault_pass_sales_demos" \
&& echo "✅ vault password file" \
|| echo "❌ ~/secrets/.vault_pass_sales_demos missing — see /sales-demos-first-time step 2"
head -c 15 playbooks/group_vars/all/secrets.yml 2>/dev/null | grep -q '^\$ANSIBLE_VAULT' \
&& echo "✅ secrets.yml is vault-encrypted" \
|| echo "❌ secrets.yml is NOT encrypted — stop, do not commit"
command -v npx >/dev/null \
&& echo "✅ npx ($(node --version)) — needed to launch kubernetes-mcp-server" \
|| echo "❌ npx not found — install Node.js, or fetch the pinned binary from https://github.com/containers/kubernetes-mcp-server/releases"
command -v uvx >/dev/null \
&& echo "✅ uvx ($(uvx --version 2>/dev/null || echo 'unknown')) — needed to launch mcp-grafana" \
|| echo "❌ uvx not found — install uv (https://docs.astral.sh/uv/getting-started/installation/)"
test -f .mcp.json \
&& echo "✅ .mcp.json present" \
|| echo "❌ .mcp.json missing — it is committed; you may be outside the repo root"
# All MCP credentials (kubeconfig, AAP URL, AO registration) still point AT
# this environment. Generating them once is not enough: repointing an
# environment (via connection.yml, local.yml, or vault edits) does NOT
# regenerate these files (#161, #533). The failure is otherwise a DNS error
# naming a dead cluster, which says nothing about credentials.
bash utilities/check-mcp-staleness.sh "$ENV" 2>&1 || true
test -d "inventory/group_vars/$ENV" \
&& echo "✅ environment '$ENV' exists" \
|| echo "❌ no such environment '$ENV'"
```
If any fails, stop and tell the user which one and the fix beside it.
## Collect inputs
| Variable | Default | Meaning |
|---|---|---|
| `ENV` | `sandbox` | Which environment to (re)generate servers for |
Never prompt for a token and never pass one on the command line — that puts it
in shell history. Everything is read from the vault.
## Run
### Step 1 — OpenShift kubeconfig
```bash
bash utilities/make-kubeconfig.sh sandbox
```
Generate `demo` or `edge` too only if the user is actually working against
them. `demo` is the environment customers watch, and a stale kubeconfig for it
is harmless whereas a confidently wrong one is not. `edge` is the bare-metal
SNO — it is persistent rather than ephemeral, so its kubeconfig goes stale far
less often than an RHDP one.
The file lands at `.kube/<env>.kubeconfig`, mode `0600`. `.gitignore` covers
`.kube/`, and the script writes the token only after locking the file down, so
the credential is never briefly world-readable.
### Step 2 — AAP MCP server
Run **after** the kubeconfig exists — the script needs it to find the MCP route.
```bash
bash utilities/make-aap-mcp.sh sandbox
```
For `demo`:
```bash
bash utilities/make-aap-mcp.sh demo
```
The script creates a personal access token, finds the `aap-mcp` route via the
kubeconfig, and writes `.aap/<env>.token` and `.aap/<env>.url`. `.mcp.json`
references these through the `aap-mcp-stdio.sh` wrapper, so the server comes
online on the next Claude Code restart — no `claude mcp add` needed.
### Portal (RHDH) servers
The RHDH self-service portal exposes an MCP endpoint natively (#555) via the
`backstage-plugin-mcp-actions-backend` plugin, with `software-catalog-mcp-tool`
and `techdocs-mcp-tool` as action sources. `portal.yml` enables the plugins,
generates a static bearer token, and stores it in a `portal-mcp-token` Secret.
`.mcp.json` defines `portal-sandbox` and `portal-demo` as stdio servers. Each
entry calls `utilities/portal-mcp-stdio.sh <env>`, which reads the token and
route URL from gitignored files in `.portal/` and bridges stdio to the remote
endpoint via `npx supergateway`. Same pattern as the AAP servers.
`utilities/make-portal-mcp.sh` reads the portal Route and the `portal-mcp-token`
Secret from the cluster via the kubeconfig, and writes `.portal/<env>.token` and
`.portal/<env>.url`. Simpler than the AAP flow — the token already exists in the
Secret, so no API call creates one.
The token dies with the portal deployment (which dies with the RHDP env). On a
fresh bootstrap, `portal.yml` generates a new token and creates a new Secret.
`make-portal-mcp.sh` picks up the new one. No vault entry, no rotation — same
ephemeral-environment reasoning as the AAP MCP PAT.
### Step 3 — Portal MCP server
Run **after** the kubeconfig exists — the script needs it to find the portal
Route and Secret.
```bash
bash utilities/make-portal-mcp.sh sandbox
```
For `demo`:
```bash
bash utilities/make-portal-mcp.sh demo
```
The script reads the portal Route and `portal-mcp-token` Secret, then writes
`.portal/<env>.token` and `.portal/<env>.url`. `.mcp.json` references these
through the `portal-mcp-stdio.sh` wrapper, so the server comes online on the
next Claude Code restart.
### Step 4 — Automation Orchestrator MCP server
Run **after** the kubeconfig exists — the script needs it to find the AO Route.
```bash
bash utilities/make-ao-mcp.sh sandbox
```
For `demo`:
```bash
bash utilities/make-ao-mcp.sh demo
```
The script resolves the AO admin password from the vault (it is the AAP admin
password, #143), finds the AO Route via the kubeconfig, verifies login works,
and registers the server with `claude mcp add --scope local`. The server
manages JWT refresh transparently — no token to retire.
**Prerequisite:** `pip install mcp` — the server is a Python MCP server using
the official SDK.
### Step 5 — Grafana Cloud MCP server
Independent of the kubeconfig and AAP steps — Grafana Cloud is an external
service, not tied to any RHDP environment.
```bash
bash utilities/make-grafana-mcp.sh
```
The script reads `grafana_cloud_url` and `grafana_cloud_sa_token` from the
vault and registers the server with `claude mcp add --scope local`.
**Prerequisite:** a Grafana Cloud account with a service account token
(Viewer role). See [`grafana-plan`](https://ericcames.github.io/sales.demos-docs/plan/grafana-plan/)
for the manual browser setup steps.
**Restart Claude Code after running for the first time.** MCP servers are
launched at startup; a server that was not registered then stays absent until
the client is relaunched.
## Verify — ask the server, not the config
### OpenShift
**A generated kubeconfig is not proof.** It proves a file was written, not that
the cluster accepts it. Ask the target:
```bash
KUBECONFIG=$PWD/.kube/sandbox.kubeconfig oc whoami
KUBECONFIG=$PWD/.kube/sandbox.kubeconfig oc get nodes -o name
```
Then prove the MCP server itself starts and answers, independent of the client:
```bash
python3 - <<'PY'
import json, subprocess, os, sys
kc = os.path.abspath(".kube/sandbox.kubeconfig")
p = subprocess.Popen(
["npx","-y","kubernetes-mcp-server@0.0.66","--kubeconfig",kc,
"--toolsets","core,config,kubevirt","--disable-multi-cluster"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
send = lambda o: (p.stdin.write(json.dumps(o)+"\n"), p.stdin.flush())
send({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"verify","version":"0"}}})
info = json.loads(p.stdout.readline())["result"]["serverInfo"]
send({"jsonrpc":"2.0","method":"notifications/initialized"})
send({"jsonrpc":"2.0","id":2,"method":"tools/list"})
tools = json.loads(p.stdout.readline())["result"]["tools"]
send({"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"namespaces_list","arguments":{}}})
got = json.loads(p.stdout.readline())
p.terminate()
ok = info.get("name") == "kubernetes-mcp-server" and len(tools) >= 20 and "result" in got
print(f"server : {info.get('name')} {info.get('version')}")
print(f"tools exposed : {len(tools)}")
print(f"live call : {'namespaces_list returned data' if 'result' in got else got}")
print("\nMCP VERIFIED" if ok else "\nVERIFICATION FAILED — do not report success")
sys.exit(0 if ok else 1)
PY
```
Expect 25 tools on `sandbox`. If it returns 16, the `--read-only` flag is being
applied to the wrong server — check which entry in `.mcp.json` was launched.
### AAP
Verify the AAP MCP server by hitting its endpoint directly, with the same token
and URL files the stdio bridge reads:
```bash
ENV=${ENV:-sandbox}
AAP_MCP_URL=$(cat .aap/$ENV.url) # already ends in /mcp
AAP_MCP_TOKEN=$(cat .aap/$ENV.token)
curl -sk -o /dev/null -w '%{http_code}\n' -X POST "$AAP_MCP_URL" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H "Authorization: Bearer $AAP_MCP_TOKEN" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"verify","version":"0"}}}'
```
`200` and a body naming `"serverInfo":{"name":"aap"}` is the pass. **The endpoint
requires the bearer token** (#515), so a `401` from a request without the
`Authorization` header proves nothing about the server (#594). **A `503` means
the Route is admitted but the pod is not serving yet** — wait and retry rather
than assuming a misconfiguration. Measured on a working sandbox: **140 tools**,
including `job_templates_launch_create`, `workflow_job_templates_launch_create`
and `jobs_stdout_retrieve`.
### Portal (RHDH)
Verify the portal MCP endpoint is reachable:
```bash
ENV=${ENV:-sandbox}
PORTAL_URL=$(cat .portal/$ENV.url)
PORTAL_TOKEN=$(cat .portal/$ENV.token)
curl -sk -o /dev/null -w '%{http_code}\n' -X POST "$PORTAL_URL" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H "Authorization: Bearer $PORTAL_TOKEN" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"verify","version":"0"}}}'
```
`200` is the pass, with `"serverInfo":{"name":"backstage"}` in the body. The
portal is read-only — it exposes the software catalog and TechDocs, not mutating
operations.
**The `Accept` header is not optional**, and it is the line this block used to
be missing (#789). The endpoint is Streamable HTTP and rejects a request that
does not name *both* content types, so without it a perfectly healthy server
answers `406 Not Acceptable` and the documented check cannot pass. A `401
Illegal token` is the failure this check is actually for — the token in
`.portal/<env>.token` no longer matches the `portal-mcp-token` Secret, which is
what happens when the portal is redeployed. Both are "not 200" and only one is
a real problem, so read the body, not just the number.
### Automation Orchestrator
`claude mcp add` registers the server, but its tools only load when Claude Code
starts — **restart first**, then ask the server rather than re-reading the
config. Two calls, in this order:
1. **`mcp__ao-<env>__version`** — proves the server started, logged in and
reached the API. The pass is `"api_version": "v1"`. Measured on sandbox
2026-09-11: `info_version` `1.1.0`.
2. **`mcp__ao-<env>__proxies_aap_job_templates`** — proves AO's AAP integration
works, which is what makes AO useful. The pass is `count` greater than 0;
measured on sandbox: **33**. A `count` of 0 or an error with `version`
passing means the MCP server is fine and AO is not connected to AAP — that
is `/sales-demos-orchestrator-config`, not this skill.
Measured on a working sandbox: **32 tools**, all read-only. A tool whose name is
missing after a restart is the server failing to start. The usual cause is the
SDK: `python3 -c 'import mcp'` fails, and `pip install mcp` fixes it. Otherwise
re-run `make-ao-mcp.sh <env>`, which tests the AO login before it registers
anything.
### Grafana Cloud
Verify the Grafana MCP server starts and can reach the Grafana Cloud instance:
```bash
python3 - <<'PY'
import json, subprocess, sys, os
vault_pass = os.path.expanduser("~/secrets/.vault_pass_sales_demos")
vault_id = f"sales.demos@{vault_pass}"
import yaml
raw = subprocess.check_output(
["ansible-vault", "view", "playbooks/group_vars/all/secrets.yml",
"--vault-id", vault_id], text=True)
secrets = yaml.safe_load(raw)
url = secrets["grafana_cloud_url"]
token = secrets["grafana_cloud_sa_token"]
p = subprocess.Popen(
["uvx", "mcp-grafana"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
text=True, env={**os.environ, "GRAFANA_URL": url,
"GRAFANA_SERVICE_ACCOUNT_TOKEN": token})
send = lambda o: (p.stdin.write(json.dumps(o)+"\n"), p.stdin.flush())
send({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"verify","version":"0"}}})
info = json.loads(p.stdout.readline())["result"]["serverInfo"]
send({"jsonrpc":"2.0","method":"notifications/initialized"})
send({"jsonrpc":"2.0","id":2,"method":"tools/list"})
tools = json.loads(p.stdout.readline())["result"]["tools"]
send({"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"list_datasources","arguments":{}}})
got = json.loads(p.stdout.readline())
p.terminate()
ok = len(tools) >= 10 and "result" in got
print(f"server : {info.get('name')} {info.get('version')}")
print(f"tools exposed : {len(tools)}")
print(f"live call : {'list_datasources returned data' if 'result' in got else got}")
print("\nGRAFANA MCP VERIFIED" if ok else "\nVERIFICATION FAILED — do not report success")
sys.exit(0 if ok else 1)
PY
```
Finally, confirm the client sees all servers:
```bash
claude mcp list
```
## If it fails
| Symptom | Cause | Fix |
|---|---|---|
| `✔ Connected` but calls fail or return no data | stdio servers (OpenShift, Grafana, AO) start locally; the check mark proves the process launched, not that the cluster is reachable | Verify with `oc whoami` or a live tool call (`namespaces_list`, `nodes_top`); if the cluster is dead, the kubeconfig is stale |
| MCP server shows as failed at startup | Kubeconfig does not exist yet | Run the generator, then restart Claude Code |
| `401 Unauthorized` on a tool call | Token in the vault is stale or the environment expired | Update `env_secrets.<env>.openshift_api_token`, re-run the generator |
| `could not resolve <env> token` | Vault password wrong, or `env_secrets.<env>` missing | `/sales-demos-first-time` step 2 |
| `dial tcp: no such host` | The RHDP environment has expired | Check `connection.yml` points at a live cluster — both had expired once before (#101) |
| Tools present but every call fails | Kubeconfig points at a different cluster than you think | `oc whoami --show-server` with `KUBECONFIG` set |
| AAP MCP returns `503` | Route admitted, pod not serving yet | Wait — `oc get deploy aap-mcp -n aap`; this is normal for ~60s after deploy |
| `aap-<env>` still dials the previous cluster after a restart, though `.aap/<env>.url` is current | A pre-#515 local-scope registration outranks `.mcp.json` (#603) | `claude mcp remove aap-<env> -s local` from the main checkout, restart. `check-mcp-staleness.sh <env>` names every such shadow |
| AAP MCP returns `401` to a request with **no** `Authorization` header | The command is wrong, not the server: the endpoint requires the bearer token (#594) | Use the Verify → AAP block as written. Do **not** mint a new token for this |
| AAP MCP returns `401` **with** `Authorization: Bearer $(cat .aap/<env>.token)` | Token expired or deleted | Re-create it: `bash utilities/make-aap-mcp.sh <env>`, then restart Claude Code. Retire the old token by hand; these do not clean themselves up |
| AAP MCP write tools missing | `aap_mcp_allow_write_operations` is false for this environment | Intentional on `demo`. Changing it needs a delete-and-recreate — re-run `mcp_server.yml`, which handles that |
| `npx: command not found` | Node not installed | See preflight; a standalone binary is the alternative |
| `no aap-mcp route` from make-aap-mcp.sh | MCP server not deployed | Run `/sales-demos-setup` or `playbooks/mcp_server.yml` first |
| Portal MCP returns `401 Illegal token` | Stored token no longer matches the `portal-mcp-token` Secret — usual cause is the portal being redeployed | `bash utilities/make-portal-mcp.sh <env>` re-reads the Secret; only re-run `portal.yml` if the Secret itself is missing |
| Portal MCP returns `406 Not Acceptable` | The request omitted `Accept: application/json, text/event-stream` — the server is fine (#789) | Fix the command, not the server. Use the Verify → Portal block as written |
| `no portal route` from make-portal-mcp.sh | Portal not deployed | Run `/sales-demos-setup` or `playbooks/portal.yml` first |
| Portal MCP `no such host` | RHDP environment expired | Same as OpenShift — repoint and re-bootstrap |
| Grafana `grafana_cloud_url not set` | Vault keys missing or still CHANGEME | `ansible-vault edit` and add real values — see the [grafana plan](https://ericcames.github.io/sales.demos-docs/plan/grafana-plan/) |
| Grafana MCP tools present but calls fail | Token expired or revoked | Create a new SA token in the Grafana Cloud UI, update the vault |
| `uvx: command not found` | uv not installed | See preflight; install from https://docs.astral.sh/uv/ |
Never paste a live token into a commit message, issue, or PR. This repo is
public — see `CLAUDE.md`.