Thanks for your interest in contributing. This guide covers the development setup and, more importantly, the handful of conventions in this repository that are load-bearing for safety — the ones where doing the obvious thing quietly removes a control.
This server administers clusters. It creates and deletes buckets, manages users, triggers failovers, and stands up and tears down Capella infrastructure, and in its intended deployment it does so unattended, driven by an agent with no human at the moment of action. That shapes every convention below.
- Python 3.10+
- uv — used by the rest of the Couchbase MCP projects
- Git
- Node (optional) — only for the admin console's frontend tests, which skip without it
git clone https://github.com/Couchbase-Ecosystem/couchbase_admin_mcp_server.git
cd couchbase-admin-mcp-server
uv sync --extra devExternal contributors do not have commit access. Fork the repository and clone your fork.
The dev extra deliberately includes Flask and uvicorn even though they are optional at
runtime. Without them the console and HTTP-transport tests skip silently, which is how
POST /api/call drifted away from the MCP dispatch twice without anyone noticing.
uv run pre-commit install
uv run pre-commit run --all-filesuv run python server.py
# [couchbase-admin-mcp] REFUSING TO START: CB_ADMIN_PROFILE is not set...That is not a bug. CB_ADMIN_PROFILE has no default, because the two deployments this
server supports have opposite security postures and guessing between them is how you get an
unauthenticated admin API on a network interface. For local work:
export CB_ADMIN_PROFILE=workstation
export CB_CONNECTION_STRING=couchbase://localhost
export CB_USERNAME=Administrator
export CB_PASSWORD=passwordSee .env.example, which is long on purpose — it documents why each variable exists, not
just its name.
uv run ruff check . # check
uv run ruff check . --fix # fix
uv run ruff format . # formatBoth must be clean before a PR. Pre-commit runs them on every commit with the same pinned ruff version as CI, so the two cannot disagree.
uv run pytest # everything (~60s)
uv run pytest -m "not live" # skip the 9 tests that bind a local port (~35s)
uv run pytest -m live # only those
uv run pytest tests/test_capella.py # one fileThe suite runs in randomised order by default (pytest-randomly). That is deliberate:
several modules read environment variables at import time, and order-dependent tests here
have hidden real bugs before. If a test passes in file order and fails randomised, the test
is wrong, not the runner.
This is the one testing rule that matters most here, and it is not negotiable for security-relevant code.
Two mutation harnesses re-introduce specific bugs and assert the suite catches them:
uv run python scripts/mutation_rounds_1_3.py # 25 mutations
uv run python scripts/mutation_round_4.py # 20 mutationsBoth must report all mutations caught. They exist because a green suite proved nothing on several occasions during this project's security review:
- Five tests asserted on a helper while the composition that actually enforces the control went uncovered. Deleting the enforcement left the suite green.
- One test passed only because it exercised a fallback path rather than the fix.
- A logging control was tested by calling the helper directly; the calling site swallowed
the helper's refusal and attached the handler anyway. The suite was green while
ln -sf /dev/null <logfile>discarded the audit trail.
If you add a guard, add a mutation entry for it. If your test still passes with the guard deleted, it is not testing the guard.
Please read this section before adding a tool. Each item is here because breaking it caused a real finding.
readOnlyHint / destructiveHint / idempotentHint decide whether a tool is loaded in
read-only mode, whether the confirmation gate applies, and whether the hard ceiling
refuses it. A write tool annotated readOnlyHint=True is a silent authorization bypass.
data = form_data(args) # NO — mass assignmentCouchbase settings endpoints accept far more fields than any tool exposes, so an invented or hostile key becomes a real configuration change. Use the schema-derived allow-list:
refusal = refuse_undeclared(args, name, TOOLS, endpoint="/settings/indexes")
if refusal is not None:
return refusal
data = form_data_declared(args, name, TOOLS)It refuses rather than dropping, because a silently dropped key looks like success to an agent, which then reports a setting as applied when it never was.
assert_egress_allowed(args["uploadHost"], field="uploadHost", tool=name)Several tools make Couchbase open the connection, including one that uploads a full
diagnostic bundle from every node. 169.254.169.254 returns cloud IAM credentials, and
that denial cannot be configured away. For nested payloads use
guard_nested_host_fields(), which walks the whole structure — the previous
single-path version failed open on five different shapes.
ok_allow_secrets() is the single sanctioned exception, for the one tool whose entire
purpose is returning a generated credential. Everything else goes through ok().
There are two dispatch paths into the handlers: server.py's MCP dispatch and the
console's POST /api/call. The console re-implemented the confirmation and ceiling logic
and drifted twice — first letting a caller self-promote out of the gate with
{"automation": true}, then testing the hard ceiling only when automation was on, so
with it off a caller's own confirm: true satisfied it. Both callers now ask
authz.evaluate().
Including refusals. In an unattended deployment the log is the only accountability, and a path that acts without recording it defeats the entire model. The console emitted nothing at all for a while, which made it the one way to act untraced.
Every path in handlers/capella/spec.py carries a provenance tag:
| Tag | Meaning |
|---|---|
[TF] |
Read from the Terraform provider's Go string literals |
[DOC] |
From Couchbase's published API reference / OpenAPI document |
[LIVE] |
Path confirmed against a real organization |
[LIVE+METHOD] |
Path and method both confirmed |
[PAT] |
Inferred from a confirmed sibling — none currently remain |
Then verify it:
export CB_CAPELLA_API_KEY='the-key-secret'
uv run python scripts/verify_capella_paths.pyRead-only by default: GETs run for real, writes are probed with OPTIONS. This is not
ceremony — it found that GET .../clusters/{id}/appservices does not exist (that path is
POST-only; App Services are listed organization-wide), which had made all 21 App Services
operations unreachable and was invisible from reading the code.
Dockerfile and pyproject.toml's force-include both list the top-level modules
explicitly. They once omitted three, so the built image raised ModuleNotFoundError on
startup with none of the security controls present. tests/test_packaging.py derives the
requirement from server.py's real imports and will fail if you forget.
gui/static/index.html is JSX compiled in the browser by a vendored Babel under
gui/static/vendor/. Do not reintroduce a CDN <script>: the console has to work in an
air-gapped network, and an admin tool should not fetch its JavaScript from a third party at
request time. It was also served with no transpiler at all for a while, which meant the
page rendered blank and no test noticed.
mcp 2.0 renamed Tool.inputSchema to input_schema and annotations.readOnlyHint to
read_only_hint. Constructions are fine — 2.x keeps the camelCase spellings as pydantic
aliases, so the several hundred Tool(inputSchema=...) calls need no change. Only attribute
reads break, and there were nineteen of them.
Use mcp_compat.input_schema(tool), is_read_only(tool), is_destructive(tool),
is_idempotent(tool). tests/test_mcp_compat.py parses every module and fails on a direct
read, so a twentieth cannot appear quietly.
The pin is mcp>=1.10,<2.0 for a separate reason, documented in pyproject.toml: 2.x also
removed Server's decorator registry and the request_ctx contextvar that
auth/request_auth.py uses to resolve claims inside the dispatch task.
tests/test_handler_contract.py discovers every module in handlers/ and every tool in its
TOOLS, then asserts the properties that are dangerous to get wrong and invisible when you
do: valid schema, required ⊆ properties, annotations present and non-contradictory,
globally unique namespaced names, every declared tool actually routed by handle(), no
handler raising instead of returning err(), and no data-mutating SQL++ embedded in source.
Two things to know before you touch it:
- Add a module to
handlers/andMODULE_NAMESmust grow with it. A test compares the two and fails if they diverge — otherwise a new group is silently skipped by ~140 parametrisations that all still pass. - The
no_clusterfixture patches each seam on every module that imported it, including the derived version predicates (is_8x,is_7x), not just the primitives they call. Patching onlyhandlers.sharedis not enough: some suites reload that module, after which a handler's imported function still closes over the old module's globals. This surfaced as a failure in one module, in full-suite runs only.
couchbase-admin-mcp-server/
├── server.py # MCP entry point, tool dispatch, HTTP transport
├── authz.py # THE confirmation/ceiling policy — both dispatch paths
├── audit.py # Structured audit record, one per decision
├── profile_config.py # workstation | enterprise, validated at startup
├── tls_config.py # Transport encryption, both termination shapes
├── deployment.py # Capella vs self-managed capability gating
├── logging_config.py # Redaction, CR/LF flattening, private log files
├── auth/
│ ├── oidc.py # Token validation, JWKS, refresh budget
│ ├── request_auth.py # Claims resolved inside the dispatch task
│ ├── scope_gate.py # Per-tool scope enforcement
│ └── session.py # Signed cookie sessions for the console
├── handlers/
│ ├── shared.py # HTTP client, ok()/err(), redaction, guards
│ ├── egress.py # Cluster-egress allowlist
│ ├── capella/
│ │ ├── spec.py # Declarative v4 op registry (61 ops)
│ │ ├── client.py # v4 client, cursor pagination, retries
│ │ ├── guardrails.py # Org pin, project allowlist, ceiling, TTL reaper
│ │ └── environment.py # Composite ensure/park/resume/teardown/reap
│ └── *.py # ns_server tool groups (buckets, cluster, xdcr, ...)
├── gui/ # Flask console + vendored frontend runtime
├── scripts/
│ ├── verify_capella_paths.py # Probe v4 paths against a live organization
│ └── mutation_*.py # Mutation harnesses (45 mutations)
└── tests/ # 636 tests
uv run ruff check . && uv run ruff format --check .
uv run pytest
uv run python scripts/mutation_rounds_1_3.py
uv run python scripts/mutation_round_4.pyThen open a PR describing:
- What the change does
- Why it is needed
- How you tested it — and for anything security-relevant, which mutation entry proves the test would fail if the control were removed
Commit messages in this repository tend to explain the reasoning, not just the change —
what was wrong, why it mattered, and what was considered and rejected. git log is the
best guide to the house style.
- Model Context Protocol
- Couchbase Management REST API
- Capella Management API v4
- Ruff
RUNBOOK.md— the operator guide: both deployment shapes, the environment lifecycle, a worked CI pipeline, troubleshootingdocs/ARCHITECTURE.mdanddocs/CB_Admin_MCP_Architecture.docx— the control design and the verification state that produced the conventions above. Section 9 of the document records what has been measured and on what datehandlers/capella/spec_pending.py— Capella v4 operations written but deliberately NOT shipped, each with the reason inline. Read this before adding a v4 path
Open an issue. For a suspected security issue, please report it privately rather than in a public issue.