Skip to content

feat(cli): lenz init — wire Lenz into an MCP client - #38

Closed
lenzhq wants to merge 3 commits into
mainfrom
feat/init-command
Closed

feat(cli): lenz init — wire Lenz into an MCP client#38
lenzhq wants to merge 3 commits into
mainfrom
feat/init-command

Conversation

@lenzhq

@lenzhq lenzhq commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Python-side parity for lenzhq/lenz-io-node#38. Same server block, same config locations, same merge rule — a developer who set one machine up with each SDK must not get two different results.

The gap: ~31 MCP connectors created against 2 activations. People connect and never reach a first successful call, and a good share of that is the config file — which one, where it lives on their platform, what shape the server block takes. A Python shop shouldn't need Node installed to answer that.

What it does

lenz init                          # writes ./.mcp.json (Claude Code)
lenz init --client cursor          # writes ./.cursor/mcp.json
lenz init --client claude-desktop  # writes the global Claude Desktop config
lenz init --print                  # print the JSON, write nothing

Writes the block, then verifies the key with one authenticated request so the user learns immediately whether it works. --print needs no key — its purpose is handing someone a config to paste and fill in, and it's also the answer for clients this doesn't know about.

Key resolution is the CLI's existing chain: --api-keyLENZ_API_KEY~/.config/lenz/config.json. The SDK surface is unchanged.

Three deliberate choices

Merging, not overwriting. A developer's MCP config routinely holds several servers, so a setup command that replaced the file would be actively destructive on exactly the machines it exists to help. Only the lenz key is written. A file that exists but doesn't parse as JSON is refused rather than overwritten — guessing could silently discard servers configured by hand, with no way to get them back. Writes go through a same-directory temp file + os.replace, so a crash mid-write can't truncate a config either.

A failed key check is reported as a key problem, not a failed init. The config is already written and correct at that point; saying which of the two broke stops people re-running init at something it cannot fix.

config_path_for takes cwd as a parameter rather than calling Path.cwd(), so the project-scoped clients are testable without chdir'ing the test process.

Structure

init is the odd one out among the verbs — every other command calls the API, this one configures — so it lives in cli/init_cmd.py alongside verify, the other command that owns a workflow rather than one SDK call. The pure merge/path helpers are in cli/mcp_config.py so they're testable without a filesystem.

Verification

pytest green (full suite), ruff check, ruff format --check and mypy src/lenz_io clean. 22 new tests, deliberately parallel to test/cli.test.ts in the Node SDK: merge behaviour (other servers preserved, unrelated top-level keys preserved, re-init replaces rather than duplicates, malformed mcpServers survived), the unparseable-file refusal leaving the file byte-identical, path resolution per client and platform, atomic write leaving no temp files, --print writing nothing, the no-key path, the bad-key path leaving the config in place, and the --json contract. One test asserts the server block matches the Node SDK's byte for byte.

Also smoke-tested through the real installed command: fresh write, re-init, and a merge against a hand-added second server plus an unrelated top-level key — output byte-identical to the Node CLI's.

🤖 Generated with Claude Code

https://claude.ai/code/session_015zMPNusWpZ6dHjEwMAn6GC

Parity with `npx lenz-io init` in the Node SDK (lenzhq/lenz-io-node#38). Same
server block, same config locations, same merge rule — a developer who set one
machine up with each SDK must not get two different results.

The gap it closes: ~31 MCP connectors created against 2 activations. People
connect and never reach a first successful call, and a good share of that is
the config file — which one, where it lives on their platform, what shape the
server block takes. A Python shop shouldn't need Node installed to answer that.

`lenz init` writes the block into Claude Code (./.mcp.json), Cursor
(./.cursor/mcp.json) or Claude Desktop (the platform's global config), then
verifies the key with one authenticated request so the user learns immediately
whether it works. `--print` emits the JSON without touching anything, which is
also the answer for clients this doesn't know about.

It is the odd one out among the verbs — every other command calls the API,
this one configures — so it lives in its own module alongside `verify`, the
other command that owns a workflow rather than one SDK call.

Three things worth stating:

Merging, not overwriting. A developer's MCP config routinely holds several
servers, so a setup command that replaced the file would be actively
destructive on exactly the machines it exists to help. Only the `lenz` key is
written. A file that exists but doesn't parse as JSON is refused rather than
overwritten — guessing could silently discard servers configured by hand, with
no way to get them back. Writes go through a same-directory temp file and
os.replace, so a crash mid-write can't truncate a config either.

A failed key check is reported as a key problem, not a failed init. The config
is already written and correct at that point; saying which of the two broke
stops people re-running init at something it cannot fix.

The config path takes `cwd` as a parameter rather than calling Path.cwd(), so
the project-scoped clients are testable without chdir'ing the test process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zMPNusWpZ6dHjEwMAn6GC
@lenzhq
lenzhq requested a review from paveljor as a code owner August 13, 2026 19:29
Lenz and others added 2 commits August 14, 2026 00:01
Parity half of the same review. The Node SDK carried the write-path bugs; this
side carried the credential one, because both wrote a live bearer token into
./.mcp.json by default and Claude Code's documentation says "Check .mcp.json
into version control so everyone on your team gets the same MCP tools and
services." A setup command whose happy path puts a credential in a file the
docs tell you to commit is handing the user a leak. The documented mechanism
for exactly this case is environment-variable expansion, supported inside
`headers`, and the docs' own example is our line verbatim.

Project-scoped clients now get a reference; --write-key is the opt-out for a
private checkout. The syntaxes are per-client and NOT interchangeable — Claude
Code takes ${VAR}, Cursor takes ${env:VAR} and treats a bare ${VAR} as literal
text — so KEY_PLACEHOLDERS is a table rather than one constant. Claude Desktop
still gets the key itself: launched from the desktop rather than a shell, it
never inherits an exported variable.

Writing a placeholder makes "key verified" a half-truth, so the run prints the
export line with the key in it, and --json reports key_in_config / key_env_var
so a script can tell whether the config is self-contained. That copy lives in
render_success() because Output.json_mode is `json_mode or not isatty()`, which
means CliRunner never reaches the human branch — the same shape the render
tests in test_cli.py already use.

The parity test pinned the server block, the one thing that never drifted,
while SETUP_URL sat at the pre-rename /welcome/setup on the Node side and
printed a 404 on every successful run. Both SDKs now pin the shared constants
and the placeholder table.

write_config already staged its temp file beside the target and cleaned up on
failure; the docstring now says why, since the Node side had staged in the
system temp directory and would have raised EXDEV anywhere /tmp is its own
filesystem. Its 0600 mode is likewise load-bearing rather than incidental, and
now has a test saying so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zMPNusWpZ6dHjEwMAn6GC
…reads

Parity half of the Node change — same two clients, same reasoning, so a
developer who sets one machine up with each still gets one result.

**Claude Desktop wrote a file nothing reads.** claude_desktop_config.json is
documented for local stdio servers; a remote streamable-HTTP server like ours
is added through Settings → Connectors → "Add custom connector". We wrote a
`{"type": "http", "headers": {...}}` entry there, which is not a documented
shape for that file, and it was the one client we handed the literal key to —
so the likely outcome was a live credential sitting somewhere inert. It now
prints the connector steps (MANUAL_CLIENTS) and writes nothing; --json reports
status "manual" with the instructions.

**Codex was missing because its config is TOML.** Added without tomli-w: the
Lenz table is appended as TEXT, because a parse → mutate → re-serialize round
trip drops comments and reflows formatting, handing back a file that is
equivalent and visibly not theirs. Appending is always valid (TOML tables are
order-independent); a second [mcp_servers.lenz] is a duplicate-table error that
stops the whole file parsing, so that raises DuplicateCodexTable and the
command says what to edit.

bearer_token_env_var names the variable in a field of its own, so Codex needs
none of the ${VAR} / ${env:VAR} interpolation the JSON clients require and no
key is written at all. --write-key uses http_headers instead.

write_text_config carries the atomic-replace and 0600 mode over to the TOML
path — one write path, so neither loses the durability or the file mode.

Smoke-tested against the installed `lenz init --client codex` on a real
config.toml holding a comment, a top-level key and another server: all survived
byte-for-byte, and the block is byte-identical to the one the Node CLI writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zMPNusWpZ6dHjEwMAn6GC
@lenzhq

lenzhq commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Closing unmerged — the init CLI is killed before release, same rationale as lenzhq/lenz-io-node#38: first-party registration commands exist for Claude Code and Codex, Claude Desktop is Connectors-only, and Cursor (the one client init uniquely served) is covered by the pasted setup prompt from lenz.io/setup.

@lenzhq lenzhq closed this Aug 13, 2026
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