Repository navigation
feat(init): set up the Netlify MCP server for the project - #8638
domitriusclark wants to merge 15 commits into
Conversation
init now runs `claude mcp add --scope local` for the hosted server, so nothing lands in the repo. Agent setup also skips the home directory and folders with no project marker, which stops skills leaking into ~/.claude.
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Comment |
📊 Benchmark resultsComparing with 2101508
|
commit: |
Pick the agent running init, or ask a person with a checkbox preticked from their editor terminal and project folders. Claude, Codex, OpenCode and VS Code get the server at user scope through their own CLIs; other agents get a pinned add-mcp command. Regenerate init docs.
Save each prompted yes or no in the global config, keyed by agent and server URL, so reruns only ask about new agents. --reset-mcp-setup asks again. Agent-driven runs are unchanged.
The project-marker check skipped skills in an empty folder, the usual start for an agent building a new site. Block only the home directory and the file system root, and apply that guard to skills, not MCP.
| store: () => Promise<McpChoiceStore> | ||
| }): Promise<Selected> => { | ||
| const drivingAgent = getDrivingAgent(env) | ||
| if (drivingAgent) { |
There was a problem hiding this comment.
[blocking] An agent-driven run adds the server at user scope with no prompt, and ignores a saved "declined".
Say someone unticks Claude Code in the checkbox, then later Claude Code runs netlify init for them. It gets added anyway. --reset-mcp-setup doesn't reach this branch either. And the header in agent-detection.ts says its result is "never for authorization" — this is the thing deciding whether to write to someone's user config.
| chalk.cyanBright.bold([binary, ...args].map(shellQuote).join(' ')) | ||
|
|
||
| const runNative = async (binary: string, args: string[], cwd: string) => { | ||
| const result = await execa(binary, args, { cwd, reject: false, timeout: NATIVE_TIMEOUT_MS }) |
There was a problem hiding this comment.
[blocking] On Windows this can run a claude.cmd (or codex.cmd, code.cmd) from the project folder instead of the one on PATH.
execa goes through cross-spawn, which resolves the command from cwd, and the Windows lookup checks the current directory before PATH. So a repo with a claude.cmd at the root runs it when someone runs netlify init in their clone, without a prompt if an agent is driving.
| binary: 'opencode', | ||
| drivenBy: ['opencode'], | ||
| projectMarkers: ['opencode.json', 'opencode.jsonc', '.opencode'], | ||
| native: { add: ['mcp', 'add', NETLIFY_MCP_SERVER_NAME, '--url', NETLIFY_MCP_URL] }, |
There was a problem hiding this comment.
[blocking] Does opencode mcp add take a name and --url? The OpenCode CLI docs show it as an interactive wizard with no flags, and the tests mock execa, so nothing here runs the real thing.
| if (existing.missingBinary) { | ||
| return 'printed' | ||
| } | ||
| if (existing.exitCode === 0) { |
There was a problem hiding this comment.
[consider] Any existing netlify entry counts as present, whatever URL it points at, and in the checkbox flow that gets saved as accepted, so they're not asked again. Only the legacy host gets a note.
| : {}), | ||
| } | ||
| const childProcess = execa(cliPath, ['init', '--manual', ...flags], { cwd, env }) | ||
| const mcpFlags = claudeBin ? [] : ['--skip-mcp-setup'] |
There was a problem hiding this comment.
[consider] Every run without the fake claude adds --skip-mcp-setup, so nothing checks that --skip-agent-setup on its own now skips MCP too. --reset-mcp-setup is only tested by calling setupAgentMcp directly, not through the command.
|
|
||
| const inEditorTerminal = (env: NodeJS.ProcessEnv): boolean => env.TERM_PROGRAM === 'vscode' | ||
|
|
||
| export const MCP_AGENTS: McpAgent[] = [ |
There was a problem hiding this comment.
[consider] This is a third list of agents to keep in step, next to AGENT_DIRECTORY_BY_DRIVING_AGENT in agent-skills.ts and the name table in agent-detection.ts, with drivenBy mapping between them by hand.
| } | ||
| } | ||
|
|
||
| const isOnPath = async (binary: string, env: NodeJS.ProcessEnv): Promise<boolean> => { |
There was a problem hiding this comment.
[consider] This is a hand-rolled which, PATHEXT and all. I didn't find an existing helper for it, so it's cross-platform code we'd own from here.
| ...agents.flatMap(({ id }) => ['-a', id]), | ||
| ].join(' ') | ||
|
|
||
| const shellQuote = (arg: string): string => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`) |
There was a problem hiding this comment.
[nit] shellQuote uses POSIX single quotes, so the printed code --add-mcp '{...}' fallback won't paste into cmd on Windows.
| const runNative = async (binary: string, args: string[], cwd: string) => { | ||
| const result = await execa(binary, args, { cwd, reject: false, timeout: NATIVE_TIMEOUT_MS }) | ||
| return { | ||
| missingBinary: (result as { code?: string }).code === 'ENOENT', |
There was a problem hiding this comment.
[nit] Other spots check ENOENT with an isErrnoException guard (src/utils/shell.ts:192) rather than a cast.
Agent-driven runs now look up and print the add command instead of writing user config, and honor a saved no and --reset-mcp-setup. Agent binaries resolve from absolute PATH entries only, so a project-local claude.cmd never runs. A server on another URL is reported, not counted as present.
A ticked agent whose netlify server was left unchanged is no longer saved as accepted. Clarify the message for non-hosted entries, note when VS Code or OpenCode may have replaced one, and drop a dead branch.
Default to project scope: Claude via claude mcp add --scope local, other agents via a printed add-mcp project command. --mcp-scope user keeps the user-wide path. Check every scope and plugin for an existing Netlify server first, save choices in .netlify/state.json, skip MCP in ~ and /, and match the hosted URL exactly.
Agent runs now install at the chosen scope (project by default) after the same existing-server check as a ticked agent. A saved no and --reset-mcp-setup still apply, and nothing goes user-wide without --mcp-scope user.
Agents with no lookup get a printed command instead of a silent replace; project scope never prints -g and reports Windsurf and Cline as unsupported; Codex is checked with mcp list --json across all names; --reset-mcp-setup only deletes an existing saved choice. Tests cover the real .netlify/state.json.
--reset-mcp-setup in an agent run now bypasses a saved no in memory instead of deleting it, so agent runs never write .netlify/state.json.
Fix the Windows PATHEXT casing in tests and make the relative-PATH test able to fail. Key saved choices by scope and remember kept servers. An unmapped agent with a terminal gets the checkbox. Use isexe, and test the home guard and --reset-mcp-setup through the init command.
seancdavis
left a comment
There was a problem hiding this comment.
a couple of blockers and then a bunch of other nits
| log( | ||
| `${agent.label} already has a Netlify MCP server (${chalk.cyan(name)}) that isn't ${chalk.underline(NETLIFY_MCP_URL)}, so another one wasn't added.`, | ||
| ) | ||
| log(` To switch, remove ${chalk.cyan(name)} in ${agent.label} and run ${netlifyCommand()} init again.`) |
There was a problem hiding this comment.
[fix] Now that kept is saved, this hint leads nowhere. After removing the other server, the next init filters the agent out as already decided and prints "Using your saved choices" instead. Only --reset-mcp-setup gets back to it.
| printCommand(agent, scope, ` Add it yourself with`) | ||
| return 'failed' | ||
| } | ||
| if (lookup && isExisting(lookup)) { |
There was a problem hiding this comment.
[fix] claude mcp list covers every scope, so with --mcp-scope user a project-local netlify counts as present. Nothing gets added user-wide, and accepted is saved for user scope, so it isn't offered again.
| return { | ||
| selection: 'driving', | ||
| agents: [agent], | ||
| declined: !reset && savedChoice(store, agent, scope)?.choice === 'declined', |
There was a problem hiding this comment.
[consider] With --reset-mcp-setup in an agent run, the saved "no" is skipped and Claude gets the server, but the "no" stays. The next run prints "You chose not to add the Netlify MCP server" for a server that's there, and the flag's help says it forgets saved choices.
| expect(logged()).toContain(`claude ${claudeAdd('local').join(' ')}`) | ||
| }) | ||
|
|
||
| test.skipIf(tempOnAnotherDrive)('never runs an agent binary found through a relative PATH entry', async () => { |
There was a problem hiding this comment.
[consider] Both of these skip on the Windows runner (workspace on D:, temp on C:). The CI log shows 2 skipped for this file, so Windows, where the cwd lookup was the problem, has no test behind the absolute-PATH filter.
| for (const binary of binaries) { | ||
| const file = join(bin, executableName(binary)) | ||
| await writeFile(file, '') | ||
| await chmod(file, 0o755) |
There was a problem hiding this comment.
[consider] Every fake binary gets chmod 755, and nothing puts a non-executable file or a directory named claude on PATH, so swapping isexe for an exists check still passes.
| ): void => { | ||
| for (const agent of offered) { | ||
| const status = statuses[agent.id] | ||
| if (status === 'failed') { |
There was a problem hiding this comment.
[nit] When an add fails during --reset-mcp-setup, this skips the save and the old declined survives, so the agent stays hidden on the next run.
| } | ||
|
|
||
| const classify = (servers: { name: string; target: string }[]): Lookup => { | ||
| const ours = servers.find(({ target }) => urlsIn(target).some(isOurUrl)) |
There was a problem hiding this comment.
[nit] The status column gets dropped, so a netlify entry in .mcp.json that's still ⏸ Pending approval counts as present and gets saved as accepted.
|
|
||
| const OUR_URL = new URL(NETLIFY_MCP_URL) | ||
|
|
||
| const isOurUrl = (url: URL): boolean => |
There was a problem hiding this comment.
[nit] Query, fragment and credentials aren't compared, and nothing tests a different port, scheme or path against this.
| return 'present' | ||
| } | ||
| log( | ||
| `${agent.label} already has a Netlify MCP server (${chalk.cyan(name)}) that isn't ${chalk.underline(NETLIFY_MCP_URL)}, so another one wasn't added.`, |
There was a problem hiding this comment.
[nit] name comes straight from the agent's list output, so a repo's .mcp.json can put control characters in front of a :netlify suffix and they print as-is.
| ).resolves.toMatchObject({ agents: { vscode: 'printed' } }) | ||
|
|
||
| expect(execaMock).not.toHaveBeenCalled() | ||
| expect(logged()).toContain('code --add-mcp') |
There was a problem hiding this comment.
[nit] Only checks for code --add-mcp, so a quoting bug in the JSON argument would still pass.
Summary
netlify initnow adds the Netlify MCP server for the project. Claude is installed withclaude mcp add --scope local, and every other agent gets a printed add-mcp command, after a check for an existing Netlify server. Project scope matches whatinitsets up;--mcp-scope userwidens it.Sean, two calls are still open before this leaves draft:
init, after the same existing-server check a ticked agent gets. Consent follows the skills model: the person raninit, and--skip-mcp-setup/--skip-agent-setupopt out. Detection only picks which agent's config to write. Claude's--scope localentry is stored in~/.claude.jsonunder the project's path, so it's a user-level file scoped to the project. If you're not OK with it, agent runs go back to printing the command. If you are, I'll update theagent-detection.tsheader so it says this instead of being contradicted.initconfigure the other agents itself? That meansadd-mcp@2.4.1as a pinned dependency behind one module of ours, only if its source passes three checks: it never overwrites an existing entry, it refuses malformed or commented files, and it never runs its own prompts. Until then, those agents get a printed command.When a person runs
initin a terminal, they get a checkbox of agents that support the chosen scope. Agents found from the editor terminal or project folders are preticked; other installed agent CLIs are listed unticked.claude mcp add --transport http --scope local netlify https://mcp.netlify.com/mcp. Nothing is written to the repo.npx -y add-mcp@2.4.1 https://mcp.netlify.com/mcp --name netlify -y -a <agent>and names the project file it writes.--mcp-scope user.-gis never printed at project scope.--mcp-scope user, Claude and Codex run their own CLIs after the existing-server check. VS Code and OpenCode can't be checked first, so their command is printed with a note that running it replaces an existingnetlifyentry..netlify/state.jsonunderagentMcpSetup.<scope>.<agent>, as accepted, declined or kept (an existing server left alone). A rerun only asks about new agents, and each scope keeps its own answer.--reset-mcp-setupasks again.When an agent runs
init, an agentinitknows (Claude Code, Codex, Cursor and the others inMCP_AGENTS) gets no prompt. Claude is installed at project scope after the existing-server check; if the check fails, nothing is installed. Other agents get the printed command. A saved "no" for that scope is honored,--reset-mcp-setupskips it for that run without deleting it, and nothing goes user-wide without--mcp-scope user. Agent runs never write.netlify/state.json. An agentinitdoesn't know gets the checkbox when a terminal is attached; without one, it gets the generic add-mcp command with how to run it without its questions (-y -a <agent>,list-agents).Existing servers are left alone. Claude is checked with
claude mcp list(every scope and plugin servers; unapproved.mcp.jsonservers aren't connected), Codex withcodex mcp list --json. The hosted URL is matched exactly. Anynetlifyorplugin:*:netlifyserver, a Netlify MCP host, or@netlify/mcpcounts as an existing Netlify server, under any name, and is reported instead of duplicated.Skills and MCP are skipped when
initruns from the home directory or the file system root. Agent CLIs are found only through absolute PATH entries, usingisexe.Tests
npm exec vitest -- run tests/unit/utils/init/: 120 passed, including saving and resetting against a real.netlify/state.jsonnpm exec vitest -- run tests/integration/commands/init/init.test.ts: 12 passed, using a fakeclaudeon PATH, includinginitfrom the home directory and a saved "no" held until--reset-mcp-setupRisk
claude mcp listhealth-checks every approved server and can take up to 30s.initknows skips the prompt even with a terminal attached, which Copilot and Cursor need since they run commands in the editor's terminal. A person whose shell setsNETLIFY_AGENTorCLAUDE_CODE_CHILD_SESSION=1gets Claude configured for the project without being asked..netlify/state.json, which can be an ancestor folder's in a monorepo.claudeand skip there. The two relative-PATH unit tests skip when the temp folder is on another drive, as on the Windows runner.Related: rerunning
initon a project linked without git creates a duplicate site today; the fix is #8646.Rollback: revert this PR. Anyone who already has the entry can remove it with
claude mcp remove --scope local netlify, or--scope userif they used--mcp-scope user.docs/commands/init.mdregenerated)