feat(claude): Cloudflare docs MCP connector - #81
Open
alycda wants to merge 6 commits into
Open
Conversation
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>
Contributor
⊕ Entity-level changesdocs/solutions/config-errors/agenix-secrets-never-install-without-systemd.md
home-manager/modules/agenix-activation.nix
home-manager/modules/common.nix
home-manager/modules/tools/agents.nix
home-manager/modules/tools/claude-mcp.nix
home-manager/profiles/dev.nix
secrets/personal/cloudflare-api-token.age
secrets/personal/r2-config.age
secrets/personal/r2-credentials.age
secrets/secrets.nix
Summary: 36 added, 2 modified, 1 deleted across 10 files (11 added orphans, 1 deleted orphan) functions and classes, not lines · sem |
alycda
marked this pull request as ready for review
August 5, 2026 06:16
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Wires Cloudflare's documentation MCP server into
~/.claude.jsonfrom 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-nowdrivesawsagainst an R2 endpoint and is inert withoutawscli2(not in the closure) and R2 credentials. A connector needs neither: nonode, nonpx, nomcp-remotestdio proxy, no packages at all. That's load-bearing rather than incidental — the dev container has no JS runtime, and the nativetype = "http"transport needs none.Why only the docs server
Probing all 17
*.mcp.cloudflare.com/mcpendpoints on 2026-08-05:docsserverInfo: docs-ai-search 0.4.10bindings,builds,observability,radar,containers,browser,graphql,autorag,auditlogs,dns-analytics, …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 aheadersHelperand 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
builtins.toJSONrather than committed as static JSON undertools/claude/(thesettings.json/ plugin-catalog shape) because aheadersHelpervalue 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.jsonis runtime state Claude Code owns and rewrites (~53KB of project history), so it's deep-merged, never linked — theclaudeManagedSettingsidiom fromclaude-code.nix, including tmp-file-then-mv-on-success so a failedjqcan't truncate it.Verification
statix check .cleannix build .#homeConfigurations."alyssa@dev".activationPackagesucceeds~/.claude.json: every other key byte-identical (SHA-256 match ondel(.mcpServers)), all 5 project entries preserved, second merge a no-op~/.claude.jsonis untouched; the server appears in/mcpafter the nexthome-manager switchor container restart🤖 Generated with Claude Code