Skip to content

feat(claude): Cloudflare docs MCP connector - #81

Open
alycda wants to merge 6 commits into
mainfrom
feat/cloudflare-docs-mcp
Open

feat(claude): Cloudflare docs MCP connector#81
alycda wants to merge 6 commits into
mainfrom
feat/cloudflare-docs-mcp

Conversation

@alycda

@alycda alycda commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Wires Cloudflare's documentation MCP server into ~/.claude.json from nix. This is the connector shape — a remote streamable-HTTP MCP server Claude Code talks to directly — not the cf-now/s3-now shape of a CLI wrapped in a skill.

Why this is a different build from cf-now (#47)

cf-now drives aws against an R2 endpoint and is inert without awscli2 (not in the closure) and R2 credentials. A connector needs neither: no node, no npx, no mcp-remote stdio proxy, no packages at all. That's load-bearing rather than incidental — the dev container has no JS runtime, and the native type = "http" transport needs none.

Why only the docs server

Probing all 17 *.mcp.cloudflare.com/mcp endpoints on 2026-08-05:

Server Status
docs 200 — real handshake, serverInfo: docs-ai-search 0.4.10
bindings, builds, observability, radar, containers, browser, graphql, autorag, auditlogs, dns-analytics, … 401 www-authenticate: Bearer realm="OAuth"

The docs server is the only one that answers unauthenticated, which makes it the one that proves the entire declarative path — nix → activation → ~/.claude.json → a live remote server — with zero credentials. The account-scoped server needs an agenix-carried API token plus a headersHelper and lands separately. Splitting there means this half is verifiable today rather than blocked on a token that doesn't exist yet.

Notes for the token tier that follows

  • OAuth isn't just inconvenient in a container, it's unavailable — no browser to complete the flow. The bearer-token header is the only viable path there.
  • Claude Code used to answer that 401 by starting OAuth and discarding the configured auth header, so token mode silently never applied (cloudflare/mcp#95). Fixed in 2.1.141; the image ships 2.1.220.
  • The registration is generated via builtins.toJSON rather than committed as static JSON under tools/claude/ (the settings.json / plugin-catalog shape) because a headersHelper value must be a store path, which a static file can't express — the alternative would need rewriting into nix the moment the token tier lands.

Merge safety

~/.claude.json is runtime state Claude Code owns and rewrites (~53KB of project history), so it's deep-merged, never linked — the claudeManagedSettings idiom from claude-code.nix, including tmp-file-then-mv-on-success so a failed jq can't truncate it.

Verification

  • statix check . clean
  • nix build .#homeConfigurations."alyssa@dev".activationPackage succeeds
  • Merging the generated file into a copy of the real ~/.claude.json: every other key byte-identical (SHA-256 match on del(.mcpServers)), all 5 project entries preserved, second merge a no-op
  • Not yet activated — the live ~/.claude.json is untouched; the server appears in /mcp after the next home-manager switch or container restart

🤖 Generated with Claude Code

A connector is not a CLI wrapped in a skill. cf-now (#47) and s3-now (#43)
wrap `aws` against an R2/S3 endpoint and need credentials to do anything at
all; a *connector* is a remote streamable-HTTP MCP server Claude Code talks
to directly. So this module installs no packages: no node, no npx, no
`mcp-remote` stdio proxy. That is not incidental - the dev container has no
JS runtime, and the native `type = "http"` transport needs none.

Only the documentation server is wired, because it is the only Cloudflare
MCP server that answers unauthenticated. Probing all 17 on 2026-08-05,
docs.mcp.cloudflare.com/mcp returns 200 and a real handshake
(serverInfo: docs-ai-search 0.4.10) while every other *.mcp.cloudflare.com
returns 401 with `www-authenticate: Bearer realm="OAuth"`. That makes it the
one server that proves the entire declarative path - nix -> activation ->
~/.claude.json -> a live remote server - with zero credentials. The
account-scoped server needs an agenix-carried API token plus a headersHelper,
and lands separately; splitting there means this half is verifiable now
rather than blocked on a token that does not exist yet.

Two things learned while building it, both recorded in the module:

- OAuth is not merely inconvenient in a container, it is unavailable - there
  is no browser to complete the flow. The token header is the only viable
  auth path there, which is worth knowing before the token tier is written.
  Claude Code also used to answer that 401 by starting OAuth and discarding
  the configured header, so token mode silently never applied
  (cloudflare/mcp#95, fixed in 2.1.141; the image ships 2.1.220).

- The registration is generated with builtins.toJSON rather than committed as
  static JSON under tools/claude/ (the shape settings.json and the plugin
  catalog use). A headersHelper value must be a store path, which a static
  file cannot express, so the alternative would have to be rewritten into nix
  the moment the token tier lands.

~/.claude.json is runtime state Claude Code owns and rewrites, so it is
deep-merged rather than linked - the claudeManagedSettings idiom from
claude-code.nix, including writing a tmp file and mv-ing only on jq success
so a failed merge cannot truncate 53KB of project history.

Verified: statix clean; `nix build .#homeConfigurations."alyssa@dev"
.activationPackage` succeeds; merging the generated file into a copy of the
real ~/.claude.json leaves every other key byte-identical, preserves all 5
project entries, and is idempotent on a second merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

⊕ Entity-level changes

docs/solutions/config-errors/agenix-secrets-never-install-without-systemd.md

Status Type Name
+ preamble (preamble)
+ heading agenix secrets silently never install in the container
+ heading Symptom
+ heading Root cause
+ heading Fix
+ heading Scope
+ heading Verifying
+ heading Does anything install secrets at activation time?
+ heading Dry run: lists every secret and its destination, with no side effects
+ heading After a real switch, the file exists rather than the symlink dangling

home-manager/modules/agenix-activation.nix

Status Type Name
+ orphan module-level
+ binding identityArgs
+ binding installOne
+ orphan module-level
+ binding home.activation.agenixInstallSecrets
+ orphan module-level

home-manager/modules/common.nix

Status Type Name
Δ binding imports

home-manager/modules/tools/agents.nix

Status Type Name
Δ binding age.secrets

home-manager/modules/tools/claude-mcp.nix

Status Type Name
+ orphan module-level
+ binding tokenFile
+ orphan module-level
+ binding cloudflareHeaders
+ binding managed.mcpServers
+ orphan module-level
+ binding managedJson
+ orphan module-level
+ binding home.activation.claudeManagedMcpServers
+ orphan module-level

home-manager/profiles/dev.nix

Status Type Name
- orphan module-level
+ orphan module-level
+ binding imports

secrets/personal/cloudflare-api-token.age

Status Type Name
+ chunk lines 1-9

secrets/personal/r2-config.age

Status Type Name
+ chunk lines 1-11

secrets/personal/r2-credentials.age

Status Type Name
+ chunk lines 1-11

secrets/secrets.nix

Status Type Name
+ orphan module-level
+ binding personal/cloudflare-api-token.age.publicKeys
+ orphan module-level
+ binding personal/r2-credentials.age.publicKeys
+ binding personal/r2-config.age.publicKeys

Summary: 36 added, 2 modified, 1 deleted across 10 files (11 added orphans, 1 deleted orphan)

functions and classes, not lines · sem

@alycda
alycda marked this pull request as ready for review August 5, 2026 06:16
alycda and others added 4 commits August 5, 2026 06:19
Supersedes the account-level "Cloudflare Developer Platform" connector. That
one reports `Scope: claude.ai config` - it lives on the Anthropic account, so
no checkout can see it, `claude mcp remove` cannot reach it (it takes only
local/user/project scopes), and there is no URL-edit subcommand at all. It is
also broken: it points at the retired /sse alias and fails HTTP 405, having
authenticated fine and then POSTed to a transport that endpoint no longer
speaks. Declaring the same server here at /mcp makes it reviewable and
fixable in a diff instead of a web form.

No `age.secrets` entry yet, deliberately. The ciphertext does not exist -
there is no token minted - and an entry pointing at a missing file fails
`nix flake check` for every configuration, which is precisely the breakage
#79 finished undoing. So the helper resolves agenix's default path via
config.age.secretsDir and degrades to `{}` when nothing is there, matching
the contract the `linear` headersHelper already runs: the server shows as
unauthenticated in /mcp rather than erroring at startup. Carrying the secret
later is then additive and changes nothing in this file.

Corrects a claim in the previous commit's comment. OAuth is not blocked in a
container by the missing browser - Claude Code prints the URL to open on the
host. It is blocked by the callback: the redirect targets a localhost port
listening inside the container, so a host browser reaches the host's own
localhost unless that port is published. Same conclusion, different reason,
and the wrong reason would send the next reader looking for a browser fix.

Verified: statix and deadnix clean (both gate CI);
`nix build .#homeConfigurations."alyssa@dev".activationPackage` succeeds;
the generated config carries the helper as a store path; the helper emits
`{}` with no token, and the right header from either $CLOUDFLARE_API_TOKEN or
a token file, with the agenix path baking out to
/root/.local/share/agenix/cloudflare-api-token as intended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ctor

Completes the token tier the previous commit deliberately left unwired. The
helper in claude-mcp.nix already resolves agenix's default path and degrades
to {} when nothing is there, so this delivers the value without touching that
module: the attribute name `cloudflare-api-token` produces
~/.local/share/agenix/cloudflare-api-token, which is exactly where the helper
looks. That makes the name load-bearing in a way a build cannot catch -
renaming it unauthenticates the connector silently rather than failing, since
emitting {} for a missing file is the designed behaviour.

Personal-only, so unlike the Linear keys this name needs no directory to
disambiguate it - there is no work Cloudflare account to collide with.
Committed ARMORED for the reason secrets.nix already documents: a binary .age
blob is valid on disk but does not reliably survive the trip into a commit.

Both new entries go inside the existing attrsets rather than as new dotted
assignments - `age.secrets` was already at three entries, and statix's
repeated_keys fires on the third, which is what broke CI in #79.

Recorded in secrets.nix because it will otherwise be assumed: this is NOT the
credential cf-now (#47) needs. R2's S3 API takes a separate R2 API token - an
access-key/secret pair minted in the R2 dashboard, not at /profile/api-tokens
- and the two are not interchangeable in either direction. Scope guidance for
both now lives in the PR discussion.

Verified: statix, deadnix, and `nix flake check --all-systems` all pass (the
flake check being the one that would have caught an age.secrets entry
pointing at a missing file); ciphertext round-trips under the identity at
~/.age/personal-key.txt, whose public key matches the recipient in
secrets.nix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e lack

Every age.secrets entry has been silently failing to arrive in the container.
ragenix's home-manager module does not install secrets from the activation
script - it installs them from a systemd *user* service
(config.systemd.user.services.agenix), and this container has no user systemd
daemon. The unit gets written to ~/.config/systemd/user/agenix.service and
never runs. The mount script is not even realised into the store, since
nothing in the activation closure references it.

Nothing fails. `home-manager switch` exits 0 and the only hint is a line that
reads like boilerplate: "User systemd daemon not running. Skipping reload."

The cost of that silence, found while wiring an unrelated Cloudflare token:
~/.agents/instructions.private.md never existed, so
~/.claude/includes/agents-instructions.private.md has been a dangling symlink
and the @includes line in ~/.claude/CLAUDE.md a dead import. Agents read that
file every session and got no private overlay - for months, because a missing
include renders exactly like an empty one. The two Linear keys sitting in
~/.local/share/agenix are leftovers placed by other means; their mtimes
predate the last switch.

Two choices in the module worth stating:

- The canonical path stays <secretsDir>/<name> even when a secret overrides
  `path`, because consumers hardcode that default (the cloudflare
  headersHelper reads it by name). An override becomes a symlink to the
  canonical file, not a second copy of the plaintext on disk.
- It never aborts activation. A failed decrypt warns and returns 0, because
  activation steps run in order and dying here would skip installPackages -
  the failure shape from #74 that leaves a container with dotfiles and no
  claude/jj/rg. A loud warning is the fix for silence; a hard failure trades
  one broken container for another.

Container-scoped (profiles/dev.nix, so alyssa@dev and alyssa@dev-x86) rather
than shared. The alternative was one module probing whether systemd is usable,
which hands the machine I cannot test from here the job of picking a code
path - and a wrong probe reproduces precisely the silent failure being fixed.
alyssa@work-dev is deliberately NOT covered: it is a Linux container too, but
profiles/work.nix is shared with darwin and deserves the decision made rather
than inherited.

Verified: statix, deadnix and `nix flake check --all-systems` pass;
`home-manager switch -n` lists all seven secrets with their destinations and
leaves no .tmp files and no ~/.aws behind, which the DRY_RUN guard exists to
guarantee - the decrypt cannot use `run`, since it needs its own exit status
and `run` returns 0 under dry-run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two secrets rather than two values: decrypted, each is a valid AWS ini that
agenix drops straight into ~/.aws, so `aws --profile alyssa-r2` works with no
flags - endpoint and region come from the config file - and cf-now's
`aws configure set` setup step disappears entirely.

Split the way the AWS CLI splits them, keys in `credentials` and everything
else in `config`, because the two use different profile header syntax
(`[alyssa-r2]` vs `[profile alyssa-r2]`). Merging them does not error; it
yields a profile the CLI cannot find, which is a worse way to fail.

r2-config holds no credential at all. It is encrypted because it embeds the
Cloudflare account ID in the endpoint URL and this repo is public.

Both take `path` overrides - unavoidable here, since the consumer is the AWS
CLI, which reads two fixed locations and accepts no override short of
$AWS_SHARED_CREDENTIALS_FILE. That is also why the previous commit had to land
first: a `path` override is exactly what was silently doing nothing.

Carried before the consumer exists, the same bet as linear-api-key-personal.
cf-now is still an unmerged PR (#47), but an R2 token that lives only in a
container's runtime dir dies with the container, and minting another is a trip
back to the dashboard.

Scoped tightly on the Cloudflare side, which is worth recording because the
skill does not expect it: the token is Object Read & Write limited to the
cfnow bucket, with the bucket and its 7-day tmp/ lifecycle rule created by
hand in the dashboard. CreateBucket and PutBucketLifecycleConfiguration need
Admin Read & Write, and an admin token could delete the eight unrelated
buckets already in that account. Consequences for #47: setup.sh will fail and
should be skipped, and its ListBuckets auth probe cannot work with a
bucket-scoped token - it needs to become head-bucket, since an auth probe
requiring broader rights than the tool itself is backwards.

Verified: statix, deadnix and `nix flake check --all-systems` pass; both
ciphertexts round-trip under the identity at ~/.age/personal-key.txt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous entry implied agents had been losing real private instructions
for months. They had not: the agent-instructions secret decrypts to a
placeholder, verified once the installer actually ran. The delivery mechanism
was genuinely broken; the payload behind it happened to be empty.

Worth correcting rather than leaving generous, because an overstated symptom
is how a solutions entry stops being trusted - and the severity does not
depend on it. The same mechanism now carries the Cloudflare API token behind
the cloudflare-bindings connector and the R2 credentials behind cf-now, so the
entry now says to judge it by what fails to arrive next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant