Skip to content

feat(init): set up the Netlify MCP server for the project - #8638

Draft
domitriusclark wants to merge 15 commits into
mainfrom
claude/mcp-setup-init-aa455c
Draft

domitriusclark wants to merge 15 commits into
mainfrom
claude/mcp-setup-init-aa455c

Conversation

@domitriusclark

@domitriusclark domitriusclark commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

netlify init now adds the Netlify MCP server for the project. Claude is installed with claude 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 what init sets up; --mcp-scope user widens it.

Sean, two calls are still open before this leaves draft:

  1. Are you OK with agent runs installing at project scope? Agent runs install for the agent running init, after the same existing-server check a ticked agent gets. Consent follows the skills model: the person ran init, and --skip-mcp-setup / --skip-agent-setup opt out. Detection only picks which agent's config to write. Claude's --scope local entry is stored in ~/.claude.json under 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 the agent-detection.ts header so it says this instead of being contradicted.
  2. Should init configure the other agents itself? That means add-mcp@2.4.1 as 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 init in 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 Code: a tick runs claude mcp add --transport http --scope local netlify https://mcp.netlify.com/mcp. Nothing is written to the repo.
  • Other agents: a tick prints npx -y add-mcp@2.4.1 https://mcp.netlify.com/mcp --name netlify -y -a <agent> and names the project file it writes.
  • Windsurf and Cline have no per-project config, so they're left out at project scope and only offered with --mcp-scope user. -g is never printed at project scope.
  • With --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 existing netlify entry.
  • Answers are saved in .netlify/state.json under agentMcpSetup.<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-setup asks again.

When an agent runs init, an agent init knows (Claude Code, Codex, Cursor and the others in MCP_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-setup skips it for that run without deleting it, and nothing goes user-wide without --mcp-scope user. Agent runs never write .netlify/state.json. An agent init doesn'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.json servers aren't connected), Codex with codex mcp list --json. The hosted URL is matched exactly. Any netlify or plugin:*:netlify server, a Netlify MCP host, or @netlify/mcp counts as an existing Netlify server, under any name, and is reported instead of duplicated.

Skills and MCP are skipped when init runs from the home directory or the file system root. Agent CLIs are found only through absolute PATH entries, using isexe.

Tests

  • npm exec vitest -- run tests/unit/utils/init/: 120 passed, including saving and resetting against a real .netlify/state.json
  • npm exec vitest -- run tests/integration/commands/init/init.test.ts: 12 passed, using a fake claude on PATH, including init from the home directory and a saved "no" held until --reset-mcp-setup
  • typecheck, lint, format:check and docs regeneration are clean; all CI checks pass on d7c3ed7, Windows unit included
  • Reviews: fresh full-PR reviews found nothing blocking; Codex adversarial findings are fixed except call 2. The round-three fixes were reviewed fresh, which caught one Windows test issue, fixed in d7c3ed7.

Risk

  • claude mcp list health-checks every approved server and can take up to 30s.
  • An agent init knows 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 sets NETLIFY_AGENT or CLAUDE_CODE_CHILD_SESSION=1 gets Claude configured for the project without being asked.
  • Saved choices go in the nearest .netlify/state.json, which can be an ancestor folder's in a monorepo.
  • Windows runs the unit tests; the integration tests use a shell-script fake claude and skip there. The two relative-PATH unit tests skip when the temp folder is on another drive, as on the Windows runner.

Related: rerunning init on 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 user if they used --mcp-scope user.


  • Open a bug/issue before writing your code: no issue; this came from the AX Slack thread
  • Read the contribution guidelines
  • Update or add tests
  • Update or add documentation (docs/commands/init.md regenerated)
  • Make sure the status checks below are successful

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.
@coderabbitai

coderabbitai Bot commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

📊 Benchmark results

Comparing with 2101508

  • Dependency count: 967 (no change)
  • Package size: 335 MB (no change)
  • Number of ts-expect-error directives: 280 (no change)

@pkg-pr-new

pkg-pr-new Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/netlify-cli@8638

commit: d7c3ed7

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.
@domitriusclark domitriusclark changed the title feat(init): add Netlify MCP server for Claude Code feat(init): add Netlify MCP server to detected AI agents Oct 20, 2026
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.

@seancdavis seancdavis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🏓

Comment thread src/utils/init/agent-mcp.ts Outdated
store: () => Promise<McpChoiceStore>
}): Promise<Selected> => {
const drivingAgent = getDrivingAgent(env)
if (drivingAgent) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread src/utils/init/agent-mcp.ts Outdated
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 })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread src/utils/init/agent-mcp.ts Outdated
binary: 'opencode',
drivenBy: ['opencode'],
projectMarkers: ['opencode.json', 'opencode.jsonc', '.opencode'],
native: { add: ['mcp', 'add', NETLIFY_MCP_SERVER_NAME, '--url', NETLIFY_MCP_URL] },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread src/utils/init/agent-mcp.ts Outdated
if (existing.missingBinary) {
return 'printed'
}
if (existing.exitCode === 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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']

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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[] = [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread src/utils/init/agent-mcp.ts Outdated
}
}

const isOnPath = async (binary: string, env: NodeJS.ProcessEnv): Promise<boolean> => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread src/utils/init/agent-mcp.ts Outdated
...agents.flatMap(({ id }) => ['-a', id]),
].join(' ')

const shellQuote = (arg: string): string => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] shellQuote uses POSIX single quotes, so the printed code --add-mcp '{...}' fallback won't paste into cmd on Windows.

Comment thread src/utils/init/agent-mcp.ts Outdated
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',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.
Comment thread src/utils/init/agent-mcp.ts Fixed
Comment thread src/utils/init/agent-mcp.ts Fixed
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.
@domitriusclark domitriusclark changed the title feat(init): add Netlify MCP server to detected AI agents feat(init): set up Netlify skills and MCP for the project Oct 21, 2026
@domitriusclark domitriusclark changed the title feat(init): set up Netlify skills and MCP for the project feat(init): set up the Netlify MCP server for the project Oct 21, 2026
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.
Comment thread tests/unit/utils/init/agent-mcp.test.ts Outdated
Comment thread tests/unit/utils/init/agent-mcp.test.ts Outdated
Comment thread src/utils/init/agent-mcp.ts Outdated
Comment thread src/utils/init/agent-mcp.ts Outdated
Comment thread src/utils/init/agent-mcp.ts Outdated
Comment thread src/utils/init/agent-mcp.ts Outdated
Comment thread src/utils/init/agent-mcp.ts Outdated
Comment thread src/commands/init/init.ts
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 seancdavis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Only checks for code --add-mcp, so a quoting bug in the JSON argument would still pass.

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.

3 participants