From 4157f262bd2bc0d000bbb8943e23b2a6ac7d51b8 Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Mon, 19 Oct 2026 21:45:04 -0400 Subject: [PATCH 01/19] feat(init): add Netlify MCP server for Claude Code 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. --- docs/commands/init.md | 3 +- src/commands/init/index.ts | 7 +- src/commands/init/init.ts | 42 ++++++- src/utils/init/agent-location.ts | 35 ++++++ src/utils/init/agent-mcp.ts | 84 +++++++++++++ tests/integration/commands/init/init.test.ts | 55 +++++++- tests/unit/utils/init/agent-location.test.ts | 60 +++++++++ tests/unit/utils/init/agent-mcp.test.ts | 126 +++++++++++++++++++ 8 files changed, 401 insertions(+), 11 deletions(-) create mode 100644 src/utils/init/agent-location.ts create mode 100644 src/utils/init/agent-mcp.ts create mode 100644 tests/unit/utils/init/agent-location.test.ts create mode 100644 tests/unit/utils/init/agent-mcp.test.ts diff --git a/docs/commands/init.md b/docs/commands/init.md index 7abf8c7b4dc..c681e7f3900 100644 --- a/docs/commands/init.md +++ b/docs/commands/init.md @@ -25,7 +25,8 @@ netlify init - `reset-context` (*boolean*) - Replace locally edited Netlify skills with the latest release, and migrate or delete edited copies under renamed or deprecated names - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in -- `skip-agent-setup` (*boolean*) - Skip installing Netlify skills for AI coding agents into the project +- `skip-agent-setup` (*boolean*) - Skip installing Netlify skills and adding the Netlify MCP server for AI coding agents +- `skip-mcp-setup` (*boolean*) - Skip adding the Netlify MCP server to your AI coding agents diff --git a/src/commands/init/index.ts b/src/commands/init/index.ts index 39d14acfc31..825bf599762 100644 --- a/src/commands/init/index.ts +++ b/src/commands/init/index.ts @@ -11,7 +11,11 @@ export const createInitCommand = (program: BaseCommand) => ) .option('-m, --manual', 'Manually configure a git remote for CI') .option('--git-remote-name ', 'Name of Git remote to use. e.g. "origin"') - .option('--skip-agent-setup', 'Skip installing Netlify skills for AI coding agents into the project') + .option( + '--skip-agent-setup', + 'Skip installing Netlify skills and adding the Netlify MCP server for AI coding agents', + ) + .option('--skip-mcp-setup', 'Skip adding the Netlify MCP server to your AI coding agents') .option( '--reset-context', 'Replace locally edited Netlify skills with the latest release, and migrate or delete edited copies under renamed or deprecated names', @@ -26,6 +30,7 @@ For more information about getting started with Netlify CLI, see ${terminalLink( const { init } = await import('./init.js') await init(options, command, { setupAgentSkills: !options.skipAgentSetup, + setupAgentMcp: !options.skipAgentSetup && !options.skipMcpSetup, resetContext: Boolean(options.resetContext), }) }) diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index f6130c62cde..4ee77423d72 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -11,6 +11,8 @@ import type BaseCommand from '../base-command.js' import { link } from '../link/link.js' import { sitesCreate } from '../sites/sites-create.js' import type { LocalState, SiteInfo } from '../../utils/types.js' +import { findAgentSetupBlocker } from '../../utils/init/agent-location.js' +import { setupAgentMcp } from '../../utils/init/agent-mcp.js' import { setupAgentSkills } from '../../utils/init/agent-skills.js' import { getBuildSettings, saveNetlifyToml } from '../../utils/init/utils.js' import { type InitExitCode, LINKED_EXISTING_SITE_EXIT_CODE, LINKED_NEW_SITE_EXIT_CODE } from './constants.js' @@ -212,12 +214,12 @@ type InitExtraOptions = { customizeExitMessage?: InitExitMessageCustomizer | undefined exitAfterConfiguringRepo?: boolean | undefined setupAgentSkills?: boolean | undefined + setupAgentMcp?: boolean | undefined resetContext?: boolean | undefined } -const installAgentSkills = async (command: BaseCommand, reset: boolean): Promise => { - log() - const result = await setupAgentSkills({ workingDir: command.netlify.repositoryRoot, reset }) +const installAgentSkills = async (workingDir: string, reset: boolean): Promise => { + const result = await setupAgentSkills({ workingDir, reset }) await track('sites_agentSkillsSetup', { installed: result.installed, directories: result.directories, @@ -227,6 +229,34 @@ const installAgentSkills = async (command: BaseCommand, reset: boolean): Promise }) } +const addAgentMcp = async (workingDir: string): Promise => { + const { claudeCode } = await setupAgentMcp({ workingDir }) + await track('sites_agentMcpSetup', { claudeCode }) +} + +const setUpAgents = async ( + command: BaseCommand, + { skills, mcp, reset }: { skills: boolean; mcp: boolean; reset: boolean }, +): Promise => { + if (!skills && !mcp) { + return + } + const { repositoryRoot } = command.netlify + log() + const blocker = await findAgentSetupBlocker(repositoryRoot) + if (blocker) { + log(`Skipped setting up AI agents: ${blocker}.`) + await track('sites_agentSetupSkipped', { skills, mcp }) + return + } + if (skills) { + await installAgentSkills(repositoryRoot, reset) + } + if (mcp) { + await addAgentMcp(repositoryRoot) + } +} + export const init = async ( options: OptionValues, command: BaseCommand, @@ -234,6 +264,7 @@ export const init = async ( customizeExitMessage, exitAfterConfiguringRepo = false, setupAgentSkills: shouldSetupAgentSkills = false, + setupAgentMcp: shouldSetupAgentMcp = false, resetContext = false, }: InitExtraOptions = {}, ): Promise => { @@ -241,6 +272,7 @@ export const init = async ( manual: options.manual, force: options.force, skipAgentSetup: options.skipAgentSetup, + skipMcpSetup: options.skipMcpSetup, resetContext: options.resetContext, }) @@ -253,9 +285,7 @@ export const init = async ( // Add .netlify to .gitignore file await ensureNetlifyIgnore(repositoryRoot) - if (shouldSetupAgentSkills) { - await installAgentSkills(command, resetContext) - } + await setUpAgents(command, { skills: shouldSetupAgentSkills, mcp: shouldSetupAgentMcp, reset: resetContext }) const repoUrl = getRepoUrl(existingSiteInfo) if (repoUrl && !options.force) { diff --git a/src/utils/init/agent-location.ts b/src/utils/init/agent-location.ts new file mode 100644 index 00000000000..b6f7db6025f --- /dev/null +++ b/src/utils/init/agent-location.ts @@ -0,0 +1,35 @@ +import { promises as fs } from 'node:fs' +import { homedir } from 'node:os' +import path from 'node:path' + +const PROJECT_MARKERS = ['.git', 'package.json', 'netlify.toml'] as const + +const realpathOrSelf = async (target: string): Promise => { + try { + return await fs.realpath(target) + } catch { + return path.resolve(target) + } +} + +const exists = async (target: string): Promise => { + try { + await fs.lstat(target) + return true + } catch { + return false + } +} + +export const findAgentSetupBlocker = async (root: string, home: string = homedir()): Promise => { + const resolvedRoot = await realpathOrSelf(root) + if (resolvedRoot === (await realpathOrSelf(home))) { + return 'this is your home directory, where agent folders hold settings for every project' + } + for (const marker of PROJECT_MARKERS) { + if (await exists(path.join(resolvedRoot, marker))) { + return undefined + } + } + return `no ${PROJECT_MARKERS.join(', ')} found in ${resolvedRoot}` +} diff --git a/src/utils/init/agent-mcp.ts b/src/utils/init/agent-mcp.ts new file mode 100644 index 00000000000..ec5063cb69a --- /dev/null +++ b/src/utils/init/agent-mcp.ts @@ -0,0 +1,84 @@ +import { chalk, log, warn } from '../command-helpers.js' +import execa from '../execa.js' + +export const NETLIFY_MCP_URL = 'https://mcp.netlify.com/mcp' +export const NETLIFY_MCP_SERVER_NAME = 'netlify' +export const NETLIFY_MCP_DOCS_URL = 'https://docs.netlify.com/welcome/build-with-ai/netlify-mcp-server/' +const LEGACY_MCP_HOST = 'netlify-mcp.netlify.app' + +const CLAUDE_TIMEOUT_MS = 15_000 + +const ADD_ARGS = ['mcp', 'add', '--transport', 'http', '--scope', 'local', NETLIFY_MCP_SERVER_NAME, NETLIFY_MCP_URL] +const REMOVE_ARGS = ['mcp', 'remove', '--scope', 'local', NETLIFY_MCP_SERVER_NAME] + +export type AgentMcpStatus = 'added' | 'present' | 'unavailable' | 'failed' + +export interface AgentMcpSetupSummary { + claudeCode: AgentMcpStatus + error?: string +} + +type ClaudeResult = { missing: true } | { missing: false; exitCode?: number; output: string; timedOut: boolean } + +const runClaude = async (args: string[], cwd: string): Promise => { + const result = await execa('claude', args, { cwd, reject: false, timeout: CLAUDE_TIMEOUT_MS }) + if ((result as { code?: string }).code === 'ENOENT') { + return { missing: true } + } + return { + missing: false, + exitCode: result.exitCode, + output: [result.stderr, result.stdout].filter(Boolean).join('\n').trim(), + timedOut: result.timedOut, + } +} + +const describeFailure = (result: Exclude): string => + result.timedOut + ? `timed out after ${(CLAUDE_TIMEOUT_MS / 1000).toString()} seconds` + : result.output || `exit code ${String(result.exitCode)}` + +const command = (args: string[]): string => chalk.cyanBright.bold(['claude', ...args].join(' ')) + +const addToClaudeCode = async (workingDir: string): Promise => { + const existing = await runClaude(['mcp', 'get', NETLIFY_MCP_SERVER_NAME], workingDir) + if (existing.missing) { + return { claudeCode: 'unavailable' } + } + if (existing.exitCode === 0) { + log(`Claude Code already has a ${chalk.cyan(NETLIFY_MCP_SERVER_NAME)} MCP server, so it was left unchanged.`) + if (existing.output.includes(LEGACY_MCP_HOST)) { + log(` It points at ${LEGACY_MCP_HOST}. Netlify's MCP server now lives at ${chalk.underline(NETLIFY_MCP_URL)}`) + } + return { claudeCode: 'present' } + } + if (existing.exitCode !== 1 || !existing.output.includes('No MCP server named')) { + return { claudeCode: 'failed', error: describeFailure(existing) } + } + + const added = await runClaude(ADD_ARGS, workingDir) + if (added.missing) { + return { claudeCode: 'unavailable' } + } + if (added.exitCode !== 0) { + return { claudeCode: 'failed', error: describeFailure(added) } + } + log(`Added the Netlify MCP server to Claude Code for you in this project. Nothing was written to the repository.`) + log(` Remove it with ${command(REMOVE_ARGS)}`) + return { claudeCode: 'added' } +} + +export const setupAgentMcp = async ({ workingDir }: { workingDir: string }): Promise => { + let summary: AgentMcpSetupSummary + try { + summary = await addToClaudeCode(workingDir) + } catch (error) { + summary = { claudeCode: 'failed', error: error instanceof Error ? error.message : String(error) } + } + if (summary.claudeCode === 'failed') { + warn(`Could not add the Netlify MCP server to Claude Code: ${summary.error ?? 'unknown error'}`) + log(` Add it yourself with ${command(ADD_ARGS)}`) + } + log(`To connect other AI agents to the Netlify MCP server, see ${chalk.underline(NETLIFY_MCP_DOCS_URL)}`) + return summary +} diff --git a/tests/integration/commands/init/init.test.ts b/tests/integration/commands/init/init.test.ts index 3b731b53509..b7a8bedbb4d 100644 --- a/tests/integration/commands/init/init.test.ts +++ b/tests/integration/commands/init/init.test.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { readFile } from 'node:fs/promises' +import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' import type { AddressInfo } from 'node:net' import path from 'node:path' @@ -807,15 +807,17 @@ describe.concurrent('commands/init', () => { { question: 'Configure the following webhook for your repository', answer: CONFIRM }, ] const initOnLinkedSite = - ({ apiUrl, cwd, skillsHost }: { apiUrl: string; cwd: string; skillsHost: string }) => + ({ apiUrl, cwd, skillsHost, claudeBin }: { apiUrl: string; cwd: string; skillsHost: string; claudeBin?: string }) => async (...flags: string[]) => { const env = { NETLIFY_API_URL: apiUrl, NETLIFY_SITE_ID: 'site_id', NETLIFY_AUTH_TOKEN: 'fake-token', NETLIFY_SKILLS_HOST: skillsHost, + ...(claudeBin ? { PATH: `${claudeBin}${path.delimiter}${process.env.PATH ?? ''}` } : {}), } - const childProcess = execa(cliPath, ['init', '--manual', ...flags], { cwd, env }) + const mcpFlags = claudeBin ? [] : ['--skip-mcp-setup'] + const childProcess = execa(cliPath, ['init', '--manual', ...mcpFlags, ...flags], { cwd, env }) if (process.env.DEBUG_TESTS) { childProcess.stdout?.on('data', (data: Buffer) => { process.stderr.write(data) @@ -852,6 +854,53 @@ describe.concurrent('commands/init', () => { }) }) + test.skipIf(process.platform === 'win32')( + 'netlify init adds the Netlify MCP server to Claude Code once and respects --skip-mcp-setup', + async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.withGit().build() + const claudeBin = path.join(builder.directory, '.fake-bin') + const callsFile = path.join(claudeBin, 'calls.log') + const addedMarker = path.join(claudeBin, 'added') + await mkdir(claudeBin, { recursive: true }) + await writeFile( + path.join(claudeBin, 'claude'), + [ + '#!/bin/sh', + `echo "$PWD $*" >> "${callsFile}"`, + `if [ "$2" = "get" ]; then [ -f "${addedMarker}" ] && exit 0; echo "No MCP server named $3" >&2; exit 1; fi`, + `if [ "$2" = "add" ]; then touch "${addedMarker}"; exit 0; fi`, + 'exit 2', + '', + ].join('\n'), + ) + await chmod(path.join(claudeBin, 'claude'), 0o755) + + await withMockApi(linkedSiteRoutes, async ({ apiUrl }) => { + await withSkillsHost(async (skillsHost) => { + const runInit = initOnLinkedSite({ apiUrl, cwd: builder.directory, skillsHost: skillsHost.url, claudeBin }) + + const first = await runInit() + t.expect(first.stdout).toContain('Added the Netlify MCP server to Claude Code') + + const second = await runInit() + t.expect(second.stdout).toContain('Claude Code already has a netlify MCP server') + + const skipped = await runInit('--skip-mcp-setup') + t.expect(skipped.stdout).not.toContain('MCP server') + + const calls = (await readFile(callsFile, 'utf8')).trim().split('\n') + t.expect(calls.map((call) => call.split(' ').slice(1).join(' '))).toEqual([ + 'mcp get netlify', + 'mcp add --transport http --scope local netlify https://mcp.netlify.com/mcp', + 'mcp get netlify', + ]) + }) + }) + }) + }, + ) + test('netlify init syncs installed skills: updates stale copies, removes deprecated ones, keeps edits until --reset-context', async (t) => { await withSiteBuilder(t, async (builder) => { const skillsDir = path.join('.agents', 'skills') diff --git a/tests/unit/utils/init/agent-location.test.ts b/tests/unit/utils/init/agent-location.test.ts new file mode 100644 index 00000000000..f4b9a00018f --- /dev/null +++ b/tests/unit/utils/init/agent-location.test.ts @@ -0,0 +1,60 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, test } from 'vitest' + +import { findAgentSetupBlocker } from '../../../../src/utils/init/agent-location.js' + +describe('findAgentSetupBlocker', () => { + let root: string + let home: string + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'netlify-agent-location-')) + home = join(root, 'home') + await mkdir(home) + }) + + afterEach(async () => { + await rm(root, { recursive: true, force: true }) + }) + + test('blocks the home directory even when it is a git repository', async () => { + await mkdir(join(home, '.git')) + await expect(findAgentSetupBlocker(home, home)).resolves.toMatch(/home directory/) + }) + + test('blocks a path that resolves to the home directory through a symlink', async () => { + const link = join(root, 'link-to-home') + await symlink(home, link) + await expect(findAgentSetupBlocker(link, home)).resolves.toMatch(/home directory/) + }) + + test('blocks a directory with no project marker', async () => { + const empty = join(root, 'empty') + await mkdir(empty) + await expect(findAgentSetupBlocker(empty, home)).resolves.toMatch(/no \.git, package\.json, netlify\.toml found/) + }) + + test.each(['package.json', 'netlify.toml'])('allows a directory with %s', async (marker) => { + const project = join(root, 'project') + await mkdir(project) + await writeFile(join(project, marker), '') + await expect(findAgentSetupBlocker(project, home)).resolves.toBeUndefined() + }) + + test('allows a git worktree, where .git is a file', async () => { + const worktree = join(root, 'worktree') + await mkdir(worktree) + await writeFile(join(worktree, '.git'), 'gitdir: /elsewhere\n') + await expect(findAgentSetupBlocker(worktree, home)).resolves.toBeUndefined() + }) + + test('allows a project nested inside the home directory', async () => { + const project = join(home, 'code', 'site') + await mkdir(project, { recursive: true }) + await writeFile(join(project, 'package.json'), '{}') + await expect(findAgentSetupBlocker(project, home)).resolves.toBeUndefined() + }) +}) diff --git a/tests/unit/utils/init/agent-mcp.test.ts b/tests/unit/utils/init/agent-mcp.test.ts new file mode 100644 index 00000000000..693bca84997 --- /dev/null +++ b/tests/unit/utils/init/agent-mcp.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' + +import { log, warn } from '../../../../src/utils/command-helpers.js' +import execa from '../../../../src/utils/execa.js' +import { NETLIFY_MCP_URL, setupAgentMcp } from '../../../../src/utils/init/agent-mcp.js' + +vi.mock('../../../../src/utils/command-helpers.js', async (importOriginal) => ({ + ...(await importOriginal()), + log: vi.fn(), + warn: vi.fn(), +})) + +vi.mock('../../../../src/utils/execa.js', () => ({ default: vi.fn() })) + +const execaMock = vi.mocked(execa) as unknown as ReturnType + +const exited = (exitCode: number, stderr = '') => ({ exitCode, stdout: '', stderr, timedOut: false }) +const notInstalled = () => ({ code: 'ENOENT', exitCode: undefined, stdout: '', stderr: '', timedOut: false }) +const timedOut = () => ({ exitCode: undefined, stdout: '', stderr: '', timedOut: true }) + +const ADD_ARGS = ['mcp', 'add', '--transport', 'http', '--scope', 'local', 'netlify', NETLIFY_MCP_URL] + +const logged = () => + vi + .mocked(log) + .mock.calls.map(([message]) => message) + .join('\n') +const warned = () => + vi + .mocked(warn) + .mock.calls.map(([message]) => message) + .join('\n') + +describe('setupAgentMcp', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test('adds the hosted server at local scope from the project root when Claude Code has none', async () => { + execaMock.mockResolvedValueOnce(exited(1, 'No MCP server named "netlify"')).mockResolvedValueOnce(exited(0)) + + await expect(setupAgentMcp({ workingDir: '/project' })).resolves.toEqual({ claudeCode: 'added' }) + + expect(execaMock).toHaveBeenNthCalledWith( + 1, + 'claude', + ['mcp', 'get', 'netlify'], + expect.objectContaining({ cwd: '/project', reject: false }), + ) + expect(execaMock).toHaveBeenNthCalledWith( + 2, + 'claude', + ADD_ARGS, + expect.objectContaining({ cwd: '/project', reject: false }), + ) + expect(logged()).toContain('Nothing was written to the repository') + expect(logged()).toContain('claude mcp remove --scope local netlify') + }) + + test('leaves an existing netlify server alone, whatever scope or host it uses', async () => { + execaMock.mockResolvedValueOnce(exited(0)) + + await expect(setupAgentMcp({ workingDir: '/project' })).resolves.toEqual({ claudeCode: 'present' }) + + expect(execaMock).toHaveBeenCalledTimes(1) + expect(logged()).toContain('already has a netlify MCP server') + }) + + test('does nothing for Claude Code when it is not installed, and still points at the docs', async () => { + execaMock.mockResolvedValueOnce(notInstalled()) + + await expect(setupAgentMcp({ workingDir: '/project' })).resolves.toEqual({ claudeCode: 'unavailable' }) + + expect(execaMock).toHaveBeenCalledTimes(1) + expect(warn).not.toHaveBeenCalled() + expect(logged()).toContain('docs.netlify.com/welcome/build-with-ai/netlify-mcp-server') + }) + + test('does not add when the lookup times out, so it never risks a duplicate', async () => { + execaMock.mockResolvedValueOnce(timedOut()) + + await expect(setupAgentMcp({ workingDir: '/project' })).resolves.toMatchObject({ claudeCode: 'failed' }) + + expect(execaMock).toHaveBeenCalledTimes(1) + expect(warned()).toContain('timed out') + expect(logged()).toContain(`claude ${ADD_ARGS.join(' ')}`) + }) + + test('points an existing entry on the old host at the new one, without changing it', async () => { + execaMock.mockResolvedValueOnce({ ...exited(0), stdout: 'URL: https://netlify-mcp.netlify.app/mcp' }) + + await expect(setupAgentMcp({ workingDir: '/project' })).resolves.toEqual({ claudeCode: 'present' }) + + expect(execaMock).toHaveBeenCalledTimes(1) + expect(logged()).toContain(`now lives at ${NETLIFY_MCP_URL}`) + }) + + test('does not add when the lookup fails for a reason other than a missing server', async () => { + execaMock.mockResolvedValueOnce(exited(1, 'Failed to connect')) + + await expect(setupAgentMcp({ workingDir: '/project' })).resolves.toEqual({ + claudeCode: 'failed', + error: 'Failed to connect', + }) + + expect(execaMock).toHaveBeenCalledTimes(1) + }) + + test('reports a failed add with the command to run by hand, without throwing', async () => { + execaMock.mockResolvedValueOnce(exited(1, 'No MCP server named "netlify"')).mockResolvedValueOnce(exited(1, 'boom')) + + await expect(setupAgentMcp({ workingDir: '/project' })).resolves.toEqual({ claudeCode: 'failed', error: 'boom' }) + + expect(warned()).toContain('boom') + expect(logged()).toContain(`claude ${ADD_ARGS.join(' ')}`) + }) + + test('turns an unexpected exception into a failure instead of breaking init', async () => { + execaMock.mockRejectedValueOnce(new Error('spawn exploded')) + + await expect(setupAgentMcp({ workingDir: '/project' })).resolves.toEqual({ + claudeCode: 'failed', + error: 'spawn exploded', + }) + }) +}) From 51f308a0749eff91056c6ae1befa4efcb6bf32ac Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Mon, 19 Oct 2026 22:08:57 -0400 Subject: [PATCH 02/19] feat(init): set up MCP for the agents init detects 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. --- docs/commands/init.md | 2 +- src/commands/init/init.ts | 4 +- src/utils/init/agent-mcp.ts | 322 ++++++++++++++++--- tests/integration/commands/init/init.test.ts | 8 +- tests/unit/utils/init/agent-mcp.test.ts | 264 +++++++++++---- 5 files changed, 480 insertions(+), 120 deletions(-) diff --git a/docs/commands/init.md b/docs/commands/init.md index c681e7f3900..2f62344e4db 100644 --- a/docs/commands/init.md +++ b/docs/commands/init.md @@ -23,10 +23,10 @@ netlify init - `git-remote-name` (*string*) - Name of Git remote to use. e.g. "origin" - `manual` (*boolean*) - Manually configure a git remote for CI - `reset-context` (*boolean*) - Replace locally edited Netlify skills with the latest release, and migrate or delete edited copies under renamed or deprecated names +- `skip-mcp-setup` (*boolean*) - Skip adding the Netlify MCP server to your AI coding agents - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `skip-agent-setup` (*boolean*) - Skip installing Netlify skills and adding the Netlify MCP server for AI coding agents -- `skip-mcp-setup` (*boolean*) - Skip adding the Netlify MCP server to your AI coding agents diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index 4ee77423d72..fdc76d8da61 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -230,8 +230,8 @@ const installAgentSkills = async (workingDir: string, reset: boolean): Promise => { - const { claudeCode } = await setupAgentMcp({ workingDir }) - await track('sites_agentMcpSetup', { claudeCode }) + const { selection, agents, failed = false } = await setupAgentMcp({ workingDir }) + await track('sites_agentMcpSetup', { selection, agents, failed }) } const setUpAgents = async ( diff --git a/src/utils/init/agent-mcp.ts b/src/utils/init/agent-mcp.ts index ec5063cb69a..0880e7487e5 100644 --- a/src/utils/init/agent-mcp.ts +++ b/src/utils/init/agent-mcp.ts @@ -1,84 +1,312 @@ +import { constants as fsConstants, promises as fs } from 'node:fs' +import path from 'node:path' + +import { checkbox } from '@inquirer/prompts' + +import { type CanonicalAgentName, getDrivingAgent } from '../agent-detection.js' import { chalk, log, warn } from '../command-helpers.js' import execa from '../execa.js' +import { isInteractive } from '../scripted-commands.js' export const NETLIFY_MCP_URL = 'https://mcp.netlify.com/mcp' export const NETLIFY_MCP_SERVER_NAME = 'netlify' -export const NETLIFY_MCP_DOCS_URL = 'https://docs.netlify.com/welcome/build-with-ai/netlify-mcp-server/' const LEGACY_MCP_HOST = 'netlify-mcp.netlify.app' +const NATIVE_TIMEOUT_MS = 15_000 +const ADD_MCP_VERSION = '2.4.1' + +interface NativeSetup { + lookup?: string[] + add: string[] + remove?: string[] +} + +export interface McpAgent { + id: string + label: string + binary?: string + drivenBy?: CanonicalAgentName[] + projectMarkers?: string[] + inTerminal?: (env: NodeJS.ProcessEnv) => boolean + native?: NativeSetup +} -const CLAUDE_TIMEOUT_MS = 15_000 +const inEditorTerminal = (env: NodeJS.ProcessEnv): boolean => env.TERM_PROGRAM === 'vscode' -const ADD_ARGS = ['mcp', 'add', '--transport', 'http', '--scope', 'local', NETLIFY_MCP_SERVER_NAME, NETLIFY_MCP_URL] -const REMOVE_ARGS = ['mcp', 'remove', '--scope', 'local', NETLIFY_MCP_SERVER_NAME] +export const MCP_AGENTS: McpAgent[] = [ + { + id: 'claude-code', + label: 'Claude Code', + binary: 'claude', + drivenBy: ['claude'], + projectMarkers: ['.claude', '.mcp.json', 'CLAUDE.md'], + native: { + lookup: ['mcp', 'get', NETLIFY_MCP_SERVER_NAME], + add: ['mcp', 'add', '--transport', 'http', '--scope', 'user', NETLIFY_MCP_SERVER_NAME, NETLIFY_MCP_URL], + remove: ['mcp', 'remove', '--scope', 'user', NETLIFY_MCP_SERVER_NAME], + }, + }, + { + id: 'codex', + label: 'Codex', + binary: 'codex', + drivenBy: ['codex'], + projectMarkers: ['.codex'], + native: { + lookup: ['mcp', 'get', NETLIFY_MCP_SERVER_NAME], + add: ['mcp', 'add', NETLIFY_MCP_SERVER_NAME, '--url', NETLIFY_MCP_URL], + remove: ['mcp', 'remove', NETLIFY_MCP_SERVER_NAME], + }, + }, + { + id: 'opencode', + label: 'OpenCode', + binary: 'opencode', + drivenBy: ['opencode'], + projectMarkers: ['opencode.json', 'opencode.jsonc', '.opencode'], + native: { add: ['mcp', 'add', NETLIFY_MCP_SERVER_NAME, '--url', NETLIFY_MCP_URL] }, + }, + { + id: 'vscode', + label: 'VS Code', + binary: 'code', + projectMarkers: [path.join('.vscode', 'mcp.json')], + inTerminal: (env) => inEditorTerminal(env) && !env.CURSOR_TRACE_ID, + native: { + add: ['--add-mcp', JSON.stringify({ name: NETLIFY_MCP_SERVER_NAME, type: 'http', url: NETLIFY_MCP_URL })], + }, + }, + { + id: 'cursor', + label: 'Cursor', + binary: 'cursor-agent', + drivenBy: ['cursor'], + projectMarkers: ['.cursor'], + inTerminal: (env) => inEditorTerminal(env) && Boolean(env.CURSOR_TRACE_ID), + }, + { id: 'windsurf', label: 'Windsurf', binary: 'windsurf', projectMarkers: ['.windsurf'] }, + { + id: 'gemini-cli', + label: 'Gemini CLI', + binary: 'gemini', + drivenBy: ['gemini'], + projectMarkers: ['.gemini', 'GEMINI.md'], + }, + { id: 'github-copilot-cli', label: 'GitHub Copilot CLI', binary: 'copilot', drivenBy: ['copilot'] }, + { id: 'kiro-cli', label: 'Kiro CLI', binary: 'kiro-cli', drivenBy: ['kiro'], projectMarkers: ['.kiro'] }, + { id: 'cline', label: 'Cline', drivenBy: ['cline'], projectMarkers: ['.clinerules'] }, +] -export type AgentMcpStatus = 'added' | 'present' | 'unavailable' | 'failed' +export type AgentMcpStatus = 'added' | 'present' | 'printed' | 'failed' +export type AgentSelection = 'driving' | 'prompt' | 'none' export interface AgentMcpSetupSummary { - claudeCode: AgentMcpStatus - error?: string + selection: AgentSelection + agents: Record + failed?: boolean } -type ClaudeResult = { missing: true } | { missing: false; exitCode?: number; output: string; timedOut: boolean } +export interface AgentCandidate { + agent: McpAgent + detected: boolean +} + +export type ChooseAgents = (candidates: AgentCandidate[]) => Promise + +const chooseWithPrompt: ChooseAgents = async (candidates) => + await checkbox({ + message: 'Add the Netlify MCP server to which AI agents? It is added for your user, across all your projects.', + choices: candidates.map(({ agent, detected }) => ({ name: agent.label, value: agent, checked: detected })), + }) + +const pathExists = async (target: string): Promise => { + try { + await fs.access(target) + return true + } catch { + return false + } +} + +const isExecutableFile = async (target: string): Promise => { + try { + if (!(await fs.stat(target)).isFile()) { + return false + } + await fs.access(target, process.platform === 'win32' ? fsConstants.F_OK : fsConstants.X_OK) + return true + } catch { + return false + } +} + +const isOnPath = async (binary: string, env: NodeJS.ProcessEnv): Promise => { + const extensions = process.platform === 'win32' ? (env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') : [''] + const directories = (env.PATH ?? '').split(path.delimiter).filter(Boolean) + const found = await Promise.all( + directories.flatMap((directory) => + extensions.map((extension) => isExecutableFile(path.join(directory, binary + extension))), + ), + ) + return found.includes(true) +} + +const hasProjectMarker = async (agent: McpAgent, workingDir: string): Promise => { + for (const marker of agent.projectMarkers ?? []) { + if (await pathExists(path.join(workingDir, marker))) { + return true + } + } + return false +} -const runClaude = async (args: string[], cwd: string): Promise => { - const result = await execa('claude', args, { cwd, reject: false, timeout: CLAUDE_TIMEOUT_MS }) - if ((result as { code?: string }).code === 'ENOENT') { - return { missing: true } +export const findCandidates = async (workingDir: string, env: NodeJS.ProcessEnv): Promise => { + const candidates: AgentCandidate[] = [] + for (const agent of MCP_AGENTS) { + const detected = Boolean(agent.inTerminal?.(env)) || (await hasProjectMarker(agent, workingDir)) + if (detected || (agent.binary !== undefined && (await isOnPath(agent.binary, env)))) { + candidates.push({ agent, detected }) + } } + return candidates.sort((a, b) => Number(b.detected) - Number(a.detected)) +} + +const addMcpCommand = (agents: McpAgent[]): string => + [ + `npx -y add-mcp@${ADD_MCP_VERSION}`, + NETLIFY_MCP_URL, + '--name', + NETLIFY_MCP_SERVER_NAME, + '-g', + ...agents.flatMap(({ id }) => ['-a', id]), + ].join(' ') + +const shellQuote = (arg: string): string => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`) + +const formatCommand = (binary: string, args: string[]): string => + 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 }) return { - missing: false, + missingBinary: (result as { code?: string }).code === 'ENOENT', exitCode: result.exitCode, output: [result.stderr, result.stdout].filter(Boolean).join('\n').trim(), timedOut: result.timedOut, } } -const describeFailure = (result: Exclude): string => - result.timedOut - ? `timed out after ${(CLAUDE_TIMEOUT_MS / 1000).toString()} seconds` - : result.output || `exit code ${String(result.exitCode)}` +const describeFailure = ({ timedOut, output, exitCode }: Awaited>): string => + timedOut + ? `timed out after ${(NATIVE_TIMEOUT_MS / 1000).toString()} seconds` + : output || `exit code ${String(exitCode)}` -const command = (args: string[]): string => chalk.cyanBright.bold(['claude', ...args].join(' ')) +const addNatively = async ( + agent: McpAgent & { binary: string; native: NativeSetup }, + workingDir: string, +): Promise => { + const { binary, native, label } = agent + if (native.lookup) { + const existing = await runNative(binary, native.lookup, workingDir) + if (existing.missingBinary) { + return 'printed' + } + if (existing.exitCode === 0) { + log(`${label} already has a ${chalk.cyan(NETLIFY_MCP_SERVER_NAME)} MCP server, so it was left unchanged.`) + if (existing.output.includes(LEGACY_MCP_HOST)) { + log(` It points at ${LEGACY_MCP_HOST}. Netlify's MCP server now lives at ${chalk.underline(NETLIFY_MCP_URL)}`) + } + return 'present' + } + if (existing.exitCode !== 1 || !existing.output.includes('No MCP server named')) { + warn(`Could not check ${label} for an existing Netlify MCP server: ${describeFailure(existing)}`) + log(` Add it yourself with ${formatCommand(binary, native.add)}`) + return 'failed' + } + } -const addToClaudeCode = async (workingDir: string): Promise => { - const existing = await runClaude(['mcp', 'get', NETLIFY_MCP_SERVER_NAME], workingDir) - if (existing.missing) { - return { claudeCode: 'unavailable' } + const added = await runNative(binary, native.add, workingDir) + if (added.missingBinary) { + return 'printed' } - if (existing.exitCode === 0) { - log(`Claude Code already has a ${chalk.cyan(NETLIFY_MCP_SERVER_NAME)} MCP server, so it was left unchanged.`) - if (existing.output.includes(LEGACY_MCP_HOST)) { - log(` It points at ${LEGACY_MCP_HOST}. Netlify's MCP server now lives at ${chalk.underline(NETLIFY_MCP_URL)}`) - } - return { claudeCode: 'present' } + if (added.exitCode !== 0) { + warn(`Could not add the Netlify MCP server to ${label}: ${describeFailure(added)}`) + log(` Add it yourself with ${formatCommand(binary, native.add)}`) + return 'failed' } - if (existing.exitCode !== 1 || !existing.output.includes('No MCP server named')) { - return { claudeCode: 'failed', error: describeFailure(existing) } + log(`Added the Netlify MCP server to ${label} for your user. Nothing was written to the repository.`) + if (native.remove) { + log(` Remove it with ${formatCommand(binary, native.remove)}`) } + return 'added' +} + +const hasNativeSetup = (agent: McpAgent): agent is McpAgent & { binary: string; native: NativeSetup } => + agent.binary !== undefined && agent.native !== undefined - const added = await runClaude(ADD_ARGS, workingDir) - if (added.missing) { - return { claudeCode: 'unavailable' } +const selectAgents = async ({ + workingDir, + env, + interactive, + choose, +}: { + workingDir: string + env: NodeJS.ProcessEnv + interactive: boolean + choose: ChooseAgents +}): Promise<{ selection: AgentSelection; agents: McpAgent[] }> => { + const drivingAgent = getDrivingAgent(env) + if (drivingAgent) { + const agent = MCP_AGENTS.find(({ drivenBy }) => drivenBy?.includes(drivingAgent.name)) + return agent ? { selection: 'driving', agents: [agent] } : { selection: 'none', agents: [] } } - if (added.exitCode !== 0) { - return { claudeCode: 'failed', error: describeFailure(added) } + if (!interactive) { + return { selection: 'none', agents: [] } + } + const candidates = await findCandidates(workingDir, env) + if (candidates.length === 0) { + return { selection: 'none', agents: [] } } - log(`Added the Netlify MCP server to Claude Code for you in this project. Nothing was written to the repository.`) - log(` Remove it with ${command(REMOVE_ARGS)}`) - return { claudeCode: 'added' } + return { selection: 'prompt', agents: await choose(candidates) } } -export const setupAgentMcp = async ({ workingDir }: { workingDir: string }): Promise => { - let summary: AgentMcpSetupSummary +export const setupAgentMcp = async ({ + workingDir, + env = process.env, + interactive = isInteractive(), + choose = chooseWithPrompt, +}: { + workingDir: string + env?: NodeJS.ProcessEnv + interactive?: boolean + choose?: ChooseAgents +}): Promise => { + const summary: AgentMcpSetupSummary = { selection: 'none', agents: {} } try { - summary = await addToClaudeCode(workingDir) + const { selection, agents } = await selectAgents({ workingDir, env, interactive, choose }) + summary.selection = selection + if (selection === 'none') { + log(`To add the Netlify MCP server to your AI agents, run ${chalk.cyanBright.bold(addMcpCommand([]))}`) + return summary + } + + const toPrint: McpAgent[] = [] + for (const agent of agents) { + const status = hasNativeSetup(agent) ? await addNatively(agent, workingDir) : 'printed' + summary.agents[agent.id] = status + if (status === 'printed') { + toPrint.push(agent) + } + } + if (toPrint.length > 0) { + const labels = toPrint.map(({ label }) => label).join(', ') + log(`To add the Netlify MCP server to ${labels}, run ${chalk.cyanBright.bold(addMcpCommand(toPrint))}`) + } } catch (error) { - summary = { claudeCode: 'failed', error: error instanceof Error ? error.message : String(error) } - } - if (summary.claudeCode === 'failed') { - warn(`Could not add the Netlify MCP server to Claude Code: ${summary.error ?? 'unknown error'}`) - log(` Add it yourself with ${command(ADD_ARGS)}`) + if (error instanceof Error && error.name === 'ExitPromptError') { + throw error + } + warn(`Could not set up the Netlify MCP server: ${error instanceof Error ? error.message : String(error)}`) + summary.failed = true } - log(`To connect other AI agents to the Netlify MCP server, see ${chalk.underline(NETLIFY_MCP_DOCS_URL)}`) return summary } diff --git a/tests/integration/commands/init/init.test.ts b/tests/integration/commands/init/init.test.ts index b7a8bedbb4d..f65fa08bc8d 100644 --- a/tests/integration/commands/init/init.test.ts +++ b/tests/integration/commands/init/init.test.ts @@ -814,7 +814,9 @@ describe.concurrent('commands/init', () => { NETLIFY_SITE_ID: 'site_id', NETLIFY_AUTH_TOKEN: 'fake-token', NETLIFY_SKILLS_HOST: skillsHost, - ...(claudeBin ? { PATH: `${claudeBin}${path.delimiter}${process.env.PATH ?? ''}` } : {}), + ...(claudeBin + ? { PATH: `${claudeBin}${path.delimiter}${process.env.PATH ?? ''}`, NETLIFY_AGENT: 'claude-code' } + : {}), } const mcpFlags = claudeBin ? [] : ['--skip-mcp-setup'] const childProcess = execa(cliPath, ['init', '--manual', ...mcpFlags, ...flags], { cwd, env }) @@ -855,7 +857,7 @@ describe.concurrent('commands/init', () => { }) test.skipIf(process.platform === 'win32')( - 'netlify init adds the Netlify MCP server to Claude Code once and respects --skip-mcp-setup', + 'netlify init adds the Netlify MCP server to the agent running it once and respects --skip-mcp-setup', async (t) => { await withSiteBuilder(t, async (builder) => { await builder.withGit().build() @@ -892,7 +894,7 @@ describe.concurrent('commands/init', () => { const calls = (await readFile(callsFile, 'utf8')).trim().split('\n') t.expect(calls.map((call) => call.split(' ').slice(1).join(' '))).toEqual([ 'mcp get netlify', - 'mcp add --transport http --scope local netlify https://mcp.netlify.com/mcp', + 'mcp add --transport http --scope user netlify https://mcp.netlify.com/mcp', 'mcp get netlify', ]) }) diff --git a/tests/unit/utils/init/agent-mcp.test.ts b/tests/unit/utils/init/agent-mcp.test.ts index 693bca84997..2001cc700be 100644 --- a/tests/unit/utils/init/agent-mcp.test.ts +++ b/tests/unit/utils/init/agent-mcp.test.ts @@ -1,8 +1,18 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { log, warn } from '../../../../src/utils/command-helpers.js' import execa from '../../../../src/utils/execa.js' -import { NETLIFY_MCP_URL, setupAgentMcp } from '../../../../src/utils/init/agent-mcp.js' +import { + type AgentCandidate, + type ChooseAgents, + NETLIFY_MCP_URL, + findCandidates, + setupAgentMcp, +} from '../../../../src/utils/init/agent-mcp.js' vi.mock('../../../../src/utils/command-helpers.js', async (importOriginal) => ({ ...(await importOriginal()), @@ -14,11 +24,13 @@ vi.mock('../../../../src/utils/execa.js', () => ({ default: vi.fn() })) const execaMock = vi.mocked(execa) as unknown as ReturnType -const exited = (exitCode: number, stderr = '') => ({ exitCode, stdout: '', stderr, timedOut: false }) +const exited = (exitCode: number, stderr = '', stdout = '') => ({ exitCode, stdout, stderr, timedOut: false }) +const missingServer = () => exited(1, 'No MCP server named "netlify"') const notInstalled = () => ({ code: 'ENOENT', exitCode: undefined, stdout: '', stderr: '', timedOut: false }) const timedOut = () => ({ exitCode: undefined, stdout: '', stderr: '', timedOut: true }) -const ADD_ARGS = ['mcp', 'add', '--transport', 'http', '--scope', 'local', 'netlify', NETLIFY_MCP_URL] +const CLAUDE_ADD = ['mcp', 'add', '--transport', 'http', '--scope', 'user', 'netlify', NETLIFY_MCP_URL] +const CODEX_ADD = ['mcp', 'add', 'netlify', '--url', NETLIFY_MCP_URL] const logged = () => vi @@ -31,96 +43,214 @@ const warned = () => .mock.calls.map(([message]) => message) .join('\n') +const neverChoose = vi.fn(() => Promise.reject(new Error('prompt should not open'))) + describe('setupAgentMcp', () => { - afterEach(() => { - vi.clearAllMocks() + let root: string + let project: string + let bin: string + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'netlify-agent-mcp-')) + project = join(root, 'project') + bin = join(root, 'bin') + await mkdir(project) + await mkdir(bin) }) - test('adds the hosted server at local scope from the project root when Claude Code has none', async () => { - execaMock.mockResolvedValueOnce(exited(1, 'No MCP server named "netlify"')).mockResolvedValueOnce(exited(0)) - - await expect(setupAgentMcp({ workingDir: '/project' })).resolves.toEqual({ claudeCode: 'added' }) - - expect(execaMock).toHaveBeenNthCalledWith( - 1, - 'claude', - ['mcp', 'get', 'netlify'], - expect.objectContaining({ cwd: '/project', reject: false }), - ) - expect(execaMock).toHaveBeenNthCalledWith( - 2, - 'claude', - ADD_ARGS, - expect.objectContaining({ cwd: '/project', reject: false }), - ) - expect(logged()).toContain('Nothing was written to the repository') - expect(logged()).toContain('claude mcp remove --scope local netlify') + afterEach(async () => { + vi.clearAllMocks() + await rm(root, { recursive: true, force: true }) }) - test('leaves an existing netlify server alone, whatever scope or host it uses', async () => { - execaMock.mockResolvedValueOnce(exited(0)) + const install = async (...binaries: string[]) => { + for (const binary of binaries) { + await writeFile(join(bin, binary), '') + await chmod(join(bin, binary), 0o755) + } + } - await expect(setupAgentMcp({ workingDir: '/project' })).resolves.toEqual({ claudeCode: 'present' }) + const run = (env: NodeJS.ProcessEnv, options: { interactive?: boolean; choose?: ChooseAgents } = {}) => + setupAgentMcp({ + workingDir: project, + env: { PATH: bin, ...env }, + interactive: options.interactive ?? false, + choose: options.choose ?? neverChoose, + }) - expect(execaMock).toHaveBeenCalledTimes(1) - expect(logged()).toContain('already has a netlify MCP server') - }) + describe('when an agent runs init', () => { + test('adds the server to that agent at user scope, without a prompt', async () => { + execaMock.mockResolvedValueOnce(missingServer()).mockResolvedValueOnce(exited(0)) - test('does nothing for Claude Code when it is not installed, and still points at the docs', async () => { - execaMock.mockResolvedValueOnce(notInstalled()) + await expect(run({ NETLIFY_AGENT: 'claude-code' }, { interactive: true })).resolves.toEqual({ + selection: 'driving', + agents: { 'claude-code': 'added' }, + }) - await expect(setupAgentMcp({ workingDir: '/project' })).resolves.toEqual({ claudeCode: 'unavailable' }) + expect(neverChoose).not.toHaveBeenCalled() + expect(execaMock).toHaveBeenNthCalledWith( + 1, + 'claude', + ['mcp', 'get', 'netlify'], + expect.objectContaining({ cwd: project, reject: false }), + ) + expect(execaMock).toHaveBeenNthCalledWith(2, 'claude', CLAUDE_ADD, expect.objectContaining({ cwd: project })) + expect(logged()).toContain('claude mcp remove --scope user netlify') + }) - expect(execaMock).toHaveBeenCalledTimes(1) - expect(warn).not.toHaveBeenCalled() - expect(logged()).toContain('docs.netlify.com/welcome/build-with-ai/netlify-mcp-server') - }) + test('uses the Codex CLI when Codex runs init', async () => { + execaMock + .mockResolvedValueOnce(exited(1, "Error: No MCP server named 'netlify' found.")) + .mockResolvedValueOnce(exited(0)) - test('does not add when the lookup times out, so it never risks a duplicate', async () => { - execaMock.mockResolvedValueOnce(timedOut()) + await expect(run({ CODEX_CI: '1' })).resolves.toEqual({ selection: 'driving', agents: { codex: 'added' } }) - await expect(setupAgentMcp({ workingDir: '/project' })).resolves.toMatchObject({ claudeCode: 'failed' }) + expect(execaMock).toHaveBeenNthCalledWith(2, 'codex', CODEX_ADD, expect.anything()) + }) - expect(execaMock).toHaveBeenCalledTimes(1) - expect(warned()).toContain('timed out') - expect(logged()).toContain(`claude ${ADD_ARGS.join(' ')}`) - }) + test('leaves an existing server alone and points an old-host entry at the new one', async () => { + execaMock.mockResolvedValueOnce(exited(0, '', 'URL: https://netlify-mcp.netlify.app/mcp')) - test('points an existing entry on the old host at the new one, without changing it', async () => { - execaMock.mockResolvedValueOnce({ ...exited(0), stdout: 'URL: https://netlify-mcp.netlify.app/mcp' }) + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toEqual({ + selection: 'driving', + agents: { 'claude-code': 'present' }, + }) - await expect(setupAgentMcp({ workingDir: '/project' })).resolves.toEqual({ claudeCode: 'present' }) + expect(execaMock).toHaveBeenCalledTimes(1) + expect(logged()).toContain('already has a netlify MCP server') + expect(logged()).toContain(`now lives at ${NETLIFY_MCP_URL}`) + }) - expect(execaMock).toHaveBeenCalledTimes(1) - expect(logged()).toContain(`now lives at ${NETLIFY_MCP_URL}`) - }) + test('prints an add-mcp command for an agent without its own add command', async () => { + await expect(run({ GEMINI_CLI: '1' })).resolves.toEqual({ + selection: 'driving', + agents: { 'gemini-cli': 'printed' }, + }) + + expect(execaMock).not.toHaveBeenCalled() + expect(logged()).toContain(`npx -y add-mcp@2.4.1 ${NETLIFY_MCP_URL} --name netlify -g -a gemini-cli`) + }) + + test('falls back to the add-mcp command when the agent CLI is not on PATH', async () => { + execaMock.mockResolvedValueOnce(notInstalled()) + + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toEqual({ + selection: 'driving', + agents: { 'claude-code': 'printed' }, + }) + + expect(logged()).toContain('-a claude-code') + expect(warn).not.toHaveBeenCalled() + }) + + test('does not add when the lookup fails for a reason other than a missing server', async () => { + execaMock.mockResolvedValueOnce(exited(1, 'Failed to connect')) + + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toEqual({ + selection: 'driving', + agents: { 'claude-code': 'failed' }, + }) + + expect(execaMock).toHaveBeenCalledTimes(1) + expect(warned()).toContain('Failed to connect') + expect(logged()).toContain(`claude ${CLAUDE_ADD.join(' ')}`) + }) + + test('does not add when the lookup times out', async () => { + execaMock.mockResolvedValueOnce(timedOut()) + + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ + agents: { 'claude-code': 'failed' }, + }) + + expect(execaMock).toHaveBeenCalledTimes(1) + expect(warned()).toContain('timed out') + }) + + test('reports a failed add with the command to run by hand', async () => { + execaMock.mockResolvedValueOnce(missingServer()).mockResolvedValueOnce(exited(1, 'boom')) - test('does not add when the lookup fails for a reason other than a missing server', async () => { - execaMock.mockResolvedValueOnce(exited(1, 'Failed to connect')) + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ + agents: { 'claude-code': 'failed' }, + }) - await expect(setupAgentMcp({ workingDir: '/project' })).resolves.toEqual({ - claudeCode: 'failed', - error: 'Failed to connect', + expect(warned()).toContain('boom') }) - expect(execaMock).toHaveBeenCalledTimes(1) + test('prints the generic command for an agent it has no mapping for', async () => { + await expect(run({ AGENT: 'amp' }, { interactive: true })).resolves.toEqual({ selection: 'none', agents: {} }) + + expect(neverChoose).not.toHaveBeenCalled() + expect(logged()).toContain(`npx -y add-mcp@2.4.1 ${NETLIFY_MCP_URL} --name netlify -g`) + }) }) - test('reports a failed add with the command to run by hand, without throwing', async () => { - execaMock.mockResolvedValueOnce(exited(1, 'No MCP server named "netlify"')).mockResolvedValueOnce(exited(1, 'boom')) + describe('when a person runs init', () => { + test('offers detected agents preticked and installed ones unticked, then adds the chosen ones', async () => { + await mkdir(join(project, '.cursor')) + await install('code', 'codex') + execaMock.mockResolvedValueOnce(exited(0)) + const choose = vi.fn((candidates: AgentCandidate[]) => + Promise.resolve(candidates.filter(({ agent }) => agent.id !== 'codex').map(({ agent }) => agent)), + ) + + await expect(run({ TERM_PROGRAM: 'vscode' }, { interactive: true, choose })).resolves.toEqual({ + selection: 'prompt', + agents: { vscode: 'added', cursor: 'printed' }, + }) + + expect(choose.mock.calls[0][0].map(({ agent, detected }) => [agent.id, detected])).toEqual([ + ['vscode', true], + ['cursor', true], + ['codex', false], + ]) + expect(execaMock).toHaveBeenCalledWith( + 'code', + ['--add-mcp', JSON.stringify({ name: 'netlify', type: 'http', url: NETLIFY_MCP_URL })], + expect.anything(), + ) + expect(logged()).toContain('-a cursor') + }) + + test('adds nothing when the person unticks every agent', async () => { + await install('claude') + + await expect(run({}, { interactive: true, choose: vi.fn(() => Promise.resolve([])) })).resolves.toEqual({ + selection: 'prompt', + agents: {}, + }) + + expect(execaMock).not.toHaveBeenCalled() + }) + + test('rethrows a cancelled prompt so init stops like any other prompt', async () => { + await install('claude') + const cancelled = Object.assign(new Error('User force closed the prompt'), { name: 'ExitPromptError' }) - await expect(setupAgentMcp({ workingDir: '/project' })).resolves.toEqual({ claudeCode: 'failed', error: 'boom' }) + await expect(run({}, { interactive: true, choose: vi.fn(() => Promise.reject(cancelled)) })).rejects.toBe( + cancelled, + ) + }) - expect(warned()).toContain('boom') - expect(logged()).toContain(`claude ${ADD_ARGS.join(' ')}`) + test('prints the generic command without a prompt when no terminal is attached', async () => { + await install('claude') + + await expect(run({})).resolves.toEqual({ selection: 'none', agents: {} }) + + expect(neverChoose).not.toHaveBeenCalled() + expect(execaMock).not.toHaveBeenCalled() + }) }) - test('turns an unexpected exception into a failure instead of breaking init', async () => { - execaMock.mockRejectedValueOnce(new Error('spawn exploded')) + describe('findCandidates', () => { + test('tells Cursor terminals apart from VS Code ones', async () => { + const candidates = await findCandidates(project, { PATH: bin, TERM_PROGRAM: 'vscode', CURSOR_TRACE_ID: 'x' }) + + expect(candidates.map(({ agent }) => agent.id)).toEqual(['cursor']) + }) - await expect(setupAgentMcp({ workingDir: '/project' })).resolves.toEqual({ - claudeCode: 'failed', - error: 'spawn exploded', + test('finds nothing in a plain terminal with no agent folders or CLIs', async () => { + await expect(findCandidates(project, { PATH: bin })).resolves.toEqual([]) }) }) }) From 0d3de81cb6f9c8a76ae0d732d8776fb3ca5466b8 Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Mon, 19 Oct 2026 22:54:39 -0400 Subject: [PATCH 03/19] feat(init): remember MCP agent choices per user 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. --- docs/commands/init.md | 1 + src/commands/init/index.ts | 5 ++ src/commands/init/init.ts | 20 ++++-- src/utils/init/agent-mcp.ts | 80 ++++++++++++++++++++-- tests/unit/utils/init/agent-mcp.test.ts | 89 ++++++++++++++++++++++++- 5 files changed, 183 insertions(+), 12 deletions(-) diff --git a/docs/commands/init.md b/docs/commands/init.md index 2f62344e4db..91bac037f43 100644 --- a/docs/commands/init.md +++ b/docs/commands/init.md @@ -23,6 +23,7 @@ netlify init - `git-remote-name` (*string*) - Name of Git remote to use. e.g. "origin" - `manual` (*boolean*) - Manually configure a git remote for CI - `reset-context` (*boolean*) - Replace locally edited Netlify skills with the latest release, and migrate or delete edited copies under renamed or deprecated names +- `reset-mcp-setup` (*boolean*) - Forget your saved Netlify MCP server choices and ask which AI coding agents to set up - `skip-mcp-setup` (*boolean*) - Skip adding the Netlify MCP server to your AI coding agents - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in diff --git a/src/commands/init/index.ts b/src/commands/init/index.ts index 825bf599762..94ca43726cf 100644 --- a/src/commands/init/index.ts +++ b/src/commands/init/index.ts @@ -16,6 +16,10 @@ export const createInitCommand = (program: BaseCommand) => 'Skip installing Netlify skills and adding the Netlify MCP server for AI coding agents', ) .option('--skip-mcp-setup', 'Skip adding the Netlify MCP server to your AI coding agents') + .option( + '--reset-mcp-setup', + 'Forget your saved Netlify MCP server choices and ask which AI coding agents to set up', + ) .option( '--reset-context', 'Replace locally edited Netlify skills with the latest release, and migrate or delete edited copies under renamed or deprecated names', @@ -31,6 +35,7 @@ For more information about getting started with Netlify CLI, see ${terminalLink( await init(options, command, { setupAgentSkills: !options.skipAgentSetup, setupAgentMcp: !options.skipAgentSetup && !options.skipMcpSetup, + resetMcpSetup: Boolean(options.resetMcpSetup), resetContext: Boolean(options.resetContext), }) }) diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index fdc76d8da61..c93be062e7c 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -216,6 +216,7 @@ type InitExtraOptions = { setupAgentSkills?: boolean | undefined setupAgentMcp?: boolean | undefined resetContext?: boolean | undefined + resetMcpSetup?: boolean | undefined } const installAgentSkills = async (workingDir: string, reset: boolean): Promise => { @@ -229,14 +230,14 @@ const installAgentSkills = async (workingDir: string, reset: boolean): Promise => { - const { selection, agents, failed = false } = await setupAgentMcp({ workingDir }) - await track('sites_agentMcpSetup', { selection, agents, failed }) +const addAgentMcp = async (workingDir: string, reset: boolean): Promise => { + const { selection, agents, failed = false } = await setupAgentMcp({ workingDir, reset }) + await track('sites_agentMcpSetup', { selection, agents, failed, resetMcpSetup: reset }) } const setUpAgents = async ( command: BaseCommand, - { skills, mcp, reset }: { skills: boolean; mcp: boolean; reset: boolean }, + { skills, mcp, reset, resetMcp }: { skills: boolean; mcp: boolean; reset: boolean; resetMcp: boolean }, ): Promise => { if (!skills && !mcp) { return @@ -253,7 +254,7 @@ const setUpAgents = async ( await installAgentSkills(repositoryRoot, reset) } if (mcp) { - await addAgentMcp(repositoryRoot) + await addAgentMcp(repositoryRoot, resetMcp) } } @@ -266,6 +267,7 @@ export const init = async ( setupAgentSkills: shouldSetupAgentSkills = false, setupAgentMcp: shouldSetupAgentMcp = false, resetContext = false, + resetMcpSetup = false, }: InitExtraOptions = {}, ): Promise => { command.setAnalyticsPayload({ @@ -274,6 +276,7 @@ export const init = async ( skipAgentSetup: options.skipAgentSetup, skipMcpSetup: options.skipMcpSetup, resetContext: options.resetContext, + resetMcpSetup: options.resetMcpSetup, }) const { repositoryRoot, state } = command.netlify @@ -285,7 +288,12 @@ export const init = async ( // Add .netlify to .gitignore file await ensureNetlifyIgnore(repositoryRoot) - await setUpAgents(command, { skills: shouldSetupAgentSkills, mcp: shouldSetupAgentMcp, reset: resetContext }) + await setUpAgents(command, { + skills: shouldSetupAgentSkills, + mcp: shouldSetupAgentMcp, + reset: resetContext, + resetMcp: resetMcpSetup, + }) const repoUrl = getRepoUrl(existingSiteInfo) if (repoUrl && !options.force) { diff --git a/src/utils/init/agent-mcp.ts b/src/utils/init/agent-mcp.ts index 0880e7487e5..1d755c7d466 100644 --- a/src/utils/init/agent-mcp.ts +++ b/src/utils/init/agent-mcp.ts @@ -2,9 +2,10 @@ import { constants as fsConstants, promises as fs } from 'node:fs' import path from 'node:path' import { checkbox } from '@inquirer/prompts' +import { getGlobalConfigStore } from '@netlify/dev-utils' import { type CanonicalAgentName, getDrivingAgent } from '../agent-detection.js' -import { chalk, log, warn } from '../command-helpers.js' +import { chalk, log, netlifyCommand, warn } from '../command-helpers.js' import execa from '../execa.js' import { isInteractive } from '../scripted-commands.js' @@ -97,7 +98,7 @@ export const MCP_AGENTS: McpAgent[] = [ ] export type AgentMcpStatus = 'added' | 'present' | 'printed' | 'failed' -export type AgentSelection = 'driving' | 'prompt' | 'none' +export type AgentSelection = 'driving' | 'prompt' | 'saved' | 'none' export interface AgentMcpSetupSummary { selection: AgentSelection @@ -243,17 +244,63 @@ const addNatively = async ( const hasNativeSetup = (agent: McpAgent): agent is McpAgent & { binary: string; native: NativeSetup } => agent.binary !== undefined && agent.native !== undefined +export interface McpChoiceStore { + get(key: string): unknown + set(key: string, value: unknown): void +} + +interface SavedChoice { + url: string + choice: 'accepted' | 'declined' +} + +const SAVED_CHOICES_KEY = 'agentMcpSetup' + +const savedChoiceKey = (agent: McpAgent): string => `${SAVED_CHOICES_KEY}.${agent.id}` + +const hasSavedChoice = (store: McpChoiceStore, agent: McpAgent): boolean => + (store.get(savedChoiceKey(agent)) as SavedChoice | undefined)?.url === NETLIFY_MCP_URL + +const saveChoices = ( + store: McpChoiceStore, + offered: McpAgent[], + chosen: McpAgent[], + statuses: Record, +): void => { + for (const agent of offered) { + if (!chosen.includes(agent)) { + store.set(savedChoiceKey(agent), { url: NETLIFY_MCP_URL, choice: 'declined' } satisfies SavedChoice) + } else if (statuses[agent.id] !== 'failed') { + store.set(savedChoiceKey(agent), { url: NETLIFY_MCP_URL, choice: 'accepted' } satisfies SavedChoice) + } + } +} + +const loadGlobalStore = async (): Promise => await getGlobalConfigStore() + +const resetHint = (): string => + `Run ${chalk.cyanBright.bold(`${netlifyCommand()} init --reset-mcp-setup`)} to choose again.` + +type Selected = + | { selection: 'driving'; agents: McpAgent[] } + | { selection: 'prompt'; agents: McpAgent[]; offered: McpAgent[]; store: McpChoiceStore } + | { selection: 'saved' | 'none'; agents: [] } + const selectAgents = async ({ workingDir, env, interactive, choose, + reset, + store, }: { workingDir: string env: NodeJS.ProcessEnv interactive: boolean choose: ChooseAgents -}): Promise<{ selection: AgentSelection; agents: McpAgent[] }> => { + reset: boolean + store: () => Promise +}): Promise => { const drivingAgent = getDrivingAgent(env) if (drivingAgent) { const agent = MCP_AGENTS.find(({ drivenBy }) => drivenBy?.includes(drivingAgent.name)) @@ -266,7 +313,17 @@ const selectAgents = async ({ if (candidates.length === 0) { return { selection: 'none', agents: [] } } - return { selection: 'prompt', agents: await choose(candidates) } + const choices = await store() + const undecided = reset ? candidates : candidates.filter(({ agent }) => !hasSavedChoice(choices, agent)) + if (undecided.length === 0) { + return { selection: 'saved', agents: [] } + } + return { + selection: 'prompt', + agents: await choose(undecided), + offered: undecided.map(({ agent }) => agent), + store: choices, + } } export const setupAgentMcp = async ({ @@ -274,16 +331,25 @@ export const setupAgentMcp = async ({ env = process.env, interactive = isInteractive(), choose = chooseWithPrompt, + reset = false, + store = loadGlobalStore, }: { workingDir: string env?: NodeJS.ProcessEnv interactive?: boolean choose?: ChooseAgents + reset?: boolean + store?: () => Promise }): Promise => { const summary: AgentMcpSetupSummary = { selection: 'none', agents: {} } try { - const { selection, agents } = await selectAgents({ workingDir, env, interactive, choose }) + const selected = await selectAgents({ workingDir, env, interactive, choose, reset, store }) + const { selection, agents } = selected summary.selection = selection + if (selection === 'saved') { + log(`Using your saved choices for the Netlify MCP server. ${resetHint()}`) + return summary + } if (selection === 'none') { log(`To add the Netlify MCP server to your AI agents, run ${chalk.cyanBright.bold(addMcpCommand([]))}`) return summary @@ -301,6 +367,10 @@ export const setupAgentMcp = async ({ const labels = toPrint.map(({ label }) => label).join(', ') log(`To add the Netlify MCP server to ${labels}, run ${chalk.cyanBright.bold(addMcpCommand(toPrint))}`) } + if (selected.selection === 'prompt') { + saveChoices(selected.store, selected.offered, agents, summary.agents) + log(`Your choices are saved for next time. ${resetHint()}`) + } } catch (error) { if (error instanceof Error && error.name === 'ExitPromptError') { throw error diff --git a/tests/unit/utils/init/agent-mcp.test.ts b/tests/unit/utils/init/agent-mcp.test.ts index 2001cc700be..d1c36ad8138 100644 --- a/tests/unit/utils/init/agent-mcp.test.ts +++ b/tests/unit/utils/init/agent-mcp.test.ts @@ -9,6 +9,7 @@ import execa from '../../../../src/utils/execa.js' import { type AgentCandidate, type ChooseAgents, + type McpChoiceStore, NETLIFY_MCP_URL, findCandidates, setupAgentMcp, @@ -49,6 +50,7 @@ describe('setupAgentMcp', () => { let root: string let project: string let bin: string + let saved: Map beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'netlify-agent-mcp-')) @@ -56,6 +58,7 @@ describe('setupAgentMcp', () => { bin = join(root, 'bin') await mkdir(project) await mkdir(bin) + saved = new Map() }) afterEach(async () => { @@ -70,14 +73,30 @@ describe('setupAgentMcp', () => { } } - const run = (env: NodeJS.ProcessEnv, options: { interactive?: boolean; choose?: ChooseAgents } = {}) => + const store = (): Promise => + Promise.resolve({ get: (key) => saved.get(key), set: (key, value) => saved.set(key, value) }) + + const run = ( + env: NodeJS.ProcessEnv, + options: { interactive?: boolean; choose?: ChooseAgents; reset?: boolean } = {}, + ) => setupAgentMcp({ workingDir: project, env: { PATH: bin, ...env }, interactive: options.interactive ?? false, choose: options.choose ?? neverChoose, + reset: options.reset ?? false, + store, }) + const chooseIds = (...ids: string[]) => + vi.fn((candidates) => + Promise.resolve(candidates.map(({ agent }) => agent).filter(({ id }) => ids.includes(id))), + ) + + const offeredIds = (choose: ReturnType, call = 0) => + choose.mock.calls[call][0].map(({ agent }) => agent.id) + describe('when an agent runs init', () => { test('adds the server to that agent at user scope, without a prompt', async () => { execaMock.mockResolvedValueOnce(missingServer()).mockResolvedValueOnce(exited(0)) @@ -242,6 +261,74 @@ describe('setupAgentMcp', () => { }) }) + describe('saved choices', () => { + test('remembers both a yes and a no, so the next run does not prompt', async () => { + await install('claude', 'codex') + execaMock.mockResolvedValueOnce(missingServer()).mockResolvedValueOnce(exited(0)) + const choose = chooseIds('claude-code') + + await run({}, { interactive: true, choose }) + expect(offeredIds(choose)).toEqual(['claude-code', 'codex']) + expect(logged()).toContain('init --reset-mcp-setup') + + await expect(run({}, { interactive: true })).resolves.toEqual({ selection: 'saved', agents: {} }) + expect(neverChoose).not.toHaveBeenCalled() + expect(execaMock).toHaveBeenCalledTimes(2) + expect(logged()).toContain('Using your saved choices') + }) + + test('asks only about an agent installed since the last run', async () => { + await install('codex') + await run({}, { interactive: true, choose: chooseIds() }) + await install('opencode') + const choose = chooseIds() + + await run({}, { interactive: true, choose }) + + expect(offeredIds(choose)).toEqual(['opencode']) + }) + + test('--reset-mcp-setup asks about every agent again', async () => { + await install('codex') + await run({}, { interactive: true, choose: chooseIds() }) + const choose = chooseIds() + + await run({}, { interactive: true, choose, reset: true }) + + expect(offeredIds(choose)).toEqual(['codex']) + }) + + test('does not save a yes whose add failed, so it is offered again', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(missingServer()).mockResolvedValueOnce(exited(1, 'boom')) + await run({}, { interactive: true, choose: chooseIds('claude-code') }) + const choose = chooseIds() + + await run({}, { interactive: true, choose }) + + expect(offeredIds(choose)).toEqual(['claude-code']) + }) + + test('offers an agent again when its saved choice was for a different server URL', async () => { + await install('codex') + saved.set('agentMcpSetup.codex', { url: 'https://netlify-mcp.netlify.app/mcp', choice: 'declined' }) + const choose = chooseIds() + + await run({}, { interactive: true, choose }) + + expect(offeredIds(choose)).toEqual(['codex']) + expect(saved.get('agentMcpSetup.codex')).toEqual({ url: NETLIFY_MCP_URL, choice: 'declined' }) + }) + + test('leaves saved choices alone when an agent runs init', async () => { + execaMock.mockResolvedValueOnce(exited(0)) + + await run({ NETLIFY_AGENT: 'claude-code' }) + + expect(saved.size).toBe(0) + }) + }) + describe('findCandidates', () => { test('tells Cursor terminals apart from VS Code ones', async () => { const candidates = await findCandidates(project, { PATH: bin, TERM_PROGRAM: 'vscode', CURSOR_TRACE_ID: 'x' }) From 433ef8f849d78b0cdccefd5ec00b63cc76354aa8 Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Mon, 19 Oct 2026 23:40:35 -0400 Subject: [PATCH 04/19] test(init): use .cmd fake agent binaries on Windows --- tests/unit/utils/init/agent-mcp.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/utils/init/agent-mcp.test.ts b/tests/unit/utils/init/agent-mcp.test.ts index d1c36ad8138..7ae341028d9 100644 --- a/tests/unit/utils/init/agent-mcp.test.ts +++ b/tests/unit/utils/init/agent-mcp.test.ts @@ -68,8 +68,9 @@ describe('setupAgentMcp', () => { const install = async (...binaries: string[]) => { for (const binary of binaries) { - await writeFile(join(bin, binary), '') - await chmod(join(bin, binary), 0o755) + const file = join(bin, process.platform === 'win32' ? `${binary}.cmd` : binary) + await writeFile(file, '') + await chmod(file, 0o755) } } From bb3ddc177756ec309563429d5a9faa99bf8c176b Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Mon, 19 Oct 2026 23:44:53 -0400 Subject: [PATCH 05/19] fix(init): install skills in new and empty folders 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. --- src/commands/init/init.ts | 17 ++++----- src/utils/init/agent-location.ts | 23 +++--------- tests/unit/utils/init/agent-location.test.ts | 37 +++++++------------- 3 files changed, 27 insertions(+), 50 deletions(-) diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index c93be062e7c..c7d450271ef 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -11,7 +11,7 @@ import type BaseCommand from '../base-command.js' import { link } from '../link/link.js' import { sitesCreate } from '../sites/sites-create.js' import type { LocalState, SiteInfo } from '../../utils/types.js' -import { findAgentSetupBlocker } from '../../utils/init/agent-location.js' +import { findSkillsSetupBlocker } from '../../utils/init/agent-location.js' import { setupAgentMcp } from '../../utils/init/agent-mcp.js' import { setupAgentSkills } from '../../utils/init/agent-skills.js' import { getBuildSettings, saveNetlifyToml } from '../../utils/init/utils.js' @@ -244,14 +244,15 @@ const setUpAgents = async ( } const { repositoryRoot } = command.netlify log() - const blocker = await findAgentSetupBlocker(repositoryRoot) - if (blocker) { - log(`Skipped setting up AI agents: ${blocker}.`) - await track('sites_agentSetupSkipped', { skills, mcp }) - return - } if (skills) { - await installAgentSkills(repositoryRoot, reset) + const blocker = await findSkillsSetupBlocker(repositoryRoot) + if (blocker) { + log(`Skipped installing Netlify skills: ${blocker}.`) + log(` Run ${chalk.cyanBright.bold(`${netlifyCommand()} init`)} from your project folder to install them there.`) + await track('sites_agentSkillsSkipped', {}) + } else { + await installAgentSkills(repositoryRoot, reset) + } } if (mcp) { await addAgentMcp(repositoryRoot, resetMcp) diff --git a/src/utils/init/agent-location.ts b/src/utils/init/agent-location.ts index b6f7db6025f..6832fa3c338 100644 --- a/src/utils/init/agent-location.ts +++ b/src/utils/init/agent-location.ts @@ -2,8 +2,6 @@ import { promises as fs } from 'node:fs' import { homedir } from 'node:os' import path from 'node:path' -const PROJECT_MARKERS = ['.git', 'package.json', 'netlify.toml'] as const - const realpathOrSelf = async (target: string): Promise => { try { return await fs.realpath(target) @@ -12,24 +10,13 @@ const realpathOrSelf = async (target: string): Promise => { } } -const exists = async (target: string): Promise => { - try { - await fs.lstat(target) - return true - } catch { - return false - } -} - -export const findAgentSetupBlocker = async (root: string, home: string = homedir()): Promise => { +export const findSkillsSetupBlocker = async (root: string, home: string = homedir()): Promise => { const resolvedRoot = await realpathOrSelf(root) if (resolvedRoot === (await realpathOrSelf(home))) { - return 'this is your home directory, where agent folders hold settings for every project' + return 'this is your home directory, where they would apply to every project' } - for (const marker of PROJECT_MARKERS) { - if (await exists(path.join(resolvedRoot, marker))) { - return undefined - } + if (resolvedRoot === path.parse(resolvedRoot).root) { + return 'this is the root of the file system' } - return `no ${PROJECT_MARKERS.join(', ')} found in ${resolvedRoot}` + return undefined } diff --git a/tests/unit/utils/init/agent-location.test.ts b/tests/unit/utils/init/agent-location.test.ts index f4b9a00018f..f1dc4187c1f 100644 --- a/tests/unit/utils/init/agent-location.test.ts +++ b/tests/unit/utils/init/agent-location.test.ts @@ -1,12 +1,12 @@ -import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, parse } from 'node:path' import { afterEach, beforeEach, describe, expect, test } from 'vitest' -import { findAgentSetupBlocker } from '../../../../src/utils/init/agent-location.js' +import { findSkillsSetupBlocker } from '../../../../src/utils/init/agent-location.js' -describe('findAgentSetupBlocker', () => { +describe('findSkillsSetupBlocker', () => { let root: string let home: string @@ -22,39 +22,28 @@ describe('findAgentSetupBlocker', () => { test('blocks the home directory even when it is a git repository', async () => { await mkdir(join(home, '.git')) - await expect(findAgentSetupBlocker(home, home)).resolves.toMatch(/home directory/) + await expect(findSkillsSetupBlocker(home, home)).resolves.toMatch(/home directory/) }) test('blocks a path that resolves to the home directory through a symlink', async () => { const link = join(root, 'link-to-home') await symlink(home, link) - await expect(findAgentSetupBlocker(link, home)).resolves.toMatch(/home directory/) + await expect(findSkillsSetupBlocker(link, home)).resolves.toMatch(/home directory/) }) - test('blocks a directory with no project marker', async () => { - const empty = join(root, 'empty') - await mkdir(empty) - await expect(findAgentSetupBlocker(empty, home)).resolves.toMatch(/no \.git, package\.json, netlify\.toml found/) - }) - - test.each(['package.json', 'netlify.toml'])('allows a directory with %s', async (marker) => { - const project = join(root, 'project') - await mkdir(project) - await writeFile(join(project, marker), '') - await expect(findAgentSetupBlocker(project, home)).resolves.toBeUndefined() + test('blocks the root of the file system', async () => { + await expect(findSkillsSetupBlocker(parse(root).root, home)).resolves.toMatch(/root of the file system/) }) - test('allows a git worktree, where .git is a file', async () => { - const worktree = join(root, 'worktree') - await mkdir(worktree) - await writeFile(join(worktree, '.git'), 'gitdir: /elsewhere\n') - await expect(findAgentSetupBlocker(worktree, home)).resolves.toBeUndefined() + test('allows an empty folder, where an agent starts a new site', async () => { + const empty = join(root, 'new-site') + await mkdir(empty) + await expect(findSkillsSetupBlocker(empty, home)).resolves.toBeUndefined() }) test('allows a project nested inside the home directory', async () => { const project = join(home, 'code', 'site') await mkdir(project, { recursive: true }) - await writeFile(join(project, 'package.json'), '{}') - await expect(findAgentSetupBlocker(project, home)).resolves.toBeUndefined() + await expect(findSkillsSetupBlocker(project, home)).resolves.toBeUndefined() }) }) From 059e415c4f9716e53089772ffc0f75fa0fad19c7 Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Tue, 20 Oct 2026 10:24:29 -0400 Subject: [PATCH 06/19] fix(init): only write MCP config when a person picks it 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. --- src/utils/init/agent-mcp.ts | 161 ++++++++++++---- tests/integration/commands/init/init.test.ts | 28 ++- tests/unit/utils/init/agent-mcp.test.ts | 182 +++++++++++++------ 3 files changed, 262 insertions(+), 109 deletions(-) diff --git a/src/utils/init/agent-mcp.ts b/src/utils/init/agent-mcp.ts index 1d755c7d466..863482b6753 100644 --- a/src/utils/init/agent-mcp.ts +++ b/src/utils/init/agent-mcp.ts @@ -97,7 +97,7 @@ export const MCP_AGENTS: McpAgent[] = [ { id: 'cline', label: 'Cline', drivenBy: ['cline'], projectMarkers: ['.clinerules'] }, ] -export type AgentMcpStatus = 'added' | 'present' | 'printed' | 'failed' +export type AgentMcpStatus = 'added' | 'present' | 'kept' | 'printed' | 'declined' | 'failed' export type AgentSelection = 'driving' | 'prompt' | 'saved' | 'none' export interface AgentMcpSetupSummary { @@ -140,15 +140,14 @@ const isExecutableFile = async (target: string): Promise => { } } -const isOnPath = async (binary: string, env: NodeJS.ProcessEnv): Promise => { +const findOnPath = async (binary: string, env: NodeJS.ProcessEnv): Promise => { const extensions = process.platform === 'win32' ? (env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') : [''] - const directories = (env.PATH ?? '').split(path.delimiter).filter(Boolean) - const found = await Promise.all( - directories.flatMap((directory) => - extensions.map((extension) => isExecutableFile(path.join(directory, binary + extension))), - ), - ) - return found.includes(true) + const candidates = (env.PATH ?? '') + .split(path.delimiter) + .filter((directory) => path.isAbsolute(directory)) + .flatMap((directory) => extensions.map((extension) => path.join(directory, binary + extension))) + const executable = await Promise.all(candidates.map(isExecutableFile)) + return candidates.find((_, index) => executable[index]) } const hasProjectMarker = async (agent: McpAgent, workingDir: string): Promise => { @@ -164,7 +163,7 @@ export const findCandidates = async (workingDir: string, env: NodeJS.ProcessEnv) const candidates: AgentCandidate[] = [] for (const agent of MCP_AGENTS) { const detected = Boolean(agent.inTerminal?.(env)) || (await hasProjectMarker(agent, workingDir)) - if (detected || (agent.binary !== undefined && (await isOnPath(agent.binary, env)))) { + if (detected || (agent.binary !== undefined && (await findOnPath(agent.binary, env)) !== undefined)) { candidates.push({ agent, detected }) } } @@ -181,51 +180,111 @@ const addMcpCommand = (agents: McpAgent[]): string => ...agents.flatMap(({ id }) => ['-a', id]), ].join(' ') -const shellQuote = (arg: string): string => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`) +const shellQuote = (arg: string): string => { + if (/^[\w@%+=:,./\\-]+$/.test(arg)) { + return arg + } + return process.platform === 'win32' ? `"${arg.replace(/"/g, '\\"')}"` : `'${arg.replace(/'/g, `'\\''`)}'` +} const formatCommand = (binary: string, args: string[]): string => 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 }) +const isErrnoException = (value: unknown): value is NodeJS.ErrnoException => + value instanceof Error && Object.hasOwn(value, 'code') + +const runNative = async (executable: string, args: string[], cwd: string) => { + const result = await execa(executable, args, { cwd, reject: false, stdin: 'ignore', timeout: NATIVE_TIMEOUT_MS }) return { - missingBinary: (result as { code?: string }).code === 'ENOENT', + missingBinary: isErrnoException(result) && result.code === 'ENOENT', exitCode: result.exitCode, output: [result.stderr, result.stdout].filter(Boolean).join('\n').trim(), timedOut: result.timedOut, } } -const describeFailure = ({ timedOut, output, exitCode }: Awaited>): string => +type NativeRun = Awaited> + +const describeFailure = ({ timedOut, output, exitCode }: NativeRun): string => timedOut ? `timed out after ${(NATIVE_TIMEOUT_MS / 1000).toString()} seconds` : output || `exit code ${String(exitCode)}` -const addNatively = async ( - agent: McpAgent & { binary: string; native: NativeSetup }, +type NativeAgent = McpAgent & { binary: string; native: NativeSetup } + +const hasNativeSetup = (agent: McpAgent): agent is NativeAgent => + agent.binary !== undefined && agent.native !== undefined + +type Existing = { found: 'ours' | 'other'; output: string } +type Lookup = Existing | { found: 'none' } | { found: 'unknown'; run: NativeRun } + +const lookUp = async (executable: string, lookup: string[], workingDir: string): Promise => { + const run = await runNative(executable, lookup, workingDir) + if (run.exitCode === 0) { + return { found: run.output.includes(NETLIFY_MCP_URL) ? 'ours' : 'other', output: run.output } + } + if (run.exitCode === 1 && run.output.includes('No MCP server named')) { + return { found: 'none' } + } + return { found: 'unknown', run } +} + +const isExisting = (lookup: Lookup): lookup is Existing => lookup.found === 'ours' || lookup.found === 'other' + +const reportExisting = (agent: NativeAgent, { found, output }: Existing): AgentMcpStatus => { + if (found === 'ours') { + log(`${agent.label} already has the Netlify MCP server.`) + return 'present' + } + const where = output.includes(LEGACY_MCP_HOST) ? `the old host, ${LEGACY_MCP_HOST}` : 'a different URL' + log( + `${agent.label} has a ${chalk.cyan(NETLIFY_MCP_SERVER_NAME)} MCP server pointing at ${where}, so it was left unchanged.`, + ) + if (agent.native.remove) { + log( + ` To switch to ${chalk.underline(NETLIFY_MCP_URL)}, run ${formatCommand(agent.binary, agent.native.remove)} then ${formatCommand(agent.binary, agent.native.add)}`, + ) + } + return 'kept' +} + +const suggestNatively = async ( + agent: NativeAgent, + env: NodeJS.ProcessEnv, workingDir: string, ): Promise => { + const executable = await findOnPath(agent.binary, env) + if (executable && agent.native.lookup) { + const existing = await lookUp(executable, agent.native.lookup, workingDir) + if (isExisting(existing)) { + return reportExisting(agent, existing) + } + } + log( + `To add the Netlify MCP server to ${agent.label} for your user, run ${formatCommand(agent.binary, agent.native.add)}`, + ) + return 'printed' +} + +const addNatively = async (agent: NativeAgent, env: NodeJS.ProcessEnv, workingDir: string): Promise => { const { binary, native, label } = agent + const executable = await findOnPath(binary, env) + if (!executable) { + return 'printed' + } if (native.lookup) { - const existing = await runNative(binary, native.lookup, workingDir) - if (existing.missingBinary) { - return 'printed' - } - if (existing.exitCode === 0) { - log(`${label} already has a ${chalk.cyan(NETLIFY_MCP_SERVER_NAME)} MCP server, so it was left unchanged.`) - if (existing.output.includes(LEGACY_MCP_HOST)) { - log(` It points at ${LEGACY_MCP_HOST}. Netlify's MCP server now lives at ${chalk.underline(NETLIFY_MCP_URL)}`) - } - return 'present' - } - if (existing.exitCode !== 1 || !existing.output.includes('No MCP server named')) { - warn(`Could not check ${label} for an existing Netlify MCP server: ${describeFailure(existing)}`) + const existing = await lookUp(executable, native.lookup, workingDir) + if (existing.found === 'unknown') { + warn(`Could not check ${label} for an existing Netlify MCP server: ${describeFailure(existing.run)}`) log(` Add it yourself with ${formatCommand(binary, native.add)}`) return 'failed' } + if (isExisting(existing)) { + return reportExisting(agent, existing) + } } - const added = await runNative(binary, native.add, workingDir) + const added = await runNative(executable, native.add, workingDir) if (added.missingBinary) { return 'printed' } @@ -241,9 +300,6 @@ const addNatively = async ( return 'added' } -const hasNativeSetup = (agent: McpAgent): agent is McpAgent & { binary: string; native: NativeSetup } => - agent.binary !== undefined && agent.native !== undefined - export interface McpChoiceStore { get(key: string): unknown set(key: string, value: unknown): void @@ -258,8 +314,10 @@ const SAVED_CHOICES_KEY = 'agentMcpSetup' const savedChoiceKey = (agent: McpAgent): string => `${SAVED_CHOICES_KEY}.${agent.id}` -const hasSavedChoice = (store: McpChoiceStore, agent: McpAgent): boolean => - (store.get(savedChoiceKey(agent)) as SavedChoice | undefined)?.url === NETLIFY_MCP_URL +const savedChoice = (store: McpChoiceStore, agent: McpAgent): SavedChoice | undefined => { + const saved = store.get(savedChoiceKey(agent)) as SavedChoice | undefined + return saved?.url === NETLIFY_MCP_URL ? saved : undefined +} const saveChoices = ( store: McpChoiceStore, @@ -282,7 +340,7 @@ const resetHint = (): string => `Run ${chalk.cyanBright.bold(`${netlifyCommand()} init --reset-mcp-setup`)} to choose again.` type Selected = - | { selection: 'driving'; agents: McpAgent[] } + | { selection: 'driving'; agents: [McpAgent]; declined: boolean } | { selection: 'prompt'; agents: McpAgent[]; offered: McpAgent[]; store: McpChoiceStore } | { selection: 'saved' | 'none'; agents: [] } @@ -304,7 +362,14 @@ const selectAgents = async ({ const drivingAgent = getDrivingAgent(env) if (drivingAgent) { const agent = MCP_AGENTS.find(({ drivenBy }) => drivenBy?.includes(drivingAgent.name)) - return agent ? { selection: 'driving', agents: [agent] } : { selection: 'none', agents: [] } + if (!agent) { + return { selection: 'none', agents: [] } + } + const choices = await store() + if (reset) { + choices.set(savedChoiceKey(agent), undefined) + } + return { selection: 'driving', agents: [agent], declined: savedChoice(choices, agent)?.choice === 'declined' } } if (!interactive) { return { selection: 'none', agents: [] } @@ -314,7 +379,7 @@ const selectAgents = async ({ return { selection: 'none', agents: [] } } const choices = await store() - const undecided = reset ? candidates : candidates.filter(({ agent }) => !hasSavedChoice(choices, agent)) + const undecided = reset ? candidates : candidates.filter(({ agent }) => !savedChoice(choices, agent)) if (undecided.length === 0) { return { selection: 'saved', agents: [] } } @@ -355,9 +420,25 @@ export const setupAgentMcp = async ({ return summary } + if (selected.selection === 'driving') { + const [agent] = selected.agents + if (selected.declined) { + log(`You chose not to add the Netlify MCP server to ${agent.label}. ${resetHint()}`) + summary.agents[agent.id] = 'declined' + } else if (hasNativeSetup(agent)) { + summary.agents[agent.id] = await suggestNatively(agent, env, workingDir) + } else { + log( + `To add the Netlify MCP server to ${agent.label} for your user, run ${chalk.cyanBright.bold(addMcpCommand([agent]))}`, + ) + summary.agents[agent.id] = 'printed' + } + return summary + } + const toPrint: McpAgent[] = [] for (const agent of agents) { - const status = hasNativeSetup(agent) ? await addNatively(agent, workingDir) : 'printed' + const status = hasNativeSetup(agent) ? await addNatively(agent, env, workingDir) : 'printed' summary.agents[agent.id] = status if (status === 'printed') { toPrint.push(agent) diff --git a/tests/integration/commands/init/init.test.ts b/tests/integration/commands/init/init.test.ts index f65fa08bc8d..4fe0413b261 100644 --- a/tests/integration/commands/init/init.test.ts +++ b/tests/integration/commands/init/init.test.ts @@ -857,21 +857,19 @@ describe.concurrent('commands/init', () => { }) test.skipIf(process.platform === 'win32')( - 'netlify init adds the Netlify MCP server to the agent running it once and respects --skip-mcp-setup', + 'netlify init tells the agent running it how to add the Netlify MCP server, and both skip flags silence it', async (t) => { await withSiteBuilder(t, async (builder) => { await builder.withGit().build() const claudeBin = path.join(builder.directory, '.fake-bin') const callsFile = path.join(claudeBin, 'calls.log') - const addedMarker = path.join(claudeBin, 'added') await mkdir(claudeBin, { recursive: true }) await writeFile( path.join(claudeBin, 'claude'), [ '#!/bin/sh', - `echo "$PWD $*" >> "${callsFile}"`, - `if [ "$2" = "get" ]; then [ -f "${addedMarker}" ] && exit 0; echo "No MCP server named $3" >&2; exit 1; fi`, - `if [ "$2" = "add" ]; then touch "${addedMarker}"; exit 0; fi`, + `echo "$*" >> "${callsFile}"`, + `if [ "$2" = "get" ]; then echo "No MCP server named $3" >&2; exit 1; fi`, 'exit 2', '', ].join('\n'), @@ -882,21 +880,19 @@ describe.concurrent('commands/init', () => { await withSkillsHost(async (skillsHost) => { const runInit = initOnLinkedSite({ apiUrl, cwd: builder.directory, skillsHost: skillsHost.url, claudeBin }) - const first = await runInit() - t.expect(first.stdout).toContain('Added the Netlify MCP server to Claude Code') + const suggested = await runInit() + t.expect(suggested.stdout).toContain( + 'claude mcp add --transport http --scope user netlify https://mcp.netlify.com/mcp', + ) - const second = await runInit() - t.expect(second.stdout).toContain('Claude Code already has a netlify MCP server') + const skippedMcp = await runInit('--skip-mcp-setup') + t.expect(skippedMcp.stdout).not.toContain('MCP server') - const skipped = await runInit('--skip-mcp-setup') - t.expect(skipped.stdout).not.toContain('MCP server') + const skippedAll = await runInit('--skip-agent-setup') + t.expect(skippedAll.stdout).not.toContain('MCP server') const calls = (await readFile(callsFile, 'utf8')).trim().split('\n') - t.expect(calls.map((call) => call.split(' ').slice(1).join(' '))).toEqual([ - 'mcp get netlify', - 'mcp add --transport http --scope user netlify https://mcp.netlify.com/mcp', - 'mcp get netlify', - ]) + t.expect(calls).toEqual(['mcp get netlify']) }) }) }) diff --git a/tests/unit/utils/init/agent-mcp.test.ts b/tests/unit/utils/init/agent-mcp.test.ts index 7ae341028d9..e063cbb50c0 100644 --- a/tests/unit/utils/init/agent-mcp.test.ts +++ b/tests/unit/utils/init/agent-mcp.test.ts @@ -1,6 +1,6 @@ import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { delimiter, join } from 'node:path' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' @@ -27,7 +27,8 @@ const execaMock = vi.mocked(execa) as unknown as ReturnType const exited = (exitCode: number, stderr = '', stdout = '') => ({ exitCode, stdout, stderr, timedOut: false }) const missingServer = () => exited(1, 'No MCP server named "netlify"') -const notInstalled = () => ({ code: 'ENOENT', exitCode: undefined, stdout: '', stderr: '', timedOut: false }) +const ourServer = () => exited(0, '', `netlify:\n Type: http\n URL: ${NETLIFY_MCP_URL}`) +const legacyServer = () => exited(0, '', 'netlify:\n Type: http\n URL: https://netlify-mcp.netlify.app/mcp') const timedOut = () => ({ exitCode: undefined, stdout: '', stderr: '', timedOut: true }) const CLAUDE_ADD = ['mcp', 'add', '--transport', 'http', '--scope', 'user', 'netlify', NETLIFY_MCP_URL] @@ -46,6 +47,8 @@ const warned = () => const neverChoose = vi.fn(() => Promise.reject(new Error('prompt should not open'))) +const executableName = (binary: string) => (process.platform === 'win32' ? `${binary}.cmd` : binary) + describe('setupAgentMcp', () => { let root: string let project: string @@ -68,22 +71,24 @@ describe('setupAgentMcp', () => { const install = async (...binaries: string[]) => { for (const binary of binaries) { - const file = join(bin, process.platform === 'win32' ? `${binary}.cmd` : binary) + const file = join(bin, executableName(binary)) await writeFile(file, '') await chmod(file, 0o755) } } + const installed = (binary: string) => join(bin, executableName(binary)) + const store = (): Promise => Promise.resolve({ get: (key) => saved.get(key), set: (key, value) => saved.set(key, value) }) const run = ( env: NodeJS.ProcessEnv, - options: { interactive?: boolean; choose?: ChooseAgents; reset?: boolean } = {}, + options: { interactive?: boolean; choose?: ChooseAgents; reset?: boolean; path?: string } = {}, ) => setupAgentMcp({ workingDir: project, - env: { PATH: bin, ...env }, + env: { PATH: options.path ?? bin, ...env }, interactive: options.interactive ?? false, choose: options.choose ?? neverChoose, reset: options.reset ?? false, @@ -99,46 +104,59 @@ describe('setupAgentMcp', () => { choose.mock.calls[call][0].map(({ agent }) => agent.id) describe('when an agent runs init', () => { - test('adds the server to that agent at user scope, without a prompt', async () => { - execaMock.mockResolvedValueOnce(missingServer()).mockResolvedValueOnce(exited(0)) + test('prints the add command for that agent and never writes its config', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(missingServer()) await expect(run({ NETLIFY_AGENT: 'claude-code' }, { interactive: true })).resolves.toEqual({ selection: 'driving', - agents: { 'claude-code': 'added' }, + agents: { 'claude-code': 'printed' }, }) expect(neverChoose).not.toHaveBeenCalled() - expect(execaMock).toHaveBeenNthCalledWith( - 1, - 'claude', + expect(execaMock).toHaveBeenCalledTimes(1) + expect(execaMock).toHaveBeenCalledWith( + installed('claude'), ['mcp', 'get', 'netlify'], expect.objectContaining({ cwd: project, reject: false }), ) - expect(execaMock).toHaveBeenNthCalledWith(2, 'claude', CLAUDE_ADD, expect.objectContaining({ cwd: project })) - expect(logged()).toContain('claude mcp remove --scope user netlify') + expect(logged()).toContain(`claude ${CLAUDE_ADD.join(' ')}`) }) - test('uses the Codex CLI when Codex runs init', async () => { - execaMock - .mockResolvedValueOnce(exited(1, "Error: No MCP server named 'netlify' found.")) - .mockResolvedValueOnce(exited(0)) + test('prints the Codex command when Codex runs init', async () => { + await install('codex') + execaMock.mockResolvedValueOnce(exited(1, "Error: No MCP server named 'netlify' found.")) - await expect(run({ CODEX_CI: '1' })).resolves.toEqual({ selection: 'driving', agents: { codex: 'added' } }) + await expect(run({ CODEX_CI: '1' })).resolves.toEqual({ selection: 'driving', agents: { codex: 'printed' } }) - expect(execaMock).toHaveBeenNthCalledWith(2, 'codex', CODEX_ADD, expect.anything()) + expect(logged()).toContain(`codex ${CODEX_ADD.join(' ')}`) }) - test('leaves an existing server alone and points an old-host entry at the new one', async () => { - execaMock.mockResolvedValueOnce(exited(0, '', 'URL: https://netlify-mcp.netlify.app/mcp')) + test('says nothing needs doing when the server is already there', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(ourServer()) await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toEqual({ selection: 'driving', agents: { 'claude-code': 'present' }, }) + expect(logged()).toContain('already has the Netlify MCP server') + expect(logged()).not.toContain('mcp add') + }) + + test('reports an existing server on another URL and how to switch, without changing it', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(legacyServer()) + + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toEqual({ + selection: 'driving', + agents: { 'claude-code': 'kept' }, + }) + + expect(logged()).toContain('the old host, netlify-mcp.netlify.app') + expect(logged()).toContain('claude mcp remove --scope user netlify') expect(execaMock).toHaveBeenCalledTimes(1) - expect(logged()).toContain('already has a netlify MCP server') - expect(logged()).toContain(`now lives at ${NETLIFY_MCP_URL}`) }) test('prints an add-mcp command for an agent without its own add command', async () => { @@ -151,50 +169,49 @@ describe('setupAgentMcp', () => { expect(logged()).toContain(`npx -y add-mcp@2.4.1 ${NETLIFY_MCP_URL} --name netlify -g -a gemini-cli`) }) - test('falls back to the add-mcp command when the agent CLI is not on PATH', async () => { - execaMock.mockResolvedValueOnce(notInstalled()) - + test('prints the command without a lookup when the agent CLI is not on PATH', async () => { await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toEqual({ selection: 'driving', agents: { 'claude-code': 'printed' }, }) - expect(logged()).toContain('-a claude-code') - expect(warn).not.toHaveBeenCalled() + expect(execaMock).not.toHaveBeenCalled() + expect(logged()).toContain(`claude ${CLAUDE_ADD.join(' ')}`) }) - test('does not add when the lookup fails for a reason other than a missing server', async () => { - execaMock.mockResolvedValueOnce(exited(1, 'Failed to connect')) + test('never runs an agent binary from the project folder or a relative PATH entry', async () => { + await writeFile(join(project, executableName('claude')), '') + await chmod(join(project, executableName('claude')), 0o755) - await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toEqual({ - selection: 'driving', - agents: { 'claude-code': 'failed' }, - }) + await run({ NETLIFY_AGENT: 'claude-code' }, { path: ['.', project.replace(root, '..')].join(delimiter) }) - expect(execaMock).toHaveBeenCalledTimes(1) - expect(warned()).toContain('Failed to connect') - expect(logged()).toContain(`claude ${CLAUDE_ADD.join(' ')}`) + expect(execaMock).not.toHaveBeenCalled() }) - test('does not add when the lookup times out', async () => { - execaMock.mockResolvedValueOnce(timedOut()) + test('respects a saved no from the prompt', async () => { + await install('claude') + saved.set('agentMcpSetup.claude-code', { url: NETLIFY_MCP_URL, choice: 'declined' }) - await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ - agents: { 'claude-code': 'failed' }, + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toEqual({ + selection: 'driving', + agents: { 'claude-code': 'declined' }, }) - expect(execaMock).toHaveBeenCalledTimes(1) - expect(warned()).toContain('timed out') + expect(execaMock).not.toHaveBeenCalled() + expect(logged()).toContain('init --reset-mcp-setup') }) - test('reports a failed add with the command to run by hand', async () => { - execaMock.mockResolvedValueOnce(missingServer()).mockResolvedValueOnce(exited(1, 'boom')) + test('--reset-mcp-setup forgets a saved no for the agent running init', async () => { + await install('claude') + saved.set('agentMcpSetup.claude-code', { url: NETLIFY_MCP_URL, choice: 'declined' }) + execaMock.mockResolvedValueOnce(missingServer()) - await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ - agents: { 'claude-code': 'failed' }, + await expect(run({ NETLIFY_AGENT: 'claude-code' }, { reset: true })).resolves.toEqual({ + selection: 'driving', + agents: { 'claude-code': 'printed' }, }) - expect(warned()).toContain('boom') + expect(saved.get('agentMcpSetup.claude-code')).toBeUndefined() }) test('prints the generic command for an agent it has no mapping for', async () => { @@ -225,17 +242,75 @@ describe('setupAgentMcp', () => { ['codex', false], ]) expect(execaMock).toHaveBeenCalledWith( - 'code', + installed('code'), ['--add-mcp', JSON.stringify({ name: 'netlify', type: 'http', url: NETLIFY_MCP_URL })], - expect.anything(), + expect.objectContaining({ stdin: 'ignore' }), ) expect(logged()).toContain('-a cursor') }) + test('adds a ticked agent after checking it has no server yet', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(missingServer()).mockResolvedValueOnce(exited(0)) + + await expect(run({}, { interactive: true, choose: chooseIds('claude-code') })).resolves.toMatchObject({ + agents: { 'claude-code': 'added' }, + }) + + expect(execaMock).toHaveBeenNthCalledWith(2, installed('claude'), CLAUDE_ADD, expect.anything()) + expect(logged()).toContain('claude mcp remove --scope user netlify') + }) + + test('leaves a ticked agent alone when its server points at another URL', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(legacyServer()) + + await expect(run({}, { interactive: true, choose: chooseIds('claude-code') })).resolves.toMatchObject({ + agents: { 'claude-code': 'kept' }, + }) + + expect(execaMock).toHaveBeenCalledTimes(1) + }) + + test('does not add when the lookup fails for a reason other than a missing server', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(exited(1, 'Failed to connect')) + + await expect(run({}, { interactive: true, choose: chooseIds('claude-code') })).resolves.toMatchObject({ + agents: { 'claude-code': 'failed' }, + }) + + expect(execaMock).toHaveBeenCalledTimes(1) + expect(warned()).toContain('Failed to connect') + }) + + test('does not add when the lookup times out', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(timedOut()) + + await expect(run({}, { interactive: true, choose: chooseIds('claude-code') })).resolves.toMatchObject({ + agents: { 'claude-code': 'failed' }, + }) + + expect(warned()).toContain('timed out') + }) + + test('reports a failed add with the command to run by hand', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(missingServer()).mockResolvedValueOnce(exited(1, 'boom')) + + await expect(run({}, { interactive: true, choose: chooseIds('claude-code') })).resolves.toMatchObject({ + agents: { 'claude-code': 'failed' }, + }) + + expect(warned()).toContain('boom') + expect(logged()).toContain(`claude ${CLAUDE_ADD.join(' ')}`) + }) + test('adds nothing when the person unticks every agent', async () => { await install('claude') - await expect(run({}, { interactive: true, choose: vi.fn(() => Promise.resolve([])) })).resolves.toEqual({ + await expect(run({}, { interactive: true, choose: chooseIds() })).resolves.toEqual({ selection: 'prompt', agents: {}, }) @@ -321,8 +396,9 @@ describe('setupAgentMcp', () => { expect(saved.get('agentMcpSetup.codex')).toEqual({ url: NETLIFY_MCP_URL, choice: 'declined' }) }) - test('leaves saved choices alone when an agent runs init', async () => { - execaMock.mockResolvedValueOnce(exited(0)) + test('an agent-driven run does not save anything', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(missingServer()) await run({ NETLIFY_AGENT: 'claude-code' }) From f6a8241a057aefa11532ea90d3ba0283589ab699 Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Tue, 20 Oct 2026 10:58:07 -0400 Subject: [PATCH 07/19] fix(init): re-offer agents that kept another MCP server 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. --- src/utils/init/agent-mcp.ts | 24 +++++++++++------------- tests/unit/utils/init/agent-mcp.test.ts | 11 +++++++++++ 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/utils/init/agent-mcp.ts b/src/utils/init/agent-mcp.ts index 863482b6753..253cf581e43 100644 --- a/src/utils/init/agent-mcp.ts +++ b/src/utils/init/agent-mcp.ts @@ -141,7 +141,9 @@ const isExecutableFile = async (target: string): Promise => { } const findOnPath = async (binary: string, env: NodeJS.ProcessEnv): Promise => { - const extensions = process.platform === 'win32' ? (env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') : [''] + const pathExtensions = (env.PATHEXT ?? '').split(';').filter(Boolean) + const extensions = + process.platform === 'win32' ? (pathExtensions.length > 0 ? pathExtensions : ['.EXE', '.CMD', '.BAT']) : [''] const candidates = (env.PATH ?? '') .split(path.delimiter) .filter((directory) => path.isAbsolute(directory)) @@ -190,13 +192,9 @@ const shellQuote = (arg: string): string => { const formatCommand = (binary: string, args: string[]): string => chalk.cyanBright.bold([binary, ...args].map(shellQuote).join(' ')) -const isErrnoException = (value: unknown): value is NodeJS.ErrnoException => - value instanceof Error && Object.hasOwn(value, 'code') - const runNative = async (executable: string, args: string[], cwd: string) => { const result = await execa(executable, args, { cwd, reject: false, stdin: 'ignore', timeout: NATIVE_TIMEOUT_MS }) return { - missingBinary: isErrnoException(result) && result.code === 'ENOENT', exitCode: result.exitCode, output: [result.stderr, result.stdout].filter(Boolean).join('\n').trim(), timedOut: result.timedOut, @@ -236,10 +234,10 @@ const reportExisting = (agent: NativeAgent, { found, output }: Existing): AgentM log(`${agent.label} already has the Netlify MCP server.`) return 'present' } - const where = output.includes(LEGACY_MCP_HOST) ? `the old host, ${LEGACY_MCP_HOST}` : 'a different URL' - log( - `${agent.label} has a ${chalk.cyan(NETLIFY_MCP_SERVER_NAME)} MCP server pointing at ${where}, so it was left unchanged.`, - ) + const which = output.includes(LEGACY_MCP_HOST) + ? `pointing at the old host, ${LEGACY_MCP_HOST}` + : `that is not Netlify's hosted server` + log(`${agent.label} has a ${chalk.cyan(NETLIFY_MCP_SERVER_NAME)} MCP server ${which}, so it was left unchanged.`) if (agent.native.remove) { log( ` To switch to ${chalk.underline(NETLIFY_MCP_URL)}, run ${formatCommand(agent.binary, agent.native.remove)} then ${formatCommand(agent.binary, agent.native.add)}`, @@ -285,15 +283,15 @@ const addNatively = async (agent: NativeAgent, env: NodeJS.ProcessEnv, workingDi } const added = await runNative(executable, native.add, workingDir) - if (added.missingBinary) { - return 'printed' - } if (added.exitCode !== 0) { warn(`Could not add the Netlify MCP server to ${label}: ${describeFailure(added)}`) log(` Add it yourself with ${formatCommand(binary, native.add)}`) return 'failed' } log(`Added the Netlify MCP server to ${label} for your user. Nothing was written to the repository.`) + if (!native.lookup) { + log(` ${label} can't be checked first, so this replaced any ${chalk.cyan(NETLIFY_MCP_SERVER_NAME)} server it had.`) + } if (native.remove) { log(` Remove it with ${formatCommand(binary, native.remove)}`) } @@ -328,7 +326,7 @@ const saveChoices = ( for (const agent of offered) { if (!chosen.includes(agent)) { store.set(savedChoiceKey(agent), { url: NETLIFY_MCP_URL, choice: 'declined' } satisfies SavedChoice) - } else if (statuses[agent.id] !== 'failed') { + } else if (statuses[agent.id] !== 'failed' && statuses[agent.id] !== 'kept') { store.set(savedChoiceKey(agent), { url: NETLIFY_MCP_URL, choice: 'accepted' } satisfies SavedChoice) } } diff --git a/tests/unit/utils/init/agent-mcp.test.ts b/tests/unit/utils/init/agent-mcp.test.ts index e063cbb50c0..2deade015de 100644 --- a/tests/unit/utils/init/agent-mcp.test.ts +++ b/tests/unit/utils/init/agent-mcp.test.ts @@ -396,6 +396,17 @@ describe('setupAgentMcp', () => { expect(saved.get('agentMcpSetup.codex')).toEqual({ url: NETLIFY_MCP_URL, choice: 'declined' }) }) + test('does not save a yes when the agent kept a different netlify server, so it is offered again', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(legacyServer()) + await run({}, { interactive: true, choose: chooseIds('claude-code') }) + const choose = chooseIds() + + await run({}, { interactive: true, choose }) + + expect(offeredIds(choose)).toEqual(['claude-code']) + }) + test('an agent-driven run does not save anything', async () => { await install('claude') execaMock.mockResolvedValueOnce(missingServer()) From 8fc3f35401e660edc7923a18683dfc7b9952c764 Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Wed, 21 Oct 2026 01:22:02 -0400 Subject: [PATCH 08/19] feat(init): set up the Netlify MCP server per project 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. --- docs/commands/init.md | 1 + src/commands/init/index.ts | 11 +- src/commands/init/init.ts | 50 ++- src/utils/init/agent-location.ts | 2 +- src/utils/init/agent-mcp.ts | 413 ++++++++++++------- tests/integration/commands/init/init.test.ts | 9 +- tests/unit/utils/init/agent-location.test.ts | 14 +- tests/unit/utils/init/agent-mcp.test.ts | 259 +++++++----- 8 files changed, 485 insertions(+), 274 deletions(-) diff --git a/docs/commands/init.md b/docs/commands/init.md index 91bac037f43..d98c89d7252 100644 --- a/docs/commands/init.md +++ b/docs/commands/init.md @@ -22,6 +22,7 @@ netlify init - `force` (*boolean*) - Reinitialize CI hooks if the linked project is already configured to use CI - `git-remote-name` (*string*) - Name of Git remote to use. e.g. "origin" - `manual` (*boolean*) - Manually configure a git remote for CI +- `mcp-scope` (*project | user*) - Add the Netlify MCP server for this project only, or for your user across all projects - `reset-context` (*boolean*) - Replace locally edited Netlify skills with the latest release, and migrate or delete edited copies under renamed or deprecated names - `reset-mcp-setup` (*boolean*) - Forget your saved Netlify MCP server choices and ask which AI coding agents to set up - `skip-mcp-setup` (*boolean*) - Skip adding the Netlify MCP server to your AI coding agents diff --git a/src/commands/init/index.ts b/src/commands/init/index.ts index 94ca43726cf..072dc9e3f5c 100644 --- a/src/commands/init/index.ts +++ b/src/commands/init/index.ts @@ -1,4 +1,4 @@ -import { OptionValues } from 'commander' +import { Option, OptionValues } from 'commander' import terminalLink from 'terminal-link' import BaseCommand from '../base-command.js' @@ -16,6 +16,14 @@ export const createInitCommand = (program: BaseCommand) => 'Skip installing Netlify skills and adding the Netlify MCP server for AI coding agents', ) .option('--skip-mcp-setup', 'Skip adding the Netlify MCP server to your AI coding agents') + .addOption( + new Option( + '--mcp-scope ', + 'Add the Netlify MCP server for this project only, or for your user across all projects', + ) + .choices(['project', 'user']) + .default('project'), + ) .option( '--reset-mcp-setup', 'Forget your saved Netlify MCP server choices and ask which AI coding agents to set up', @@ -36,6 +44,7 @@ For more information about getting started with Netlify CLI, see ${terminalLink( setupAgentSkills: !options.skipAgentSetup, setupAgentMcp: !options.skipAgentSetup && !options.skipMcpSetup, resetMcpSetup: Boolean(options.resetMcpSetup), + mcpScope: options.mcpScope === 'user' ? 'user' : 'project', resetContext: Boolean(options.resetContext), }) }) diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index c7d450271ef..14985794dd3 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -11,8 +11,8 @@ import type BaseCommand from '../base-command.js' import { link } from '../link/link.js' import { sitesCreate } from '../sites/sites-create.js' import type { LocalState, SiteInfo } from '../../utils/types.js' -import { findSkillsSetupBlocker } from '../../utils/init/agent-location.js' -import { setupAgentMcp } from '../../utils/init/agent-mcp.js' +import { findProjectSetupBlocker } from '../../utils/init/agent-location.js' +import { type McpScope, setupAgentMcp } from '../../utils/init/agent-mcp.js' import { setupAgentSkills } from '../../utils/init/agent-skills.js' import { getBuildSettings, saveNetlifyToml } from '../../utils/init/utils.js' import { type InitExitCode, LINKED_EXISTING_SITE_EXIT_CODE, LINKED_NEW_SITE_EXIT_CODE } from './constants.js' @@ -217,6 +217,7 @@ type InitExtraOptions = { setupAgentMcp?: boolean | undefined resetContext?: boolean | undefined resetMcpSetup?: boolean | undefined + mcpScope?: McpScope | undefined } const installAgentSkills = async (workingDir: string, reset: boolean): Promise => { @@ -230,32 +231,48 @@ const installAgentSkills = async (workingDir: string, reset: boolean): Promise => { - const { selection, agents, failed = false } = await setupAgentMcp({ workingDir, reset }) - await track('sites_agentMcpSetup', { selection, agents, failed, resetMcpSetup: reset }) +const addAgentMcp = async (command: BaseCommand, reset: boolean, scope: McpScope): Promise => { + const { repositoryRoot, state } = command.netlify + const { + selection, + agents, + failed = false, + } = await setupAgentMcp({ + workingDir: repositoryRoot, + store: state, + scope, + reset, + }) + await track('sites_agentMcpSetup', { selection, agents, failed, scope, resetMcpSetup: reset }) } const setUpAgents = async ( command: BaseCommand, - { skills, mcp, reset, resetMcp }: { skills: boolean; mcp: boolean; reset: boolean; resetMcp: boolean }, + { + skills, + mcp, + reset, + resetMcp, + mcpScope, + }: { skills: boolean; mcp: boolean; reset: boolean; resetMcp: boolean; mcpScope: McpScope }, ): Promise => { if (!skills && !mcp) { return } const { repositoryRoot } = command.netlify log() + const blocker = await findProjectSetupBlocker(repositoryRoot) + if (blocker) { + log(`Skipped setting up Netlify skills and the Netlify MCP server: ${blocker}.`) + log(` Run ${chalk.cyanBright.bold(`${netlifyCommand()} init`)} from your project folder to set them up there.`) + await track('sites_agentSetupSkipped', { skills, mcp }) + return + } if (skills) { - const blocker = await findSkillsSetupBlocker(repositoryRoot) - if (blocker) { - log(`Skipped installing Netlify skills: ${blocker}.`) - log(` Run ${chalk.cyanBright.bold(`${netlifyCommand()} init`)} from your project folder to install them there.`) - await track('sites_agentSkillsSkipped', {}) - } else { - await installAgentSkills(repositoryRoot, reset) - } + await installAgentSkills(repositoryRoot, reset) } if (mcp) { - await addAgentMcp(repositoryRoot, resetMcp) + await addAgentMcp(command, resetMcp, mcpScope) } } @@ -269,6 +286,7 @@ export const init = async ( setupAgentMcp: shouldSetupAgentMcp = false, resetContext = false, resetMcpSetup = false, + mcpScope = 'project', }: InitExtraOptions = {}, ): Promise => { command.setAnalyticsPayload({ @@ -278,6 +296,7 @@ export const init = async ( skipMcpSetup: options.skipMcpSetup, resetContext: options.resetContext, resetMcpSetup: options.resetMcpSetup, + mcpScope, }) const { repositoryRoot, state } = command.netlify @@ -294,6 +313,7 @@ export const init = async ( mcp: shouldSetupAgentMcp, reset: resetContext, resetMcp: resetMcpSetup, + mcpScope, }) const repoUrl = getRepoUrl(existingSiteInfo) diff --git a/src/utils/init/agent-location.ts b/src/utils/init/agent-location.ts index 6832fa3c338..29b8ba513ae 100644 --- a/src/utils/init/agent-location.ts +++ b/src/utils/init/agent-location.ts @@ -10,7 +10,7 @@ const realpathOrSelf = async (target: string): Promise => { } } -export const findSkillsSetupBlocker = async (root: string, home: string = homedir()): Promise => { +export const findProjectSetupBlocker = async (root: string, home: string = homedir()): Promise => { const resolvedRoot = await realpathOrSelf(root) if (resolvedRoot === (await realpathOrSelf(home))) { return 'this is your home directory, where they would apply to every project' diff --git a/src/utils/init/agent-mcp.ts b/src/utils/init/agent-mcp.ts index 253cf581e43..2b0660f0f95 100644 --- a/src/utils/init/agent-mcp.ts +++ b/src/utils/init/agent-mcp.ts @@ -2,7 +2,6 @@ import { constants as fsConstants, promises as fs } from 'node:fs' import path from 'node:path' import { checkbox } from '@inquirer/prompts' -import { getGlobalConfigStore } from '@netlify/dev-utils' import { type CanonicalAgentName, getDrivingAgent } from '../agent-detection.js' import { chalk, log, netlifyCommand, warn } from '../command-helpers.js' @@ -11,28 +10,49 @@ import { isInteractive } from '../scripted-commands.js' export const NETLIFY_MCP_URL = 'https://mcp.netlify.com/mcp' export const NETLIFY_MCP_SERVER_NAME = 'netlify' -const LEGACY_MCP_HOST = 'netlify-mcp.netlify.app' +const NETLIFY_MCP_HOSTS = new Set(['mcp.netlify.com', 'netlify-mcp.netlify.app']) +const NETLIFY_MCP_PACKAGE = '@netlify/mcp' const NATIVE_TIMEOUT_MS = 15_000 +const LIST_TIMEOUT_MS = 30_000 const ADD_MCP_VERSION = '2.4.1' -interface NativeSetup { - lookup?: string[] +export const MCP_SCOPES = ['project', 'user'] as const +export type McpScope = (typeof MCP_SCOPES)[number] + +interface NativeCommands { add: string[] remove?: string[] } +interface NativeSetup { + lookup?: 'list' | 'get' + scopes: Partial> +} + export interface McpAgent { id: string label: string binary?: string drivenBy?: CanonicalAgentName[] projectMarkers?: string[] + projectFile?: string inTerminal?: (env: NodeJS.ProcessEnv) => boolean native?: NativeSetup } const inEditorTerminal = (env: NodeJS.ProcessEnv): boolean => env.TERM_PROGRAM === 'vscode' +const claudeAdd = (scope: string) => [ + 'mcp', + 'add', + '--transport', + 'http', + '--scope', + scope, + NETLIFY_MCP_SERVER_NAME, + NETLIFY_MCP_URL, +] + export const MCP_AGENTS: McpAgent[] = [ { id: 'claude-code', @@ -40,10 +60,13 @@ export const MCP_AGENTS: McpAgent[] = [ binary: 'claude', drivenBy: ['claude'], projectMarkers: ['.claude', '.mcp.json', 'CLAUDE.md'], + projectFile: '.mcp.json', native: { - lookup: ['mcp', 'get', NETLIFY_MCP_SERVER_NAME], - add: ['mcp', 'add', '--transport', 'http', '--scope', 'user', NETLIFY_MCP_SERVER_NAME, NETLIFY_MCP_URL], - remove: ['mcp', 'remove', '--scope', 'user', NETLIFY_MCP_SERVER_NAME], + lookup: 'list', + scopes: { + project: { add: claudeAdd('local'), remove: ['mcp', 'remove', '--scope', 'local', NETLIFY_MCP_SERVER_NAME] }, + user: { add: claudeAdd('user'), remove: ['mcp', 'remove', '--scope', 'user', NETLIFY_MCP_SERVER_NAME] }, + }, }, }, { @@ -52,10 +75,15 @@ export const MCP_AGENTS: McpAgent[] = [ binary: 'codex', drivenBy: ['codex'], projectMarkers: ['.codex'], + projectFile: path.join('.codex', 'config.toml'), native: { - lookup: ['mcp', 'get', NETLIFY_MCP_SERVER_NAME], - add: ['mcp', 'add', NETLIFY_MCP_SERVER_NAME, '--url', NETLIFY_MCP_URL], - remove: ['mcp', 'remove', NETLIFY_MCP_SERVER_NAME], + lookup: 'get', + scopes: { + user: { + add: ['mcp', 'add', NETLIFY_MCP_SERVER_NAME, '--url', NETLIFY_MCP_URL], + remove: ['mcp', 'remove', NETLIFY_MCP_SERVER_NAME], + }, + }, }, }, { @@ -64,16 +92,22 @@ export const MCP_AGENTS: McpAgent[] = [ binary: 'opencode', drivenBy: ['opencode'], projectMarkers: ['opencode.json', 'opencode.jsonc', '.opencode'], - native: { add: ['mcp', 'add', NETLIFY_MCP_SERVER_NAME, '--url', NETLIFY_MCP_URL] }, + projectFile: 'opencode.jsonc', + native: { scopes: { user: { add: ['mcp', 'add', NETLIFY_MCP_SERVER_NAME, '--url', NETLIFY_MCP_URL] } } }, }, { id: 'vscode', label: 'VS Code', binary: 'code', projectMarkers: [path.join('.vscode', 'mcp.json')], + projectFile: path.join('.vscode', 'mcp.json'), inTerminal: (env) => inEditorTerminal(env) && !env.CURSOR_TRACE_ID, native: { - add: ['--add-mcp', JSON.stringify({ name: NETLIFY_MCP_SERVER_NAME, type: 'http', url: NETLIFY_MCP_URL })], + scopes: { + user: { + add: ['--add-mcp', JSON.stringify({ name: NETLIFY_MCP_SERVER_NAME, type: 'http', url: NETLIFY_MCP_URL })], + }, + }, }, }, { @@ -82,6 +116,7 @@ export const MCP_AGENTS: McpAgent[] = [ binary: 'cursor-agent', drivenBy: ['cursor'], projectMarkers: ['.cursor'], + projectFile: path.join('.cursor', 'mcp.json'), inTerminal: (env) => inEditorTerminal(env) && Boolean(env.CURSOR_TRACE_ID), }, { id: 'windsurf', label: 'Windsurf', binary: 'windsurf', projectMarkers: ['.windsurf'] }, @@ -91,9 +126,23 @@ export const MCP_AGENTS: McpAgent[] = [ binary: 'gemini', drivenBy: ['gemini'], projectMarkers: ['.gemini', 'GEMINI.md'], + projectFile: path.join('.gemini', 'settings.json'), + }, + { + id: 'github-copilot-cli', + label: 'GitHub Copilot CLI', + binary: 'copilot', + drivenBy: ['copilot'], + projectFile: '.mcp.json', + }, + { + id: 'kiro-cli', + label: 'Kiro CLI', + binary: 'kiro-cli', + drivenBy: ['kiro'], + projectMarkers: ['.kiro'], + projectFile: path.join('.kiro', 'settings', 'mcp.json'), }, - { id: 'github-copilot-cli', label: 'GitHub Copilot CLI', binary: 'copilot', drivenBy: ['copilot'] }, - { id: 'kiro-cli', label: 'Kiro CLI', binary: 'kiro-cli', drivenBy: ['kiro'], projectMarkers: ['.kiro'] }, { id: 'cline', label: 'Cline', drivenBy: ['cline'], projectMarkers: ['.clinerules'] }, ] @@ -101,6 +150,7 @@ export type AgentMcpStatus = 'added' | 'present' | 'kept' | 'printed' | 'decline export type AgentSelection = 'driving' | 'prompt' | 'saved' | 'none' export interface AgentMcpSetupSummary { + scope: McpScope selection: AgentSelection agents: Record failed?: boolean @@ -113,11 +163,15 @@ export interface AgentCandidate { export type ChooseAgents = (candidates: AgentCandidate[]) => Promise -const chooseWithPrompt: ChooseAgents = async (candidates) => - await checkbox({ - message: 'Add the Netlify MCP server to which AI agents? It is added for your user, across all your projects.', - choices: candidates.map(({ agent, detected }) => ({ name: agent.label, value: agent, checked: detected })), - }) +const where = (scope: McpScope): string => (scope === 'project' ? 'this project' : 'all your projects') + +const chooseWithPrompt = + (scope: McpScope): ChooseAgents => + async (candidates) => + await checkbox({ + message: `Add the Netlify MCP server for ${where(scope)} to which AI agents?`, + choices: candidates.map(({ agent, detected }) => ({ name: agent.label, value: agent, checked: detected })), + }) const pathExists = async (target: string): Promise => { try { @@ -172,31 +226,63 @@ export const findCandidates = async (workingDir: string, env: NodeJS.ProcessEnv) return candidates.sort((a, b) => Number(b.detected) - Number(a.detected)) } -const addMcpCommand = (agents: McpAgent[]): string => - [ - `npx -y add-mcp@${ADD_MCP_VERSION}`, - NETLIFY_MCP_URL, - '--name', - NETLIFY_MCP_SERVER_NAME, - '-g', - ...agents.flatMap(({ id }) => ['-a', id]), - ].join(' ') +const parseUrl = (value: string): URL | undefined => { + try { + return new URL(value) + } catch { + return undefined + } +} + +const urlsIn = (text: string): URL[] => + (text.match(/https?:\/\/[^\s"'<>]+/g) ?? []).map(parseUrl).filter((url): url is URL => url !== undefined) + +const OUR_URL = new URL(NETLIFY_MCP_URL) + +const isOurUrl = (url: URL): boolean => + url.protocol === OUR_URL.protocol && + url.hostname.toLowerCase() === OUR_URL.hostname && + url.pathname.replace(/\/$/, '') === OUR_URL.pathname + +const pointsAtNetlify = (target: string): boolean => + urlsIn(target).some((url) => NETLIFY_MCP_HOSTS.has(url.hostname.toLowerCase())) || + target.split(/\s+/).some((word) => word === NETLIFY_MCP_PACKAGE || word.startsWith(`${NETLIFY_MCP_PACKAGE}@`)) + +const isNetlifyName = (name: string): boolean => + name === NETLIFY_MCP_SERVER_NAME || name.endsWith(`:${NETLIFY_MCP_SERVER_NAME}`) const shellQuote = (arg: string): string => { - if (/^[\w@%+=:,./\\-]+$/.test(arg)) { - return arg + if (process.platform === 'win32') { + if (/^[\w@%+=:,./\\-]+$/.test(arg)) { + return arg + } + return `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"` } - return process.platform === 'win32' ? `"${arg.replace(/"/g, '\\"')}"` : `'${arg.replace(/'/g, `'\\''`)}'` + return /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'` } const formatCommand = (binary: string, args: string[]): string => chalk.cyanBright.bold([binary, ...args].map(shellQuote).join(' ')) -const runNative = async (executable: string, args: string[], cwd: string) => { - const result = await execa(executable, args, { cwd, reject: false, stdin: 'ignore', timeout: NATIVE_TIMEOUT_MS }) +const addMcpArgs = (agents: McpAgent[], scope: McpScope): string[] => { + const global = scope === 'user' || agents.some((agent) => agent.projectFile === undefined) + return [ + '-y', + `add-mcp@${ADD_MCP_VERSION}`, + NETLIFY_MCP_URL, + '--name', + NETLIFY_MCP_SERVER_NAME, + ...(global ? ['-g'] : []), + ...(agents.length > 0 ? ['-y', ...agents.flatMap(({ id }) => ['-a', id])] : []), + ] +} + +const runNative = async (executable: string, args: string[], cwd: string, timeout = NATIVE_TIMEOUT_MS) => { + const result = await execa(executable, args, { cwd, reject: false, stdin: 'ignore', timeout }) return { exitCode: result.exitCode, output: [result.stderr, result.stdout].filter(Boolean).join('\n').trim(), + stdout: result.stdout, timedOut: result.timedOut, } } @@ -204,22 +290,38 @@ const runNative = async (executable: string, args: string[], cwd: string) => { type NativeRun = Awaited> const describeFailure = ({ timedOut, output, exitCode }: NativeRun): string => - timedOut - ? `timed out after ${(NATIVE_TIMEOUT_MS / 1000).toString()} seconds` - : output || `exit code ${String(exitCode)}` - -type NativeAgent = McpAgent & { binary: string; native: NativeSetup } - -const hasNativeSetup = (agent: McpAgent): agent is NativeAgent => - agent.binary !== undefined && agent.native !== undefined + timedOut ? 'timed out' : output || `exit code ${String(exitCode)}` -type Existing = { found: 'ours' | 'other'; output: string } +type Existing = { found: 'ours' | 'other'; name: string } type Lookup = Existing | { found: 'none' } | { found: 'unknown'; run: NativeRun } -const lookUp = async (executable: string, lookup: string[], workingDir: string): Promise => { - const run = await runNative(executable, lookup, workingDir) +const listedServers = (stdout: string): { name: string; target: string }[] => + stdout.split('\n').flatMap((line) => { + const separator = line.indexOf(': ') + const status = line.lastIndexOf(' - ') + if (separator <= 0 || status <= separator) { + return [] + } + return [{ name: line.slice(0, separator).trim(), target: line.slice(separator + 2, status).trim() }] + }) + +const lookUp = async (executable: string, kind: 'list' | 'get', workingDir: string): Promise => { + if (kind === 'list') { + const run = await runNative(executable, ['mcp', 'list'], workingDir, LIST_TIMEOUT_MS) + if (run.exitCode !== 0) { + return { found: 'unknown', run } + } + const servers = listedServers(run.stdout) + const ours = servers.find(({ target }) => urlsIn(target).some(isOurUrl)) + if (ours) { + return { found: 'ours', name: ours.name } + } + const other = servers.find(({ name, target }) => isNetlifyName(name) || pointsAtNetlify(target)) + return other ? { found: 'other', name: other.name } : { found: 'none' } + } + const run = await runNative(executable, ['mcp', 'get', NETLIFY_MCP_SERVER_NAME], workingDir) if (run.exitCode === 0) { - return { found: run.output.includes(NETLIFY_MCP_URL) ? 'ours' : 'other', output: run.output } + return { found: urlsIn(run.output).some(isOurUrl) ? 'ours' : 'other', name: NETLIFY_MCP_SERVER_NAME } } if (run.exitCode === 1 && run.output.includes('No MCP server named')) { return { found: 'none' } @@ -229,72 +331,118 @@ const lookUp = async (executable: string, lookup: string[], workingDir: string): const isExisting = (lookup: Lookup): lookup is Existing => lookup.found === 'ours' || lookup.found === 'other' -const reportExisting = (agent: NativeAgent, { found, output }: Existing): AgentMcpStatus => { +const reportExisting = (agent: McpAgent, { found, name }: Existing): AgentMcpStatus => { if (found === 'ours') { log(`${agent.label} already has the Netlify MCP server.`) return 'present' } - const which = output.includes(LEGACY_MCP_HOST) - ? `pointing at the old host, ${LEGACY_MCP_HOST}` - : `that is not Netlify's hosted server` - log(`${agent.label} has a ${chalk.cyan(NETLIFY_MCP_SERVER_NAME)} MCP server ${which}, so it was left unchanged.`) - if (agent.native.remove) { - log( - ` To switch to ${chalk.underline(NETLIFY_MCP_URL)}, run ${formatCommand(agent.binary, agent.native.remove)} then ${formatCommand(agent.binary, agent.native.add)}`, - ) - } + 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.`) return 'kept' } -const suggestNatively = async ( - agent: NativeAgent, +const lookUpExisting = async ( + agent: McpAgent, env: NodeJS.ProcessEnv, workingDir: string, -): Promise => { +): Promise<{ executable?: string; lookup?: Lookup }> => { + if (agent.binary === undefined) { + return {} + } const executable = await findOnPath(agent.binary, env) - if (executable && agent.native.lookup) { - const existing = await lookUp(executable, agent.native.lookup, workingDir) - if (isExisting(existing)) { - return reportExisting(agent, existing) - } + const kind = agent.native?.lookup + if (!executable || !kind) { + return { executable } } - log( - `To add the Netlify MCP server to ${agent.label} for your user, run ${formatCommand(agent.binary, agent.native.add)}`, - ) + return { executable, lookup: await lookUp(executable, kind, workingDir) } +} + +const restartHint = (agent: McpAgent): string => + ` The Netlify MCP server loads the next time you start ${agent.label} in this folder.` + +const commandFor = (agent: McpAgent, scope: McpScope): string => { + const native = agent.native?.scopes[scope] + if (native && agent.binary) { + return formatCommand(agent.binary, native.add) + } + return formatCommand('npx', addMcpArgs([agent], scope)) +} + +const fileNote = (agent: McpAgent, scope: McpScope): string | undefined => { + if (agent.native?.scopes[scope]) { + return undefined + } + if (scope === 'project' && agent.projectFile === undefined) { + return ` ${agent.label} has no per-project setting, so this adds it for all your projects.` + } + if (scope === 'project') { + return ` This writes ${agent.projectFile ?? ''} in this folder. Commit it only if everyone on the project should get it.` + } + return undefined +} + +const printCommand = (agent: McpAgent, scope: McpScope, lead: string): void => { + log(`${lead} ${commandFor(agent, scope)}`) + const note = fileNote(agent, scope) + if (note) { + log(note) + } + log(restartHint(agent)) +} + +const suggest = async ( + agent: McpAgent, + scope: McpScope, + env: NodeJS.ProcessEnv, + workingDir: string, +): Promise => { + const { lookup } = await lookUpExisting(agent, env, workingDir) + if (lookup && isExisting(lookup)) { + return reportExisting(agent, lookup) + } + printCommand(agent, scope, `If the user wants the Netlify MCP server in ${agent.label} for ${where(scope)}, run`) return 'printed' } -const addNatively = async (agent: NativeAgent, env: NodeJS.ProcessEnv, workingDir: string): Promise => { - const { binary, native, label } = agent - const executable = await findOnPath(binary, env) - if (!executable) { - return 'printed' +const add = async ( + agent: McpAgent, + scope: McpScope, + env: NodeJS.ProcessEnv, + workingDir: string, +): Promise => { + const commands = agent.native?.scopes[scope] + const { executable, lookup } = await lookUpExisting(agent, env, workingDir) + if (lookup?.found === 'unknown') { + warn(`Could not check ${agent.label} for an existing Netlify MCP server: ${describeFailure(lookup.run)}`) + printCommand(agent, scope, ` Add it yourself with`) + return 'failed' } - if (native.lookup) { - const existing = await lookUp(executable, native.lookup, workingDir) - if (existing.found === 'unknown') { - warn(`Could not check ${label} for an existing Netlify MCP server: ${describeFailure(existing.run)}`) - log(` Add it yourself with ${formatCommand(binary, native.add)}`) - return 'failed' - } - if (isExisting(existing)) { - return reportExisting(agent, existing) - } + if (lookup && isExisting(lookup)) { + return reportExisting(agent, lookup) + } + if (!commands || !executable || !agent.binary) { + printCommand(agent, scope, `To add the Netlify MCP server to ${agent.label} for ${where(scope)}, run`) + return 'printed' } - const added = await runNative(executable, native.add, workingDir) + const added = await runNative(executable, commands.add, workingDir) if (added.exitCode !== 0) { - warn(`Could not add the Netlify MCP server to ${label}: ${describeFailure(added)}`) - log(` Add it yourself with ${formatCommand(binary, native.add)}`) + warn(`Could not add the Netlify MCP server to ${agent.label}: ${describeFailure(added)}`) + log(` Add it yourself with ${formatCommand(agent.binary, commands.add)}`) return 'failed' } - log(`Added the Netlify MCP server to ${label} for your user. Nothing was written to the repository.`) - if (!native.lookup) { - log(` ${label} can't be checked first, so this replaced any ${chalk.cyan(NETLIFY_MCP_SERVER_NAME)} server it had.`) + log(`Added the Netlify MCP server to ${agent.label} for ${where(scope)}. Nothing was written to the repository.`) + if (!agent.native?.lookup) { + log( + ` ${agent.label} can't be checked first, so this replaced any ${chalk.cyan(NETLIFY_MCP_SERVER_NAME)} server it had.`, + ) } - if (native.remove) { - log(` Remove it with ${formatCommand(binary, native.remove)}`) + if (commands.remove) { + log(` Remove it with ${formatCommand(agent.binary, commands.remove)}`) } + log(restartHint(agent)) return 'added' } @@ -305,6 +453,7 @@ export interface McpChoiceStore { interface SavedChoice { url: string + scope: McpScope choice: 'accepted' | 'declined' } @@ -312,35 +461,35 @@ const SAVED_CHOICES_KEY = 'agentMcpSetup' const savedChoiceKey = (agent: McpAgent): string => `${SAVED_CHOICES_KEY}.${agent.id}` -const savedChoice = (store: McpChoiceStore, agent: McpAgent): SavedChoice | undefined => { +const savedChoice = (store: McpChoiceStore, agent: McpAgent, scope: McpScope): SavedChoice | undefined => { const saved = store.get(savedChoiceKey(agent)) as SavedChoice | undefined - return saved?.url === NETLIFY_MCP_URL ? saved : undefined + return saved?.url === NETLIFY_MCP_URL && saved.scope === scope ? saved : undefined } const saveChoices = ( store: McpChoiceStore, + scope: McpScope, offered: McpAgent[], chosen: McpAgent[], statuses: Record, ): void => { for (const agent of offered) { if (!chosen.includes(agent)) { - store.set(savedChoiceKey(agent), { url: NETLIFY_MCP_URL, choice: 'declined' } satisfies SavedChoice) + store.set(savedChoiceKey(agent), { url: NETLIFY_MCP_URL, scope, choice: 'declined' } satisfies SavedChoice) } else if (statuses[agent.id] !== 'failed' && statuses[agent.id] !== 'kept') { - store.set(savedChoiceKey(agent), { url: NETLIFY_MCP_URL, choice: 'accepted' } satisfies SavedChoice) + store.set(savedChoiceKey(agent), { url: NETLIFY_MCP_URL, scope, choice: 'accepted' } satisfies SavedChoice) } } } -const loadGlobalStore = async (): Promise => await getGlobalConfigStore() - const resetHint = (): string => `Run ${chalk.cyanBright.bold(`${netlifyCommand()} init --reset-mcp-setup`)} to choose again.` type Selected = | { selection: 'driving'; agents: [McpAgent]; declined: boolean } - | { selection: 'prompt'; agents: McpAgent[]; offered: McpAgent[]; store: McpChoiceStore } - | { selection: 'saved' | 'none'; agents: [] } + | { selection: 'prompt'; agents: McpAgent[]; offered: McpAgent[] } + | { selection: 'saved'; agents: [] } + | { selection: 'none'; agents: [] } const selectAgents = async ({ workingDir, @@ -348,6 +497,7 @@ const selectAgents = async ({ interactive, choose, reset, + scope, store, }: { workingDir: string @@ -355,7 +505,8 @@ const selectAgents = async ({ interactive: boolean choose: ChooseAgents reset: boolean - store: () => Promise + scope: McpScope + store: McpChoiceStore }): Promise => { const drivingAgent = getDrivingAgent(env) if (drivingAgent) { @@ -363,11 +514,14 @@ const selectAgents = async ({ if (!agent) { return { selection: 'none', agents: [] } } - const choices = await store() if (reset) { - choices.set(savedChoiceKey(agent), undefined) + store.set(savedChoiceKey(agent), undefined) + } + return { + selection: 'driving', + agents: [agent], + declined: savedChoice(store, agent, scope)?.choice === 'declined', } - return { selection: 'driving', agents: [agent], declined: savedChoice(choices, agent)?.choice === 'declined' } } if (!interactive) { return { selection: 'none', agents: [] } @@ -376,45 +530,40 @@ const selectAgents = async ({ if (candidates.length === 0) { return { selection: 'none', agents: [] } } - const choices = await store() - const undecided = reset ? candidates : candidates.filter(({ agent }) => !savedChoice(choices, agent)) + const undecided = reset ? candidates : candidates.filter(({ agent }) => !savedChoice(store, agent, scope)) if (undecided.length === 0) { return { selection: 'saved', agents: [] } } - return { - selection: 'prompt', - agents: await choose(undecided), - offered: undecided.map(({ agent }) => agent), - store: choices, - } + return { selection: 'prompt', agents: await choose(undecided), offered: undecided.map(({ agent }) => agent) } } export const setupAgentMcp = async ({ workingDir, + store, + scope = 'project', env = process.env, interactive = isInteractive(), - choose = chooseWithPrompt, + choose = chooseWithPrompt(scope), reset = false, - store = loadGlobalStore, }: { workingDir: string + store: McpChoiceStore + scope?: McpScope env?: NodeJS.ProcessEnv interactive?: boolean choose?: ChooseAgents reset?: boolean - store?: () => Promise }): Promise => { - const summary: AgentMcpSetupSummary = { selection: 'none', agents: {} } + const summary: AgentMcpSetupSummary = { scope, selection: 'none', agents: {} } try { - const selected = await selectAgents({ workingDir, env, interactive, choose, reset, store }) - const { selection, agents } = selected - summary.selection = selection - if (selection === 'saved') { + const selected = await selectAgents({ workingDir, env, interactive, choose, reset, scope, store }) + summary.selection = selected.selection + if (selected.selection === 'saved') { log(`Using your saved choices for the Netlify MCP server. ${resetHint()}`) return summary } - if (selection === 'none') { - log(`To add the Netlify MCP server to your AI agents, run ${chalk.cyanBright.bold(addMcpCommand([]))}`) + if (selected.selection === 'none') { + log(`To add the Netlify MCP server to your AI agents, run ${formatCommand('npx', addMcpArgs([], scope))}`) return summary } @@ -423,33 +572,17 @@ export const setupAgentMcp = async ({ if (selected.declined) { log(`You chose not to add the Netlify MCP server to ${agent.label}. ${resetHint()}`) summary.agents[agent.id] = 'declined' - } else if (hasNativeSetup(agent)) { - summary.agents[agent.id] = await suggestNatively(agent, env, workingDir) } else { - log( - `To add the Netlify MCP server to ${agent.label} for your user, run ${chalk.cyanBright.bold(addMcpCommand([agent]))}`, - ) - summary.agents[agent.id] = 'printed' + summary.agents[agent.id] = await suggest(agent, scope, env, workingDir) } return summary } - const toPrint: McpAgent[] = [] - for (const agent of agents) { - const status = hasNativeSetup(agent) ? await addNatively(agent, env, workingDir) : 'printed' - summary.agents[agent.id] = status - if (status === 'printed') { - toPrint.push(agent) - } - } - if (toPrint.length > 0) { - const labels = toPrint.map(({ label }) => label).join(', ') - log(`To add the Netlify MCP server to ${labels}, run ${chalk.cyanBright.bold(addMcpCommand(toPrint))}`) - } - if (selected.selection === 'prompt') { - saveChoices(selected.store, selected.offered, agents, summary.agents) - log(`Your choices are saved for next time. ${resetHint()}`) + for (const agent of selected.agents) { + summary.agents[agent.id] = await add(agent, scope, env, workingDir) } + saveChoices(store, scope, selected.offered, selected.agents, summary.agents) + log(`Your choices are saved for this project. ${resetHint()}`) } catch (error) { if (error instanceof Error && error.name === 'ExitPromptError') { throw error diff --git a/tests/integration/commands/init/init.test.ts b/tests/integration/commands/init/init.test.ts index 4fe0413b261..c6b0d2ec029 100644 --- a/tests/integration/commands/init/init.test.ts +++ b/tests/integration/commands/init/init.test.ts @@ -869,7 +869,7 @@ describe.concurrent('commands/init', () => { [ '#!/bin/sh', `echo "$*" >> "${callsFile}"`, - `if [ "$2" = "get" ]; then echo "No MCP server named $3" >&2; exit 1; fi`, + `if [ "$2" = "list" ]; then echo "No MCP servers configured."; exit 0; fi`, 'exit 2', '', ].join('\n'), @@ -882,6 +882,11 @@ describe.concurrent('commands/init', () => { const suggested = await runInit() t.expect(suggested.stdout).toContain( + 'claude mcp add --transport http --scope local netlify https://mcp.netlify.com/mcp', + ) + + const userWide = await runInit('--mcp-scope', 'user') + t.expect(userWide.stdout).toContain( 'claude mcp add --transport http --scope user netlify https://mcp.netlify.com/mcp', ) @@ -892,7 +897,7 @@ describe.concurrent('commands/init', () => { t.expect(skippedAll.stdout).not.toContain('MCP server') const calls = (await readFile(callsFile, 'utf8')).trim().split('\n') - t.expect(calls).toEqual(['mcp get netlify']) + t.expect(calls).toEqual(['mcp list', 'mcp list']) }) }) }) diff --git a/tests/unit/utils/init/agent-location.test.ts b/tests/unit/utils/init/agent-location.test.ts index f1dc4187c1f..0193dc649a8 100644 --- a/tests/unit/utils/init/agent-location.test.ts +++ b/tests/unit/utils/init/agent-location.test.ts @@ -4,9 +4,9 @@ import { join, parse } from 'node:path' import { afterEach, beforeEach, describe, expect, test } from 'vitest' -import { findSkillsSetupBlocker } from '../../../../src/utils/init/agent-location.js' +import { findProjectSetupBlocker } from '../../../../src/utils/init/agent-location.js' -describe('findSkillsSetupBlocker', () => { +describe('findProjectSetupBlocker', () => { let root: string let home: string @@ -22,28 +22,28 @@ describe('findSkillsSetupBlocker', () => { test('blocks the home directory even when it is a git repository', async () => { await mkdir(join(home, '.git')) - await expect(findSkillsSetupBlocker(home, home)).resolves.toMatch(/home directory/) + await expect(findProjectSetupBlocker(home, home)).resolves.toMatch(/home directory/) }) test('blocks a path that resolves to the home directory through a symlink', async () => { const link = join(root, 'link-to-home') await symlink(home, link) - await expect(findSkillsSetupBlocker(link, home)).resolves.toMatch(/home directory/) + await expect(findProjectSetupBlocker(link, home)).resolves.toMatch(/home directory/) }) test('blocks the root of the file system', async () => { - await expect(findSkillsSetupBlocker(parse(root).root, home)).resolves.toMatch(/root of the file system/) + await expect(findProjectSetupBlocker(parse(root).root, home)).resolves.toMatch(/root of the file system/) }) test('allows an empty folder, where an agent starts a new site', async () => { const empty = join(root, 'new-site') await mkdir(empty) - await expect(findSkillsSetupBlocker(empty, home)).resolves.toBeUndefined() + await expect(findProjectSetupBlocker(empty, home)).resolves.toBeUndefined() }) test('allows a project nested inside the home directory', async () => { const project = join(home, 'code', 'site') await mkdir(project, { recursive: true }) - await expect(findSkillsSetupBlocker(project, home)).resolves.toBeUndefined() + await expect(findProjectSetupBlocker(project, home)).resolves.toBeUndefined() }) }) diff --git a/tests/unit/utils/init/agent-mcp.test.ts b/tests/unit/utils/init/agent-mcp.test.ts index 2deade015de..8647b2aaf7e 100644 --- a/tests/unit/utils/init/agent-mcp.test.ts +++ b/tests/unit/utils/init/agent-mcp.test.ts @@ -7,9 +7,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { log, warn } from '../../../../src/utils/command-helpers.js' import execa from '../../../../src/utils/execa.js' import { - type AgentCandidate, type ChooseAgents, type McpChoiceStore, + type McpScope, NETLIFY_MCP_URL, findCandidates, setupAgentMcp, @@ -26,13 +26,16 @@ vi.mock('../../../../src/utils/execa.js', () => ({ default: vi.fn() })) const execaMock = vi.mocked(execa) as unknown as ReturnType const exited = (exitCode: number, stderr = '', stdout = '') => ({ exitCode, stdout, stderr, timedOut: false }) -const missingServer = () => exited(1, 'No MCP server named "netlify"') -const ourServer = () => exited(0, '', `netlify:\n Type: http\n URL: ${NETLIFY_MCP_URL}`) -const legacyServer = () => exited(0, '', 'netlify:\n Type: http\n URL: https://netlify-mcp.netlify.app/mcp') +const listed = (...lines: string[]) => exited(0, '', ['Checking MCP server health…', '', ...lines].join('\n')) const timedOut = () => ({ exitCode: undefined, stdout: '', stderr: '', timedOut: true }) -const CLAUDE_ADD = ['mcp', 'add', '--transport', 'http', '--scope', 'user', 'netlify', NETLIFY_MCP_URL] -const CODEX_ADD = ['mcp', 'add', 'netlify', '--url', NETLIFY_MCP_URL] +const OURS_LINE = `netlify: ${NETLIFY_MCP_URL} (HTTP) - ✔ Connected` +const LOCAL_LINE = 'netlify: npx -y @netlify/mcp - ✔ Connected' +const LEGACY_PLUGIN_LINE = 'plugin:netlify:netlify: https://netlify-mcp.netlify.app/mcp (HTTP) - ✔ Connected' +const UNRELATED_LINE = 'netliray: https://netliray.netlify.app/mcp (HTTP) - ✘ Failed to connect' + +const claudeAdd = (scope: string) => ['mcp', 'add', '--transport', 'http', '--scope', scope, 'netlify', NETLIFY_MCP_URL] +const ADD_MCP = `npx -y add-mcp@2.4.1 ${NETLIFY_MCP_URL} --name netlify` const logged = () => vi @@ -54,6 +57,7 @@ describe('setupAgentMcp', () => { let project: string let bin: string let saved: Map + let store: McpChoiceStore beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'netlify-agent-mcp-')) @@ -62,6 +66,7 @@ describe('setupAgentMcp', () => { await mkdir(project) await mkdir(bin) saved = new Map() + store = { get: (key) => saved.get(key), set: (key, value) => saved.set(key, value) } }) afterEach(async () => { @@ -79,20 +84,18 @@ describe('setupAgentMcp', () => { const installed = (binary: string) => join(bin, executableName(binary)) - const store = (): Promise => - Promise.resolve({ get: (key) => saved.get(key), set: (key, value) => saved.set(key, value) }) - const run = ( env: NodeJS.ProcessEnv, - options: { interactive?: boolean; choose?: ChooseAgents; reset?: boolean; path?: string } = {}, + options: { interactive?: boolean; choose?: ChooseAgents; reset?: boolean; path?: string; scope?: McpScope } = {}, ) => setupAgentMcp({ workingDir: project, + store, + scope: options.scope ?? 'project', env: { PATH: options.path ?? bin, ...env }, interactive: options.interactive ?? false, choose: options.choose ?? neverChoose, reset: options.reset ?? false, - store, }) const chooseIds = (...ids: string[]) => @@ -104,11 +107,12 @@ describe('setupAgentMcp', () => { choose.mock.calls[call][0].map(({ agent }) => agent.id) describe('when an agent runs init', () => { - test('prints the add command for that agent and never writes its config', async () => { + test('prints the project-scoped Claude command and never writes config', async () => { await install('claude') - execaMock.mockResolvedValueOnce(missingServer()) + execaMock.mockResolvedValueOnce(listed(UNRELATED_LINE)) await expect(run({ NETLIFY_AGENT: 'claude-code' }, { interactive: true })).resolves.toEqual({ + scope: 'project', selection: 'driving', agents: { 'claude-code': 'printed' }, }) @@ -117,27 +121,47 @@ describe('setupAgentMcp', () => { expect(execaMock).toHaveBeenCalledTimes(1) expect(execaMock).toHaveBeenCalledWith( installed('claude'), - ['mcp', 'get', 'netlify'], - expect.objectContaining({ cwd: project, reject: false }), + ['mcp', 'list'], + expect.objectContaining({ cwd: project, reject: false, stdin: 'ignore' }), ) - expect(logged()).toContain(`claude ${CLAUDE_ADD.join(' ')}`) + expect(logged()).toContain(`claude ${claudeAdd('local').join(' ')}`) + expect(logged()).toContain('loads the next time you start Claude Code in this folder') }) - test('prints the Codex command when Codex runs init', async () => { + test('prints the user-scoped command with --mcp-scope user', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed()) + + await run({ NETLIFY_AGENT: 'claude-code' }, { scope: 'user' }) + + expect(logged()).toContain(`claude ${claudeAdd('user').join(' ')}`) + }) + + test('prints a project add-mcp command for Codex, naming the file it writes', async () => { await install('codex') execaMock.mockResolvedValueOnce(exited(1, "Error: No MCP server named 'netlify' found.")) - await expect(run({ CODEX_CI: '1' })).resolves.toEqual({ selection: 'driving', agents: { codex: 'printed' } }) + await expect(run({ CODEX_CI: '1' })).resolves.toMatchObject({ agents: { codex: 'printed' } }) - expect(logged()).toContain(`codex ${CODEX_ADD.join(' ')}`) + expect(logged()).toContain(`${ADD_MCP} -y -a codex`) + expect(logged()).not.toContain(' -g') + expect(logged()).toContain(join('.codex', 'config.toml')) }) - test('says nothing needs doing when the server is already there', async () => { + test('prints the Codex CLI command when the scope is user', async () => { + await install('codex') + execaMock.mockResolvedValueOnce(exited(1, "Error: No MCP server named 'netlify' found.")) + + await run({ CODEX_CI: '1' }, { scope: 'user' }) + + expect(logged()).toContain(`codex mcp add netlify --url ${NETLIFY_MCP_URL}`) + }) + + test('says nothing needs doing when the hosted server is already set up at any scope', async () => { await install('claude') - execaMock.mockResolvedValueOnce(ourServer()) + execaMock.mockResolvedValueOnce(listed(UNRELATED_LINE, OURS_LINE)) - await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toEqual({ - selection: 'driving', + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ agents: { 'claude-code': 'present' }, }) @@ -145,38 +169,51 @@ describe('setupAgentMcp', () => { expect(logged()).not.toContain('mcp add') }) - test('reports an existing server on another URL and how to switch, without changing it', async () => { + test.each([ + ['a local npx server', LOCAL_LINE, 'netlify'], + ['a plugin server on the old host', LEGACY_PLUGIN_LINE, 'plugin:netlify:netlify'], + ])('leaves %s alone instead of adding a second one', async (_, line, name) => { await install('claude') - execaMock.mockResolvedValueOnce(legacyServer()) + execaMock.mockResolvedValueOnce(listed(line)) - await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toEqual({ - selection: 'driving', + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ agents: { 'claude-code': 'kept' }, }) - expect(logged()).toContain('the old host, netlify-mcp.netlify.app') - expect(logged()).toContain('claude mcp remove --scope user netlify') - expect(execaMock).toHaveBeenCalledTimes(1) + expect(logged()).toContain(name) + expect(logged()).not.toContain('mcp add') }) - test('prints an add-mcp command for an agent without its own add command', async () => { - await expect(run({ GEMINI_CLI: '1' })).resolves.toEqual({ - selection: 'driving', - agents: { 'gemini-cli': 'printed' }, + test('does not treat a URL that only contains the hosted URL as the hosted server', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed(`evil: https://evil.example/${NETLIFY_MCP_URL} (HTTP) - ✔ Connected`)) + + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ + agents: { 'claude-code': 'printed' }, }) + }) + + test('prints add-mcp for Gemini CLI without running anything', async () => { + await expect(run({ GEMINI_CLI: '1' })).resolves.toMatchObject({ agents: { 'gemini-cli': 'printed' } }) expect(execaMock).not.toHaveBeenCalled() - expect(logged()).toContain(`npx -y add-mcp@2.4.1 ${NETLIFY_MCP_URL} --name netlify -g -a gemini-cli`) + expect(logged()).toContain(`${ADD_MCP} -y -a gemini-cli`) + }) + + test('uses a user-wide command for an agent with no per-project setting, and says so', async () => { + await run({ CLINE_ACTIVE: 'true' }) + + expect(logged()).toContain(`${ADD_MCP} -g -y -a cline`) + expect(logged()).toContain('has no per-project setting') }) test('prints the command without a lookup when the agent CLI is not on PATH', async () => { - await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toEqual({ - selection: 'driving', + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ agents: { 'claude-code': 'printed' }, }) expect(execaMock).not.toHaveBeenCalled() - expect(logged()).toContain(`claude ${CLAUDE_ADD.join(' ')}`) + expect(logged()).toContain(`claude ${claudeAdd('local').join(' ')}`) }) test('never runs an agent binary from the project folder or a relative PATH entry', async () => { @@ -188,12 +225,11 @@ describe('setupAgentMcp', () => { expect(execaMock).not.toHaveBeenCalled() }) - test('respects a saved no from the prompt', async () => { + test('respects a saved no from the prompt for this project', async () => { await install('claude') - saved.set('agentMcpSetup.claude-code', { url: NETLIFY_MCP_URL, choice: 'declined' }) + saved.set('agentMcpSetup.claude-code', { url: NETLIFY_MCP_URL, scope: 'project', choice: 'declined' }) - await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toEqual({ - selection: 'driving', + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ agents: { 'claude-code': 'declined' }, }) @@ -203,67 +239,84 @@ describe('setupAgentMcp', () => { test('--reset-mcp-setup forgets a saved no for the agent running init', async () => { await install('claude') - saved.set('agentMcpSetup.claude-code', { url: NETLIFY_MCP_URL, choice: 'declined' }) - execaMock.mockResolvedValueOnce(missingServer()) + saved.set('agentMcpSetup.claude-code', { url: NETLIFY_MCP_URL, scope: 'project', choice: 'declined' }) + execaMock.mockResolvedValueOnce(listed()) - await expect(run({ NETLIFY_AGENT: 'claude-code' }, { reset: true })).resolves.toEqual({ - selection: 'driving', + await expect(run({ NETLIFY_AGENT: 'claude-code' }, { reset: true })).resolves.toMatchObject({ agents: { 'claude-code': 'printed' }, }) expect(saved.get('agentMcpSetup.claude-code')).toBeUndefined() }) - test('prints the generic command for an agent it has no mapping for', async () => { - await expect(run({ AGENT: 'amp' }, { interactive: true })).resolves.toEqual({ selection: 'none', agents: {} }) + test('prints the generic project command for an agent it has no mapping for', async () => { + await expect(run({ AGENT: 'amp' }, { interactive: true })).resolves.toMatchObject({ selection: 'none' }) expect(neverChoose).not.toHaveBeenCalled() - expect(logged()).toContain(`npx -y add-mcp@2.4.1 ${NETLIFY_MCP_URL} --name netlify -g`) + expect(logged()).toContain(ADD_MCP) + expect(logged()).not.toContain(`${ADD_MCP} -`) }) }) describe('when a person runs init', () => { - test('offers detected agents preticked and installed ones unticked, then adds the chosen ones', async () => { + test('offers detected agents preticked and installed ones unticked', async () => { await mkdir(join(project, '.cursor')) await install('code', 'codex') - execaMock.mockResolvedValueOnce(exited(0)) - const choose = vi.fn((candidates: AgentCandidate[]) => - Promise.resolve(candidates.filter(({ agent }) => agent.id !== 'codex').map(({ agent }) => agent)), - ) + const choose = chooseIds() - await expect(run({ TERM_PROGRAM: 'vscode' }, { interactive: true, choose })).resolves.toEqual({ - selection: 'prompt', - agents: { vscode: 'added', cursor: 'printed' }, - }) + await run({ TERM_PROGRAM: 'vscode' }, { interactive: true, choose }) expect(choose.mock.calls[0][0].map(({ agent, detected }) => [agent.id, detected])).toEqual([ ['vscode', true], ['cursor', true], ['codex', false], ]) - expect(execaMock).toHaveBeenCalledWith( - installed('code'), - ['--add-mcp', JSON.stringify({ name: 'netlify', type: 'http', url: NETLIFY_MCP_URL })], - expect.objectContaining({ stdin: 'ignore' }), - ) - expect(logged()).toContain('-a cursor') }) - test('adds a ticked agent after checking it has no server yet', async () => { + test('adds Claude for this project only after checking it has no Netlify server yet', async () => { await install('claude') - execaMock.mockResolvedValueOnce(missingServer()).mockResolvedValueOnce(exited(0)) + execaMock.mockResolvedValueOnce(listed(UNRELATED_LINE)).mockResolvedValueOnce(exited(0)) await expect(run({}, { interactive: true, choose: chooseIds('claude-code') })).resolves.toMatchObject({ agents: { 'claude-code': 'added' }, }) - expect(execaMock).toHaveBeenNthCalledWith(2, installed('claude'), CLAUDE_ADD, expect.anything()) - expect(logged()).toContain('claude mcp remove --scope user netlify') + expect(execaMock).toHaveBeenNthCalledWith(2, installed('claude'), claudeAdd('local'), expect.anything()) + expect(logged()).toContain('for this project') + expect(logged()).toContain('claude mcp remove --scope local netlify') }) - test('leaves a ticked agent alone when its server points at another URL', async () => { + test('prints project commands for ticked agents whose CLIs only add user-wide', async () => { + await install('code') + + await expect( + run({ TERM_PROGRAM: 'vscode' }, { interactive: true, choose: chooseIds('vscode') }), + ).resolves.toMatchObject({ agents: { vscode: 'printed' } }) + + expect(execaMock).not.toHaveBeenCalled() + expect(logged()).toContain(`${ADD_MCP} -y -a vscode`) + expect(logged()).toContain(join('.vscode', 'mcp.json')) + }) + + test('runs the agent CLI for user scope', async () => { + await install('code') + execaMock.mockResolvedValueOnce(exited(0)) + + await expect( + run({ TERM_PROGRAM: 'vscode' }, { interactive: true, choose: chooseIds('vscode'), scope: 'user' }), + ).resolves.toMatchObject({ agents: { vscode: 'added' } }) + + expect(execaMock).toHaveBeenCalledWith( + installed('code'), + ['--add-mcp', JSON.stringify({ name: 'netlify', type: 'http', url: NETLIFY_MCP_URL })], + expect.objectContaining({ stdin: 'ignore' }), + ) + expect(logged()).toContain('replaced any') + }) + + test('leaves a ticked agent alone when it already has another Netlify server', async () => { await install('claude') - execaMock.mockResolvedValueOnce(legacyServer()) + execaMock.mockResolvedValueOnce(listed(LOCAL_LINE)) await expect(run({}, { interactive: true, choose: chooseIds('claude-code') })).resolves.toMatchObject({ agents: { 'claude-code': 'kept' }, @@ -272,16 +325,16 @@ describe('setupAgentMcp', () => { expect(execaMock).toHaveBeenCalledTimes(1) }) - test('does not add when the lookup fails for a reason other than a missing server', async () => { + test('does not add when the lookup fails', async () => { await install('claude') - execaMock.mockResolvedValueOnce(exited(1, 'Failed to connect')) + execaMock.mockResolvedValueOnce(exited(1, 'boom')) await expect(run({}, { interactive: true, choose: chooseIds('claude-code') })).resolves.toMatchObject({ agents: { 'claude-code': 'failed' }, }) expect(execaMock).toHaveBeenCalledTimes(1) - expect(warned()).toContain('Failed to connect') + expect(warned()).toContain('boom') }) test('does not add when the lookup times out', async () => { @@ -297,20 +350,20 @@ describe('setupAgentMcp', () => { test('reports a failed add with the command to run by hand', async () => { await install('claude') - execaMock.mockResolvedValueOnce(missingServer()).mockResolvedValueOnce(exited(1, 'boom')) + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(1, 'nope')) await expect(run({}, { interactive: true, choose: chooseIds('claude-code') })).resolves.toMatchObject({ agents: { 'claude-code': 'failed' }, }) - expect(warned()).toContain('boom') - expect(logged()).toContain(`claude ${CLAUDE_ADD.join(' ')}`) + expect(warned()).toContain('nope') + expect(logged()).toContain(`claude ${claudeAdd('local').join(' ')}`) }) test('adds nothing when the person unticks every agent', async () => { await install('claude') - await expect(run({}, { interactive: true, choose: chooseIds() })).resolves.toEqual({ + await expect(run({}, { interactive: true, choose: chooseIds() })).resolves.toMatchObject({ selection: 'prompt', agents: {}, }) @@ -330,7 +383,7 @@ describe('setupAgentMcp', () => { test('prints the generic command without a prompt when no terminal is attached', async () => { await install('claude') - await expect(run({})).resolves.toEqual({ selection: 'none', agents: {} }) + await expect(run({})).resolves.toMatchObject({ selection: 'none', agents: {} }) expect(neverChoose).not.toHaveBeenCalled() expect(execaMock).not.toHaveBeenCalled() @@ -338,67 +391,57 @@ describe('setupAgentMcp', () => { }) describe('saved choices', () => { - test('remembers both a yes and a no, so the next run does not prompt', async () => { + test('remembers both a yes and a no for this project, so the next run does not prompt', async () => { await install('claude', 'codex') - execaMock.mockResolvedValueOnce(missingServer()).mockResolvedValueOnce(exited(0)) + execaMock + .mockResolvedValueOnce(listed()) + .mockResolvedValueOnce(exited(0)) + .mockResolvedValueOnce(exited(1, 'No MCP server named "netlify"')) const choose = chooseIds('claude-code') await run({}, { interactive: true, choose }) expect(offeredIds(choose)).toEqual(['claude-code', 'codex']) - expect(logged()).toContain('init --reset-mcp-setup') + expect(saved.get('agentMcpSetup.codex')).toEqual({ url: NETLIFY_MCP_URL, scope: 'project', choice: 'declined' }) - await expect(run({}, { interactive: true })).resolves.toEqual({ selection: 'saved', agents: {} }) + await expect(run({}, { interactive: true })).resolves.toMatchObject({ selection: 'saved' }) expect(neverChoose).not.toHaveBeenCalled() - expect(execaMock).toHaveBeenCalledTimes(2) expect(logged()).toContain('Using your saved choices') }) - test('asks only about an agent installed since the last run', async () => { + test('a choice saved for project scope does not answer a user-scope run', async () => { await install('codex') - await run({}, { interactive: true, choose: chooseIds() }) - await install('opencode') + saved.set('agentMcpSetup.codex', { url: NETLIFY_MCP_URL, scope: 'project', choice: 'declined' }) const choose = chooseIds() - await run({}, { interactive: true, choose }) + await run({}, { interactive: true, choose, scope: 'user' }) - expect(offeredIds(choose)).toEqual(['opencode']) + expect(offeredIds(choose)).toEqual(['codex']) }) - test('--reset-mcp-setup asks about every agent again', async () => { + test('asks only about an agent installed since the last run', async () => { await install('codex') await run({}, { interactive: true, choose: chooseIds() }) - const choose = chooseIds() - - await run({}, { interactive: true, choose, reset: true }) - - expect(offeredIds(choose)).toEqual(['codex']) - }) - - test('does not save a yes whose add failed, so it is offered again', async () => { - await install('claude') - execaMock.mockResolvedValueOnce(missingServer()).mockResolvedValueOnce(exited(1, 'boom')) - await run({}, { interactive: true, choose: chooseIds('claude-code') }) + await install('opencode') const choose = chooseIds() await run({}, { interactive: true, choose }) - expect(offeredIds(choose)).toEqual(['claude-code']) + expect(offeredIds(choose)).toEqual(['opencode']) }) - test('offers an agent again when its saved choice was for a different server URL', async () => { + test('--reset-mcp-setup asks about every agent again', async () => { await install('codex') - saved.set('agentMcpSetup.codex', { url: 'https://netlify-mcp.netlify.app/mcp', choice: 'declined' }) + await run({}, { interactive: true, choose: chooseIds() }) const choose = chooseIds() - await run({}, { interactive: true, choose }) + await run({}, { interactive: true, choose, reset: true }) expect(offeredIds(choose)).toEqual(['codex']) - expect(saved.get('agentMcpSetup.codex')).toEqual({ url: NETLIFY_MCP_URL, choice: 'declined' }) }) - test('does not save a yes when the agent kept a different netlify server, so it is offered again', async () => { + test('does not save a yes whose add failed or that kept another server', async () => { await install('claude') - execaMock.mockResolvedValueOnce(legacyServer()) + execaMock.mockResolvedValueOnce(listed(LOCAL_LINE)) await run({}, { interactive: true, choose: chooseIds('claude-code') }) const choose = chooseIds() @@ -409,7 +452,7 @@ describe('setupAgentMcp', () => { test('an agent-driven run does not save anything', async () => { await install('claude') - execaMock.mockResolvedValueOnce(missingServer()) + execaMock.mockResolvedValueOnce(listed()) await run({ NETLIFY_AGENT: 'claude-code' }) From 535189fecf9303ae0c4b16d0a0d98bba7c32a2b6 Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Wed, 21 Oct 2026 01:27:48 -0400 Subject: [PATCH 09/19] fix(init): read older claude mcp list output and flag commands left to run --- src/utils/init/agent-mcp.ts | 13 ++++++++++--- tests/unit/utils/init/agent-mcp.test.ts | 10 ++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/utils/init/agent-mcp.ts b/src/utils/init/agent-mcp.ts index 2b0660f0f95..65f70e427b4 100644 --- a/src/utils/init/agent-mcp.ts +++ b/src/utils/init/agent-mcp.ts @@ -298,11 +298,12 @@ type Lookup = Existing | { found: 'none' } | { found: 'unknown'; run: NativeRun const listedServers = (stdout: string): { name: string; target: string }[] => stdout.split('\n').flatMap((line) => { const separator = line.indexOf(': ') - const status = line.lastIndexOf(' - ') - if (separator <= 0 || status <= separator) { + if (separator <= 0) { return [] } - return [{ name: line.slice(0, separator).trim(), target: line.slice(separator + 2, status).trim() }] + const status = line.lastIndexOf(' - ') + const end = status > separator ? status : line.length + return [{ name: line.slice(0, separator).trim(), target: line.slice(separator + 2, end).trim() }] }) const lookUp = async (executable: string, kind: 'list' | 'get', workingDir: string): Promise => { @@ -582,6 +583,12 @@ export const setupAgentMcp = async ({ summary.agents[agent.id] = await add(agent, scope, env, workingDir) } saveChoices(store, scope, selected.offered, selected.agents, summary.agents) + const toRun = selected.agents.filter(({ id }) => summary.agents[id] === 'printed') + if (toRun.length > 0) { + log( + `Run the command shown for ${toRun.map(({ label }) => label).join(', ')} to finish. You won't be asked about ${toRun.length === 1 ? 'it' : 'them'} again in this project.`, + ) + } log(`Your choices are saved for this project. ${resetHint()}`) } catch (error) { if (error instanceof Error && error.name === 'ExitPromptError') { diff --git a/tests/unit/utils/init/agent-mcp.test.ts b/tests/unit/utils/init/agent-mcp.test.ts index 8647b2aaf7e..abc16a7ff64 100644 --- a/tests/unit/utils/init/agent-mcp.test.ts +++ b/tests/unit/utils/init/agent-mcp.test.ts @@ -184,6 +184,15 @@ describe('setupAgentMcp', () => { expect(logged()).not.toContain('mcp add') }) + test('reads older claude mcp list output that has no health status', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed(`netlify: ${NETLIFY_MCP_URL} (HTTP)`)) + + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ + agents: { 'claude-code': 'present' }, + }) + }) + test('does not treat a URL that only contains the hosted URL as the hosted server', async () => { await install('claude') execaMock.mockResolvedValueOnce(listed(`evil: https://evil.example/${NETLIFY_MCP_URL} (HTTP) - ✔ Connected`)) @@ -296,6 +305,7 @@ describe('setupAgentMcp', () => { expect(execaMock).not.toHaveBeenCalled() expect(logged()).toContain(`${ADD_MCP} -y -a vscode`) expect(logged()).toContain(join('.vscode', 'mcp.json')) + expect(logged()).toContain('Run the command shown for VS Code to finish') }) test('runs the agent CLI for user scope', async () => { From 404f7ab05755be4bb71b6e0109522c1d6df81535 Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Wed, 21 Oct 2026 02:45:10 -0400 Subject: [PATCH 10/19] feat(init): add the MCP server for the agent running init 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. --- src/utils/init/agent-mcp.ts | 16 +------ tests/integration/commands/init/init.test.ts | 20 +++++---- tests/unit/utils/init/agent-mcp.test.ts | 46 ++++++++++++-------- 3 files changed, 40 insertions(+), 42 deletions(-) diff --git a/src/utils/init/agent-mcp.ts b/src/utils/init/agent-mcp.ts index 65f70e427b4..5a15a38b293 100644 --- a/src/utils/init/agent-mcp.ts +++ b/src/utils/init/agent-mcp.ts @@ -393,20 +393,6 @@ const printCommand = (agent: McpAgent, scope: McpScope, lead: string): void => { log(restartHint(agent)) } -const suggest = async ( - agent: McpAgent, - scope: McpScope, - env: NodeJS.ProcessEnv, - workingDir: string, -): Promise => { - const { lookup } = await lookUpExisting(agent, env, workingDir) - if (lookup && isExisting(lookup)) { - return reportExisting(agent, lookup) - } - printCommand(agent, scope, `If the user wants the Netlify MCP server in ${agent.label} for ${where(scope)}, run`) - return 'printed' -} - const add = async ( agent: McpAgent, scope: McpScope, @@ -574,7 +560,7 @@ export const setupAgentMcp = async ({ log(`You chose not to add the Netlify MCP server to ${agent.label}. ${resetHint()}`) summary.agents[agent.id] = 'declined' } else { - summary.agents[agent.id] = await suggest(agent, scope, env, workingDir) + summary.agents[agent.id] = await add(agent, scope, env, workingDir) } return summary } diff --git a/tests/integration/commands/init/init.test.ts b/tests/integration/commands/init/init.test.ts index c6b0d2ec029..4260b55ba32 100644 --- a/tests/integration/commands/init/init.test.ts +++ b/tests/integration/commands/init/init.test.ts @@ -857,7 +857,7 @@ describe.concurrent('commands/init', () => { }) test.skipIf(process.platform === 'win32')( - 'netlify init tells the agent running it how to add the Netlify MCP server, and both skip flags silence it', + 'netlify init adds the Netlify MCP server for the project to the agent running it, and both skip flags silence it', async (t) => { await withSiteBuilder(t, async (builder) => { await builder.withGit().build() @@ -870,6 +870,7 @@ describe.concurrent('commands/init', () => { '#!/bin/sh', `echo "$*" >> "${callsFile}"`, `if [ "$2" = "list" ]; then echo "No MCP servers configured."; exit 0; fi`, + `if [ "$2" = "add" ]; then exit 0; fi`, 'exit 2', '', ].join('\n'), @@ -880,15 +881,11 @@ describe.concurrent('commands/init', () => { await withSkillsHost(async (skillsHost) => { const runInit = initOnLinkedSite({ apiUrl, cwd: builder.directory, skillsHost: skillsHost.url, claudeBin }) - const suggested = await runInit() - t.expect(suggested.stdout).toContain( - 'claude mcp add --transport http --scope local netlify https://mcp.netlify.com/mcp', - ) + const projectScoped = await runInit() + t.expect(projectScoped.stdout).toContain('Added the Netlify MCP server to Claude Code for this project') const userWide = await runInit('--mcp-scope', 'user') - t.expect(userWide.stdout).toContain( - 'claude mcp add --transport http --scope user netlify https://mcp.netlify.com/mcp', - ) + t.expect(userWide.stdout).toContain('Added the Netlify MCP server to Claude Code for all your projects') const skippedMcp = await runInit('--skip-mcp-setup') t.expect(skippedMcp.stdout).not.toContain('MCP server') @@ -897,7 +894,12 @@ describe.concurrent('commands/init', () => { t.expect(skippedAll.stdout).not.toContain('MCP server') const calls = (await readFile(callsFile, 'utf8')).trim().split('\n') - t.expect(calls).toEqual(['mcp list', 'mcp list']) + t.expect(calls).toEqual([ + 'mcp list', + 'mcp add --transport http --scope local netlify https://mcp.netlify.com/mcp', + 'mcp list', + 'mcp add --transport http --scope user netlify https://mcp.netlify.com/mcp', + ]) }) }) }) diff --git a/tests/unit/utils/init/agent-mcp.test.ts b/tests/unit/utils/init/agent-mcp.test.ts index abc16a7ff64..11c96879cec 100644 --- a/tests/unit/utils/init/agent-mcp.test.ts +++ b/tests/unit/utils/init/agent-mcp.test.ts @@ -107,34 +107,35 @@ describe('setupAgentMcp', () => { choose.mock.calls[call][0].map(({ agent }) => agent.id) describe('when an agent runs init', () => { - test('prints the project-scoped Claude command and never writes config', async () => { + test('installs Claude for this project only, without a prompt, after checking every scope', async () => { await install('claude') - execaMock.mockResolvedValueOnce(listed(UNRELATED_LINE)) + execaMock.mockResolvedValueOnce(listed(UNRELATED_LINE)).mockResolvedValueOnce(exited(0)) await expect(run({ NETLIFY_AGENT: 'claude-code' }, { interactive: true })).resolves.toEqual({ scope: 'project', selection: 'driving', - agents: { 'claude-code': 'printed' }, + agents: { 'claude-code': 'added' }, }) expect(neverChoose).not.toHaveBeenCalled() - expect(execaMock).toHaveBeenCalledTimes(1) - expect(execaMock).toHaveBeenCalledWith( + expect(execaMock).toHaveBeenNthCalledWith( + 1, installed('claude'), ['mcp', 'list'], expect.objectContaining({ cwd: project, reject: false, stdin: 'ignore' }), ) - expect(logged()).toContain(`claude ${claudeAdd('local').join(' ')}`) + expect(execaMock).toHaveBeenNthCalledWith(2, installed('claude'), claudeAdd('local'), expect.anything()) + expect(logged()).toContain('claude mcp remove --scope local netlify') expect(logged()).toContain('loads the next time you start Claude Code in this folder') }) - test('prints the user-scoped command with --mcp-scope user', async () => { + test('installs user-wide only when --mcp-scope user is passed', async () => { await install('claude') - execaMock.mockResolvedValueOnce(listed()) + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) await run({ NETLIFY_AGENT: 'claude-code' }, { scope: 'user' }) - expect(logged()).toContain(`claude ${claudeAdd('user').join(' ')}`) + expect(execaMock).toHaveBeenNthCalledWith(2, installed('claude'), claudeAdd('user'), expect.anything()) }) test('prints a project add-mcp command for Codex, naming the file it writes', async () => { @@ -148,13 +149,20 @@ describe('setupAgentMcp', () => { expect(logged()).toContain(join('.codex', 'config.toml')) }) - test('prints the Codex CLI command when the scope is user', async () => { + test('runs the Codex CLI when the scope is user', async () => { await install('codex') - execaMock.mockResolvedValueOnce(exited(1, "Error: No MCP server named 'netlify' found.")) + execaMock + .mockResolvedValueOnce(exited(1, "Error: No MCP server named 'netlify' found.")) + .mockResolvedValueOnce(exited(0)) - await run({ CODEX_CI: '1' }, { scope: 'user' }) + await expect(run({ CODEX_CI: '1' }, { scope: 'user' })).resolves.toMatchObject({ agents: { codex: 'added' } }) - expect(logged()).toContain(`codex mcp add netlify --url ${NETLIFY_MCP_URL}`) + expect(execaMock).toHaveBeenNthCalledWith( + 2, + installed('codex'), + ['mcp', 'add', 'netlify', '--url', NETLIFY_MCP_URL], + expect.anything(), + ) }) test('says nothing needs doing when the hosted server is already set up at any scope', async () => { @@ -195,10 +203,12 @@ describe('setupAgentMcp', () => { test('does not treat a URL that only contains the hosted URL as the hosted server', async () => { await install('claude') - execaMock.mockResolvedValueOnce(listed(`evil: https://evil.example/${NETLIFY_MCP_URL} (HTTP) - ✔ Connected`)) + execaMock + .mockResolvedValueOnce(listed(`evil: https://evil.example/${NETLIFY_MCP_URL} (HTTP) - ✔ Connected`)) + .mockResolvedValueOnce(exited(0)) await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ - agents: { 'claude-code': 'printed' }, + agents: { 'claude-code': 'added' }, }) }) @@ -249,10 +259,10 @@ describe('setupAgentMcp', () => { test('--reset-mcp-setup forgets a saved no for the agent running init', async () => { await install('claude') saved.set('agentMcpSetup.claude-code', { url: NETLIFY_MCP_URL, scope: 'project', choice: 'declined' }) - execaMock.mockResolvedValueOnce(listed()) + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) await expect(run({ NETLIFY_AGENT: 'claude-code' }, { reset: true })).resolves.toMatchObject({ - agents: { 'claude-code': 'printed' }, + agents: { 'claude-code': 'added' }, }) expect(saved.get('agentMcpSetup.claude-code')).toBeUndefined() @@ -462,7 +472,7 @@ describe('setupAgentMcp', () => { test('an agent-driven run does not save anything', async () => { await install('claude') - execaMock.mockResolvedValueOnce(listed()) + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) await run({ NETLIFY_AGENT: 'claude-code' }) From 8875f870b2e543bc0340009e61a32396f4a7dec1 Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Wed, 21 Oct 2026 02:48:01 -0400 Subject: [PATCH 11/19] fix(init): name where Claude's MCP entry is saved --- src/utils/init/agent-mcp.ts | 16 +++++++++++++--- tests/unit/utils/init/agent-mcp.test.ts | 12 ++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/utils/init/agent-mcp.ts b/src/utils/init/agent-mcp.ts index 5a15a38b293..e97adca5be2 100644 --- a/src/utils/init/agent-mcp.ts +++ b/src/utils/init/agent-mcp.ts @@ -22,6 +22,7 @@ export type McpScope = (typeof MCP_SCOPES)[number] interface NativeCommands { add: string[] remove?: string[] + savedIn?: string } interface NativeSetup { @@ -64,8 +65,16 @@ export const MCP_AGENTS: McpAgent[] = [ native: { lookup: 'list', scopes: { - project: { add: claudeAdd('local'), remove: ['mcp', 'remove', '--scope', 'local', NETLIFY_MCP_SERVER_NAME] }, - user: { add: claudeAdd('user'), remove: ['mcp', 'remove', '--scope', 'user', NETLIFY_MCP_SERVER_NAME] }, + project: { + add: claudeAdd('local'), + remove: ['mcp', 'remove', '--scope', 'local', NETLIFY_MCP_SERVER_NAME], + savedIn: "~/.claude.json, under this project's path", + }, + user: { + add: claudeAdd('user'), + remove: ['mcp', 'remove', '--scope', 'user', NETLIFY_MCP_SERVER_NAME], + savedIn: '~/.claude.json', + }, }, }, }, @@ -420,7 +429,8 @@ const add = async ( log(` Add it yourself with ${formatCommand(agent.binary, commands.add)}`) return 'failed' } - log(`Added the Netlify MCP server to ${agent.label} for ${where(scope)}. Nothing was written to the repository.`) + const savedIn = commands.savedIn ? `Saved in ${commands.savedIn}; nothing` : 'Nothing' + log(`Added the Netlify MCP server to ${agent.label} for ${where(scope)}. ${savedIn} was written to the repository.`) if (!agent.native?.lookup) { log( ` ${agent.label} can't be checked first, so this replaced any ${chalk.cyan(NETLIFY_MCP_SERVER_NAME)} server it had.`, diff --git a/tests/unit/utils/init/agent-mcp.test.ts b/tests/unit/utils/init/agent-mcp.test.ts index 11c96879cec..2713303e6cf 100644 --- a/tests/unit/utils/init/agent-mcp.test.ts +++ b/tests/unit/utils/init/agent-mcp.test.ts @@ -125,10 +125,22 @@ describe('setupAgentMcp', () => { expect.objectContaining({ cwd: project, reject: false, stdin: 'ignore' }), ) expect(execaMock).toHaveBeenNthCalledWith(2, installed('claude'), claudeAdd('local'), expect.anything()) + expect(logged()).toContain("Saved in ~/.claude.json, under this project's path") expect(logged()).toContain('claude mcp remove --scope local netlify') expect(logged()).toContain('loads the next time you start Claude Code in this folder') }) + test('does not install when the existing-server check fails', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(exited(1, 'boom')) + + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ + agents: { 'claude-code': 'failed' }, + }) + + expect(execaMock).toHaveBeenCalledTimes(1) + }) + test('installs user-wide only when --mcp-scope user is passed', async () => { await install('claude') execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) From 73eae05642b21620fc5c6c9009582a5284927a89 Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Wed, 21 Oct 2026 03:09:37 -0400 Subject: [PATCH 12/19] fix(init): never overwrite or go user-wide by default 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. --- src/commands/init/init.ts | 3 +- src/utils/init/agent-mcp.ts | 116 +++++++++++++------- tests/unit/utils/init/agent-mcp.test.ts | 138 +++++++++++++++++++----- 3 files changed, 191 insertions(+), 66 deletions(-) diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index 14985794dd3..b9b9e9a578c 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -263,7 +263,8 @@ const setUpAgents = async ( log() const blocker = await findProjectSetupBlocker(repositoryRoot) if (blocker) { - log(`Skipped setting up Netlify skills and the Netlify MCP server: ${blocker}.`) + const skipped = [skills && 'Netlify skills', mcp && 'the Netlify MCP server'].filter(Boolean).join(' and ') + log(`Skipped setting up ${skipped}: ${blocker}.`) log(` Run ${chalk.cyanBright.bold(`${netlifyCommand()} init`)} from your project folder to set them up there.`) await track('sites_agentSetupSkipped', { skills, mcp }) return diff --git a/src/utils/init/agent-mcp.ts b/src/utils/init/agent-mcp.ts index e97adca5be2..6b3981e89f9 100644 --- a/src/utils/init/agent-mcp.ts +++ b/src/utils/init/agent-mcp.ts @@ -19,6 +19,8 @@ const ADD_MCP_VERSION = '2.4.1' export const MCP_SCOPES = ['project', 'user'] as const export type McpScope = (typeof MCP_SCOPES)[number] +type LookupKind = 'list' | 'list-json' + interface NativeCommands { add: string[] remove?: string[] @@ -26,7 +28,7 @@ interface NativeCommands { } interface NativeSetup { - lookup?: 'list' | 'get' + lookup?: LookupKind scopes: Partial> } @@ -86,7 +88,7 @@ export const MCP_AGENTS: McpAgent[] = [ projectMarkers: ['.codex'], projectFile: path.join('.codex', 'config.toml'), native: { - lookup: 'get', + lookup: 'list-json', scopes: { user: { add: ['mcp', 'add', NETLIFY_MCP_SERVER_NAME, '--url', NETLIFY_MCP_URL], @@ -155,7 +157,7 @@ export const MCP_AGENTS: McpAgent[] = [ { id: 'cline', label: 'Cline', drivenBy: ['cline'], projectMarkers: ['.clinerules'] }, ] -export type AgentMcpStatus = 'added' | 'present' | 'kept' | 'printed' | 'declined' | 'failed' +export type AgentMcpStatus = 'added' | 'present' | 'kept' | 'printed' | 'unsupported' | 'declined' | 'failed' export type AgentSelection = 'driving' | 'prompt' | 'saved' | 'none' export interface AgentMcpSetupSummary { @@ -224,9 +226,16 @@ const hasProjectMarker = async (agent: McpAgent, workingDir: string): Promise => { +const supportsScope = (agent: McpAgent, scope: McpScope): boolean => + scope === 'user' || agent.projectFile !== undefined || agent.native?.scopes.project !== undefined + +export const findCandidates = async ( + workingDir: string, + env: NodeJS.ProcessEnv, + scope: McpScope = 'project', +): Promise => { const candidates: AgentCandidate[] = [] - for (const agent of MCP_AGENTS) { + for (const agent of MCP_AGENTS.filter((candidate) => supportsScope(candidate, scope))) { const detected = Boolean(agent.inTerminal?.(env)) || (await hasProjectMarker(agent, workingDir)) if (detected || (agent.binary !== undefined && (await findOnPath(agent.binary, env)) !== undefined)) { candidates.push({ agent, detected }) @@ -251,6 +260,7 @@ const OUR_URL = new URL(NETLIFY_MCP_URL) const isOurUrl = (url: URL): boolean => url.protocol === OUR_URL.protocol && url.hostname.toLowerCase() === OUR_URL.hostname && + url.port === OUR_URL.port && url.pathname.replace(/\/$/, '') === OUR_URL.pathname const pointsAtNetlify = (target: string): boolean => @@ -274,7 +284,7 @@ const formatCommand = (binary: string, args: string[]): string => chalk.cyanBright.bold([binary, ...args].map(shellQuote).join(' ')) const addMcpArgs = (agents: McpAgent[], scope: McpScope): string[] => { - const global = scope === 'user' || agents.some((agent) => agent.projectFile === undefined) + const global = scope === 'user' return [ '-y', `add-mcp@${ADD_MCP_VERSION}`, @@ -315,28 +325,49 @@ const listedServers = (stdout: string): { name: string; target: string }[] => return [{ name: line.slice(0, separator).trim(), target: line.slice(separator + 2, end).trim() }] }) -const lookUp = async (executable: string, kind: 'list' | 'get', workingDir: string): Promise => { - if (kind === 'list') { - const run = await runNative(executable, ['mcp', 'list'], workingDir, LIST_TIMEOUT_MS) - if (run.exitCode !== 0) { - return { found: 'unknown', run } - } - const servers = listedServers(run.stdout) - const ours = servers.find(({ target }) => urlsIn(target).some(isOurUrl)) - if (ours) { - return { found: 'ours', name: ours.name } +interface CodexServer { + name?: unknown + transport?: { url?: unknown; command?: unknown; args?: unknown } +} + +const codexServers = (stdout: string): { name: string; target: string }[] | undefined => { + try { + const parsed: unknown = JSON.parse(stdout) + if (!Array.isArray(parsed)) { + return undefined } - const other = servers.find(({ name, target }) => isNetlifyName(name) || pointsAtNetlify(target)) - return other ? { found: 'other', name: other.name } : { found: 'none' } + return (parsed as CodexServer[]).map(({ name, transport }) => ({ + name: typeof name === 'string' ? name : '', + target: [ + transport?.url, + transport?.command, + ...(Array.isArray(transport?.args) ? (transport.args as unknown[]) : []), + ] + .filter((part): part is string => typeof part === 'string') + .join(' '), + })) + } catch { + return undefined } - const run = await runNative(executable, ['mcp', 'get', NETLIFY_MCP_SERVER_NAME], workingDir) - if (run.exitCode === 0) { - return { found: urlsIn(run.output).some(isOurUrl) ? 'ours' : 'other', name: NETLIFY_MCP_SERVER_NAME } +} + +const classify = (servers: { name: string; target: string }[]): Lookup => { + const ours = servers.find(({ target }) => urlsIn(target).some(isOurUrl)) + if (ours) { + return { found: 'ours', name: ours.name } } - if (run.exitCode === 1 && run.output.includes('No MCP server named')) { - return { found: 'none' } + const other = servers.find(({ name, target }) => isNetlifyName(name) || pointsAtNetlify(target)) + return other ? { found: 'other', name: other.name } : { found: 'none' } +} + +const lookUp = async (executable: string, kind: LookupKind, workingDir: string): Promise => { + if (kind === 'list-json') { + const run = await runNative(executable, ['mcp', 'list', '--json'], workingDir) + const servers = run.exitCode === 0 ? codexServers(run.stdout) : undefined + return servers ? classify(servers) : { found: 'unknown', run } } - return { found: 'unknown', run } + const run = await runNative(executable, ['mcp', 'list'], workingDir, LIST_TIMEOUT_MS) + return run.exitCode === 0 ? classify(listedServers(run.stdout)) : { found: 'unknown', run } } const isExisting = (lookup: Lookup): lookup is Existing => lookup.found === 'ours' || lookup.found === 'other' @@ -369,8 +400,8 @@ const lookUpExisting = async ( return { executable, lookup: await lookUp(executable, kind, workingDir) } } -const restartHint = (agent: McpAgent): string => - ` The Netlify MCP server loads the next time you start ${agent.label} in this folder.` +const restartHint = (agent: McpAgent, scope: McpScope): string => + ` The Netlify MCP server loads the next time you start ${agent.label}${scope === 'project' ? ' in this folder' : ''}.` const commandFor = (agent: McpAgent, scope: McpScope): string => { const native = agent.native?.scopes[scope] @@ -381,11 +412,12 @@ const commandFor = (agent: McpAgent, scope: McpScope): string => { } const fileNote = (agent: McpAgent, scope: McpScope): string | undefined => { - if (agent.native?.scopes[scope]) { - return undefined + const native = agent.native?.scopes[scope] + if (native && !agent.native?.lookup) { + return ` Running it replaces any ${chalk.cyan(NETLIFY_MCP_SERVER_NAME)} server ${agent.label} already has.` } - if (scope === 'project' && agent.projectFile === undefined) { - return ` ${agent.label} has no per-project setting, so this adds it for all your projects.` + if (native) { + return undefined } if (scope === 'project') { return ` This writes ${agent.projectFile ?? ''} in this folder. Commit it only if everyone on the project should get it.` @@ -399,7 +431,7 @@ const printCommand = (agent: McpAgent, scope: McpScope, lead: string): void => { if (note) { log(note) } - log(restartHint(agent)) + log(restartHint(agent, scope)) } const add = async ( @@ -408,6 +440,12 @@ const add = async ( env: NodeJS.ProcessEnv, workingDir: string, ): Promise => { + if (!supportsScope(agent, scope)) { + log( + `${agent.label} has no per-project MCP setting, so nothing was set up. To add the Netlify MCP server for all your projects, run ${chalk.cyanBright.bold(`${netlifyCommand()} init --mcp-scope user`)}`, + ) + return 'unsupported' + } const commands = agent.native?.scopes[scope] const { executable, lookup } = await lookUpExisting(agent, env, workingDir) if (lookup?.found === 'unknown') { @@ -418,7 +456,7 @@ const add = async ( if (lookup && isExisting(lookup)) { return reportExisting(agent, lookup) } - if (!commands || !executable || !agent.binary) { + if (!commands || !executable || !agent.binary || !agent.native?.lookup) { printCommand(agent, scope, `To add the Netlify MCP server to ${agent.label} for ${where(scope)}, run`) return 'printed' } @@ -431,21 +469,17 @@ const add = async ( } const savedIn = commands.savedIn ? `Saved in ${commands.savedIn}; nothing` : 'Nothing' log(`Added the Netlify MCP server to ${agent.label} for ${where(scope)}. ${savedIn} was written to the repository.`) - if (!agent.native?.lookup) { - log( - ` ${agent.label} can't be checked first, so this replaced any ${chalk.cyan(NETLIFY_MCP_SERVER_NAME)} server it had.`, - ) - } if (commands.remove) { log(` Remove it with ${formatCommand(agent.binary, commands.remove)}`) } - log(restartHint(agent)) + log(restartHint(agent, scope)) return 'added' } export interface McpChoiceStore { get(key: string): unknown set(key: string, value: unknown): void + delete(key: string): void } interface SavedChoice { @@ -511,8 +545,8 @@ const selectAgents = async ({ if (!agent) { return { selection: 'none', agents: [] } } - if (reset) { - store.set(savedChoiceKey(agent), undefined) + if (reset && store.get(savedChoiceKey(agent)) !== undefined) { + store.delete(savedChoiceKey(agent)) } return { selection: 'driving', @@ -523,7 +557,7 @@ const selectAgents = async ({ if (!interactive) { return { selection: 'none', agents: [] } } - const candidates = await findCandidates(workingDir, env) + const candidates = await findCandidates(workingDir, env, scope) if (candidates.length === 0) { return { selection: 'none', agents: [] } } diff --git a/tests/unit/utils/init/agent-mcp.test.ts b/tests/unit/utils/init/agent-mcp.test.ts index 2713303e6cf..3abb6572e38 100644 --- a/tests/unit/utils/init/agent-mcp.test.ts +++ b/tests/unit/utils/init/agent-mcp.test.ts @@ -1,7 +1,8 @@ -import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' +import { LocalState } from '@netlify/dev-utils' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { log, warn } from '../../../../src/utils/command-helpers.js' @@ -28,6 +29,8 @@ const execaMock = vi.mocked(execa) as unknown as ReturnType const exited = (exitCode: number, stderr = '', stdout = '') => ({ exitCode, stdout, stderr, timedOut: false }) const listed = (...lines: string[]) => exited(0, '', ['Checking MCP server health…', '', ...lines].join('\n')) const timedOut = () => ({ exitCode: undefined, stdout: '', stderr: '', timedOut: true }) +const codexListed = (...servers: { name: string; transport: Record }[]) => + exited(0, '', JSON.stringify(servers)) const OURS_LINE = `netlify: ${NETLIFY_MCP_URL} (HTTP) - ✔ Connected` const LOCAL_LINE = 'netlify: npx -y @netlify/mcp - ✔ Connected' @@ -66,11 +69,16 @@ describe('setupAgentMcp', () => { await mkdir(project) await mkdir(bin) saved = new Map() - store = { get: (key) => saved.get(key), set: (key, value) => saved.set(key, value) } + store = { + get: (key) => saved.get(key), + set: (key, value) => saved.set(key, value), + delete: (key) => saved.delete(key), + } }) afterEach(async () => { vi.clearAllMocks() + execaMock.mockReset() await rm(root, { recursive: true, force: true }) }) @@ -86,11 +94,18 @@ describe('setupAgentMcp', () => { const run = ( env: NodeJS.ProcessEnv, - options: { interactive?: boolean; choose?: ChooseAgents; reset?: boolean; path?: string; scope?: McpScope } = {}, + options: { + interactive?: boolean + choose?: ChooseAgents + reset?: boolean + path?: string + scope?: McpScope + store?: McpChoiceStore + } = {}, ) => setupAgentMcp({ workingDir: project, - store, + store: options.store ?? store, scope: options.scope ?? 'project', env: { PATH: options.path ?? bin, ...env }, interactive: options.interactive ?? false, @@ -152,10 +167,11 @@ describe('setupAgentMcp', () => { test('prints a project add-mcp command for Codex, naming the file it writes', async () => { await install('codex') - execaMock.mockResolvedValueOnce(exited(1, "Error: No MCP server named 'netlify' found.")) + execaMock.mockResolvedValueOnce(codexListed()) await expect(run({ CODEX_CI: '1' })).resolves.toMatchObject({ agents: { codex: 'printed' } }) + expect(execaMock).toHaveBeenCalledWith(installed('codex'), ['mcp', 'list', '--json'], expect.anything()) expect(logged()).toContain(`${ADD_MCP} -y -a codex`) expect(logged()).not.toContain(' -g') expect(logged()).toContain(join('.codex', 'config.toml')) @@ -163,9 +179,7 @@ describe('setupAgentMcp', () => { test('runs the Codex CLI when the scope is user', async () => { await install('codex') - execaMock - .mockResolvedValueOnce(exited(1, "Error: No MCP server named 'netlify' found.")) - .mockResolvedValueOnce(exited(0)) + execaMock.mockResolvedValueOnce(codexListed()).mockResolvedValueOnce(exited(0)) await expect(run({ CODEX_CI: '1' }, { scope: 'user' })).resolves.toMatchObject({ agents: { codex: 'added' } }) @@ -175,6 +189,32 @@ describe('setupAgentMcp', () => { ['mcp', 'add', 'netlify', '--url', NETLIFY_MCP_URL], expect.anything(), ) + expect(logged()).toContain('loads the next time you start Codex.') + }) + + test.each([ + ['the hosted URL under another name', { type: 'streamable_http', url: NETLIFY_MCP_URL }, 'present'], + [ + 'the local npx server under another name', + { type: 'stdio', command: 'npx', args: ['-y', '@netlify/mcp'] }, + 'kept', + ], + ])('finds a Codex server with %s', async (_, transport, status) => { + await install('codex') + execaMock.mockResolvedValueOnce(codexListed({ name: 'netlify-prod', transport })) + + await expect(run({ CODEX_CI: '1' }, { scope: 'user' })).resolves.toMatchObject({ agents: { codex: status } }) + + expect(execaMock).toHaveBeenCalledTimes(1) + }) + + test('treats unreadable Codex output as a failed check and adds nothing', async () => { + await install('codex') + execaMock.mockResolvedValueOnce(exited(0, '', 'not json')) + + await expect(run({ CODEX_CI: '1' }, { scope: 'user' })).resolves.toMatchObject({ agents: { codex: 'failed' } }) + + expect(execaMock).toHaveBeenCalledTimes(1) }) test('says nothing needs doing when the hosted server is already set up at any scope', async () => { @@ -231,11 +271,18 @@ describe('setupAgentMcp', () => { expect(logged()).toContain(`${ADD_MCP} -y -a gemini-cli`) }) - test('uses a user-wide command for an agent with no per-project setting, and says so', async () => { - await run({ CLINE_ACTIVE: 'true' }) + test('suggests nothing user-wide at project scope for an agent with no per-project setting', async () => { + await expect(run({ CLINE_ACTIVE: 'true' })).resolves.toMatchObject({ agents: { cline: 'unsupported' } }) + + expect(logged()).toContain('has no per-project MCP setting') + expect(logged()).toContain('init --mcp-scope user') + expect(logged()).not.toContain(' -g') + }) + + test('suggests the user-wide command for that agent when the scope is user', async () => { + await run({ CLINE_ACTIVE: 'true' }, { scope: 'user' }) expect(logged()).toContain(`${ADD_MCP} -g -y -a cline`) - expect(logged()).toContain('has no per-project setting') }) test('prints the command without a lookup when the agent CLI is not on PATH', async () => { @@ -330,20 +377,26 @@ describe('setupAgentMcp', () => { expect(logged()).toContain('Run the command shown for VS Code to finish') }) - test('runs the agent CLI for user scope', async () => { + test('prints instead of running an agent CLI that cannot check for an existing server', async () => { await install('code') - execaMock.mockResolvedValueOnce(exited(0)) await expect( run({ TERM_PROGRAM: 'vscode' }, { interactive: true, choose: chooseIds('vscode'), scope: 'user' }), - ).resolves.toMatchObject({ agents: { vscode: 'added' } }) + ).resolves.toMatchObject({ agents: { vscode: 'printed' } }) - expect(execaMock).toHaveBeenCalledWith( - installed('code'), - ['--add-mcp', JSON.stringify({ name: 'netlify', type: 'http', url: NETLIFY_MCP_URL })], - expect.objectContaining({ stdin: 'ignore' }), - ) - expect(logged()).toContain('replaced any') + expect(execaMock).not.toHaveBeenCalled() + expect(logged()).toContain('code --add-mcp') + expect(logged()).toContain('Running it replaces any') + }) + + test('leaves agents with no per-project setting out of the project-scope prompt', async () => { + await mkdir(join(project, '.windsurf')) + await install('windsurf', 'claude') + const choose = chooseIds() + + await run({}, { interactive: true, choose }) + + expect(offeredIds(choose)).toEqual(['claude-code']) }) test('leaves a ticked agent alone when it already has another Netlify server', async () => { @@ -425,10 +478,7 @@ describe('setupAgentMcp', () => { describe('saved choices', () => { test('remembers both a yes and a no for this project, so the next run does not prompt', async () => { await install('claude', 'codex') - execaMock - .mockResolvedValueOnce(listed()) - .mockResolvedValueOnce(exited(0)) - .mockResolvedValueOnce(exited(1, 'No MCP server named "netlify"')) + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) const choose = chooseIds('claude-code') await run({}, { interactive: true, choose }) @@ -492,6 +542,46 @@ describe('setupAgentMcp', () => { }) }) + describe('with the real project state file', () => { + const stateFile = () => join(project, '.netlify', 'state.json') + + test('saves a choice nested under agentMcpSetup and reads it back on the next run', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) + + await run({}, { interactive: true, choose: chooseIds('claude-code'), store: new LocalState(project) }) + + const written = JSON.parse(await readFile(stateFile(), 'utf8')) as Record + expect(written).toEqual({ + agentMcpSetup: { 'claude-code': { url: NETLIFY_MCP_URL, scope: 'project', choice: 'accepted' } }, + }) + await expect(run({}, { interactive: true, store: new LocalState(project) })).resolves.toMatchObject({ + selection: 'saved', + }) + }) + + test('an agent run with --reset-mcp-setup and nothing saved never creates the state file', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) + + await run({ NETLIFY_AGENT: 'claude-code' }, { reset: true, store: new LocalState(project) }) + + await expect(readFile(stateFile(), 'utf8')).rejects.toThrow(/ENOENT/) + }) + + test('--reset-mcp-setup removes a saved no from the state file in an agent run', async () => { + await install('claude') + const state = new LocalState(project) + state.set('siteId', 'site_id') + state.set('agentMcpSetup.claude-code', { url: NETLIFY_MCP_URL, scope: 'project', choice: 'declined' }) + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) + + await run({ NETLIFY_AGENT: 'claude-code' }, { reset: true, store: new LocalState(project) }) + + expect(JSON.parse(await readFile(stateFile(), 'utf8'))).toEqual({ siteId: 'site_id', agentMcpSetup: {} }) + }) + }) + describe('findCandidates', () => { test('tells Cursor terminals apart from VS Code ones', async () => { const candidates = await findCandidates(project, { PATH: bin, TERM_PROGRAM: 'vscode', CURSOR_TRACE_ID: 'x' }) From b70b3e394a6ec3c8e774fc3cdf5b0a7a10143c4e Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Wed, 21 Oct 2026 13:56:36 -0400 Subject: [PATCH 13/19] fix(init): keep agent runs from touching saved MCP choices --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. --- src/utils/init/agent-mcp.ts | 6 +----- tests/unit/utils/init/agent-mcp.test.ts | 18 +++++++++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/utils/init/agent-mcp.ts b/src/utils/init/agent-mcp.ts index 6b3981e89f9..39624c5ebbc 100644 --- a/src/utils/init/agent-mcp.ts +++ b/src/utils/init/agent-mcp.ts @@ -479,7 +479,6 @@ const add = async ( export interface McpChoiceStore { get(key: string): unknown set(key: string, value: unknown): void - delete(key: string): void } interface SavedChoice { @@ -545,13 +544,10 @@ const selectAgents = async ({ if (!agent) { return { selection: 'none', agents: [] } } - if (reset && store.get(savedChoiceKey(agent)) !== undefined) { - store.delete(savedChoiceKey(agent)) - } return { selection: 'driving', agents: [agent], - declined: savedChoice(store, agent, scope)?.choice === 'declined', + declined: !reset && savedChoice(store, agent, scope)?.choice === 'declined', } } if (!interactive) { diff --git a/tests/unit/utils/init/agent-mcp.test.ts b/tests/unit/utils/init/agent-mcp.test.ts index 3abb6572e38..b9dd0ec358c 100644 --- a/tests/unit/utils/init/agent-mcp.test.ts +++ b/tests/unit/utils/init/agent-mcp.test.ts @@ -72,7 +72,6 @@ describe('setupAgentMcp', () => { store = { get: (key) => saved.get(key), set: (key, value) => saved.set(key, value), - delete: (key) => saved.delete(key), } }) @@ -315,16 +314,17 @@ describe('setupAgentMcp', () => { expect(logged()).toContain('init --reset-mcp-setup') }) - test('--reset-mcp-setup forgets a saved no for the agent running init', async () => { + test('--reset-mcp-setup ignores a saved no for the agent running init without changing it', async () => { await install('claude') - saved.set('agentMcpSetup.claude-code', { url: NETLIFY_MCP_URL, scope: 'project', choice: 'declined' }) + const declined = { url: NETLIFY_MCP_URL, scope: 'project', choice: 'declined' } + saved.set('agentMcpSetup.claude-code', declined) execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) await expect(run({ NETLIFY_AGENT: 'claude-code' }, { reset: true })).resolves.toMatchObject({ agents: { 'claude-code': 'added' }, }) - expect(saved.get('agentMcpSetup.claude-code')).toBeUndefined() + expect(saved.get('agentMcpSetup.claude-code')).toEqual(declined) }) test('prints the generic project command for an agent it has no mapping for', async () => { @@ -569,16 +569,20 @@ describe('setupAgentMcp', () => { await expect(readFile(stateFile(), 'utf8')).rejects.toThrow(/ENOENT/) }) - test('--reset-mcp-setup removes a saved no from the state file in an agent run', async () => { + test.each([ + ['the add succeeds', exited(0)], + ['the add fails', exited(1, 'boom')], + ])('an agent run with --reset-mcp-setup leaves the state file byte for byte when %s', async (_, addResult) => { await install('claude') const state = new LocalState(project) state.set('siteId', 'site_id') state.set('agentMcpSetup.claude-code', { url: NETLIFY_MCP_URL, scope: 'project', choice: 'declined' }) - execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) + const before = await readFile(stateFile(), 'utf8') + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(addResult) await run({ NETLIFY_AGENT: 'claude-code' }, { reset: true, store: new LocalState(project) }) - expect(JSON.parse(await readFile(stateFile(), 'utf8'))).toEqual({ siteId: 'site_id', agentMcpSetup: {} }) + await expect(readFile(stateFile(), 'utf8')).resolves.toBe(before) }) }) From b3fe95ac02bcb677f3828b08f5cd84e9defdad58 Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Thu, 22 Oct 2026 13:21:07 -0400 Subject: [PATCH 14/19] fix(init): address MCP setup review round three 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. --- src/utils/init/agent-mcp.ts | 44 +++---- tests/integration/commands/init/init.test.ts | 120 ++++++++++++++++--- tests/unit/utils/init/agent-mcp.test.ts | 78 +++++++++--- 3 files changed, 182 insertions(+), 60 deletions(-) diff --git a/src/utils/init/agent-mcp.ts b/src/utils/init/agent-mcp.ts index 39624c5ebbc..69ee088e7b3 100644 --- a/src/utils/init/agent-mcp.ts +++ b/src/utils/init/agent-mcp.ts @@ -1,7 +1,8 @@ -import { constants as fsConstants, promises as fs } from 'node:fs' +import { promises as fs } from 'node:fs' import path from 'node:path' import { checkbox } from '@inquirer/prompts' +import { isexe } from 'isexe' import { type CanonicalAgentName, getDrivingAgent } from '../agent-detection.js' import { chalk, log, netlifyCommand, warn } from '../command-helpers.js' @@ -193,22 +194,11 @@ const pathExists = async (target: string): Promise => { } } -const isExecutableFile = async (target: string): Promise => { - try { - if (!(await fs.stat(target)).isFile()) { - return false - } - await fs.access(target, process.platform === 'win32' ? fsConstants.F_OK : fsConstants.X_OK) - return true - } catch { - return false - } -} - const findOnPath = async (binary: string, env: NodeJS.ProcessEnv): Promise => { const pathExtensions = (env.PATHEXT ?? '').split(';').filter(Boolean) const extensions = process.platform === 'win32' ? (pathExtensions.length > 0 ? pathExtensions : ['.EXE', '.CMD', '.BAT']) : [''] + const isExecutableFile = (target: string) => isexe(target, { ignoreErrors: true, pathExt: extensions.join(';') }) const candidates = (env.PATH ?? '') .split(path.delimiter) .filter((directory) => path.isAbsolute(directory)) @@ -483,17 +473,16 @@ export interface McpChoiceStore { interface SavedChoice { url: string - scope: McpScope - choice: 'accepted' | 'declined' + choice: 'accepted' | 'declined' | 'kept' } const SAVED_CHOICES_KEY = 'agentMcpSetup' -const savedChoiceKey = (agent: McpAgent): string => `${SAVED_CHOICES_KEY}.${agent.id}` +const savedChoiceKey = (agent: McpAgent, scope: McpScope): string => `${SAVED_CHOICES_KEY}.${scope}.${agent.id}` const savedChoice = (store: McpChoiceStore, agent: McpAgent, scope: McpScope): SavedChoice | undefined => { - const saved = store.get(savedChoiceKey(agent)) as SavedChoice | undefined - return saved?.url === NETLIFY_MCP_URL && saved.scope === scope ? saved : undefined + const saved = store.get(savedChoiceKey(agent, scope)) as SavedChoice | undefined + return saved?.url === NETLIFY_MCP_URL ? saved : undefined } const saveChoices = ( @@ -504,11 +493,12 @@ const saveChoices = ( statuses: Record, ): void => { for (const agent of offered) { - if (!chosen.includes(agent)) { - store.set(savedChoiceKey(agent), { url: NETLIFY_MCP_URL, scope, choice: 'declined' } satisfies SavedChoice) - } else if (statuses[agent.id] !== 'failed' && statuses[agent.id] !== 'kept') { - store.set(savedChoiceKey(agent), { url: NETLIFY_MCP_URL, scope, choice: 'accepted' } satisfies SavedChoice) + const status = statuses[agent.id] + if (status === 'failed') { + continue } + const choice: SavedChoice['choice'] = !chosen.includes(agent) ? 'declined' : status === 'kept' ? 'kept' : 'accepted' + store.set(savedChoiceKey(agent, scope), { url: NETLIFY_MCP_URL, choice } satisfies SavedChoice) } } @@ -539,11 +529,8 @@ const selectAgents = async ({ store: McpChoiceStore }): Promise => { const drivingAgent = getDrivingAgent(env) - if (drivingAgent) { - const agent = MCP_AGENTS.find(({ drivenBy }) => drivenBy?.includes(drivingAgent.name)) - if (!agent) { - return { selection: 'none', agents: [] } - } + const agent = drivingAgent && MCP_AGENTS.find(({ drivenBy }) => drivenBy?.includes(drivingAgent.name)) + if (agent) { return { selection: 'driving', agents: [agent], @@ -591,6 +578,9 @@ export const setupAgentMcp = async ({ } if (selected.selection === 'none') { log(`To add the Netlify MCP server to your AI agents, run ${formatCommand('npx', addMcpArgs([], scope))}`) + log( + ` It asks which agents to set up. To skip the questions, add ${chalk.cyan('-y -a ')}; ${formatCommand('npx', [`add-mcp@${ADD_MCP_VERSION}`, 'list-agents'])} shows the agent names.`, + ) return summary } diff --git a/tests/integration/commands/init/init.test.ts b/tests/integration/commands/init/init.test.ts index 4260b55ba32..e9589f763db 100644 --- a/tests/integration/commands/init/init.test.ts +++ b/tests/integration/commands/init/init.test.ts @@ -806,8 +806,39 @@ describe.concurrent('commands/init', () => { { question: 'The SSH URL of the remote git repo', answer: CONFIRM }, { question: 'Configure the following webhook for your repository', answer: CONFIRM }, ] + const writeFakeClaude = async (directory: string) => { + const dir = path.join(directory, '.fake-bin') + const calls = path.join(dir, 'calls.log') + await mkdir(dir, { recursive: true }) + await writeFile( + path.join(dir, 'claude'), + [ + '#!/bin/sh', + `echo "$*" >> "${calls}"`, + `if [ "$2" = "list" ]; then echo "No MCP servers configured."; exit 0; fi`, + `if [ "$2" = "add" ]; then exit 0; fi`, + 'exit 2', + '', + ].join('\n'), + ) + await chmod(path.join(dir, 'claude'), 0o755) + return { dir, calls } + } + const initOnLinkedSite = - ({ apiUrl, cwd, skillsHost, claudeBin }: { apiUrl: string; cwd: string; skillsHost: string; claudeBin?: string }) => + ({ + apiUrl, + cwd, + skillsHost, + claudeBin, + home, + }: { + apiUrl: string + cwd: string + skillsHost: string + claudeBin?: string + home?: string + }) => async (...flags: string[]) => { const env = { NETLIFY_API_URL: apiUrl, @@ -817,6 +848,7 @@ describe.concurrent('commands/init', () => { ...(claudeBin ? { PATH: `${claudeBin}${path.delimiter}${process.env.PATH ?? ''}`, NETLIFY_AGENT: 'claude-code' } : {}), + ...(home ? { HOME: home, USERPROFILE: home } : {}), } const mcpFlags = claudeBin ? [] : ['--skip-mcp-setup'] const childProcess = execa(cliPath, ['init', '--manual', ...mcpFlags, ...flags], { cwd, env }) @@ -861,21 +893,7 @@ describe.concurrent('commands/init', () => { async (t) => { await withSiteBuilder(t, async (builder) => { await builder.withGit().build() - const claudeBin = path.join(builder.directory, '.fake-bin') - const callsFile = path.join(claudeBin, 'calls.log') - await mkdir(claudeBin, { recursive: true }) - await writeFile( - path.join(claudeBin, 'claude'), - [ - '#!/bin/sh', - `echo "$*" >> "${callsFile}"`, - `if [ "$2" = "list" ]; then echo "No MCP servers configured."; exit 0; fi`, - `if [ "$2" = "add" ]; then exit 0; fi`, - 'exit 2', - '', - ].join('\n'), - ) - await chmod(path.join(claudeBin, 'claude'), 0o755) + const { dir: claudeBin, calls: callsFile } = await writeFakeClaude(builder.directory) await withMockApi(linkedSiteRoutes, async ({ apiUrl }) => { await withSkillsHost(async (skillsHost) => { @@ -906,6 +924,76 @@ describe.concurrent('commands/init', () => { }, ) + test.skipIf(process.platform === 'win32')( + 'netlify init skips skills and the MCP server when run from the home directory', + async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.withGit().ensureDirectoryExists(path.join(builder.directory, '.agents')).build() + const claudeBin = await writeFakeClaude(builder.directory) + + await withMockApi(linkedSiteRoutes, async ({ apiUrl }) => { + await withSkillsHost(async (skillsHost) => { + const runInit = initOnLinkedSite({ + apiUrl, + cwd: builder.directory, + skillsHost: skillsHost.url, + claudeBin: claudeBin.dir, + home: builder.directory, + }) + + const { stdout } = await runInit() + + t.expect(stdout).toContain('Skipped setting up Netlify skills and the Netlify MCP server') + t.expect(stdout).toContain('this is your home directory') + await t + .expect(readFile(path.join(builder.directory, '.agents', 'skills', 'netlify-functions', 'SKILL.md'))) + .rejects.toThrow(/ENOENT/) + await t.expect(readFile(claudeBin.calls, 'utf8')).rejects.toThrow(/ENOENT/) + }) + }) + }) + }, + ) + + test.skipIf(process.platform === 'win32')( + 'netlify init honors a saved no in an agent run until --reset-mcp-setup', + async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.withGit().build() + const claudeBin = await writeFakeClaude(builder.directory) + await mkdir(path.join(builder.directory, '.netlify'), { recursive: true }) + await writeFile( + path.join(builder.directory, '.netlify', 'state.json'), + JSON.stringify({ + agentMcpSetup: { project: { 'claude-code': { url: 'https://mcp.netlify.com/mcp', choice: 'declined' } } }, + }), + ) + + await withMockApi(linkedSiteRoutes, async ({ apiUrl }) => { + await withSkillsHost(async (skillsHost) => { + const runInit = initOnLinkedSite({ + apiUrl, + cwd: builder.directory, + skillsHost: skillsHost.url, + claudeBin: claudeBin.dir, + }) + + const declined = await runInit() + t.expect(declined.stdout).toContain('You chose not to add the Netlify MCP server to Claude Code') + await t.expect(readFile(claudeBin.calls, 'utf8')).rejects.toThrow(/ENOENT/) + + const reset = await runInit('--reset-mcp-setup') + t.expect(reset.stdout).toContain('Added the Netlify MCP server to Claude Code for this project') + t.expect((await readFile(claudeBin.calls, 'utf8')).trim().split('\n')).toEqual([ + 'mcp list', + 'mcp add --transport http --scope local netlify https://mcp.netlify.com/mcp', + ]) + }) + }) + }) + }, + ) + test('netlify init syncs installed skills: updates stale copies, removes deprecated ones, keeps edits until --reset-context', async (t) => { await withSiteBuilder(t, async (builder) => { const skillsDir = path.join('.agents', 'skills') diff --git a/tests/unit/utils/init/agent-mcp.test.ts b/tests/unit/utils/init/agent-mcp.test.ts index b9dd0ec358c..f3af2eb08a7 100644 --- a/tests/unit/utils/init/agent-mcp.test.ts +++ b/tests/unit/utils/init/agent-mcp.test.ts @@ -1,6 +1,6 @@ import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { delimiter, join } from 'node:path' +import { delimiter, join, relative, resolve } from 'node:path' import { LocalState } from '@netlify/dev-utils' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' @@ -53,7 +53,7 @@ const warned = () => const neverChoose = vi.fn(() => Promise.reject(new Error('prompt should not open'))) -const executableName = (binary: string) => (process.platform === 'win32' ? `${binary}.cmd` : binary) +const executableName = (binary: string) => (process.platform === 'win32' ? `${binary}.CMD` : binary) describe('setupAgentMcp', () => { let root: string @@ -293,18 +293,30 @@ describe('setupAgentMcp', () => { expect(logged()).toContain(`claude ${claudeAdd('local').join(' ')}`) }) - test('never runs an agent binary from the project folder or a relative PATH entry', async () => { - await writeFile(join(project, executableName('claude')), '') - await chmod(join(project, executableName('claude')), 0o755) + test('never runs an agent binary found through a relative PATH entry', async () => { + await install('claude') + const relativeBin = relative(resolve(), bin) + expect(relativeBin).not.toBe(bin) - await run({ NETLIFY_AGENT: 'claude-code' }, { path: ['.', project.replace(root, '..')].join(delimiter) }) + await expect(run({ NETLIFY_AGENT: 'claude-code' }, { path: relativeBin })).resolves.toMatchObject({ + agents: { 'claude-code': 'printed' }, + }) expect(execaMock).not.toHaveBeenCalled() }) + test('runs the agent binary from the same directory once it is an absolute PATH entry', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) + + await run({ NETLIFY_AGENT: 'claude-code' }, { path: [relative(resolve(), bin), bin].join(delimiter) }) + + expect(execaMock).toHaveBeenNthCalledWith(1, installed('claude'), ['mcp', 'list'], expect.anything()) + }) + test('respects a saved no from the prompt for this project', async () => { await install('claude') - saved.set('agentMcpSetup.claude-code', { url: NETLIFY_MCP_URL, scope: 'project', choice: 'declined' }) + saved.set('agentMcpSetup.project.claude-code', { url: NETLIFY_MCP_URL, choice: 'declined' }) await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ agents: { 'claude-code': 'declined' }, @@ -316,23 +328,36 @@ describe('setupAgentMcp', () => { test('--reset-mcp-setup ignores a saved no for the agent running init without changing it', async () => { await install('claude') - const declined = { url: NETLIFY_MCP_URL, scope: 'project', choice: 'declined' } - saved.set('agentMcpSetup.claude-code', declined) + const declined = { url: NETLIFY_MCP_URL, choice: 'declined' } + saved.set('agentMcpSetup.project.claude-code', declined) execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) await expect(run({ NETLIFY_AGENT: 'claude-code' }, { reset: true })).resolves.toMatchObject({ agents: { 'claude-code': 'added' }, }) - expect(saved.get('agentMcpSetup.claude-code')).toEqual(declined) + expect(saved.get('agentMcpSetup.project.claude-code')).toEqual(declined) }) - test('prints the generic project command for an agent it has no mapping for', async () => { - await expect(run({ AGENT: 'amp' }, { interactive: true })).resolves.toMatchObject({ selection: 'none' }) + test('prints the generic command with how to skip its questions for an agent it has no mapping for', async () => { + await expect(run({ AGENT: 'amp' })).resolves.toMatchObject({ selection: 'none' }) expect(neverChoose).not.toHaveBeenCalled() expect(logged()).toContain(ADD_MCP) expect(logged()).not.toContain(`${ADD_MCP} -`) + expect(logged()).toContain('-y -a ') + expect(logged()).toContain('add-mcp@2.4.1 list-agents') + }) + + test('shows the checkbox to a person in a terminal when the detected agent has no mapping', async () => { + await install('claude') + const choose = chooseIds() + + await expect(run({ AGENT: 'amp' }, { interactive: true, choose })).resolves.toMatchObject({ + selection: 'prompt', + }) + + expect(offeredIds(choose)).toEqual(['claude-code']) }) }) @@ -483,7 +508,7 @@ describe('setupAgentMcp', () => { await run({}, { interactive: true, choose }) expect(offeredIds(choose)).toEqual(['claude-code', 'codex']) - expect(saved.get('agentMcpSetup.codex')).toEqual({ url: NETLIFY_MCP_URL, scope: 'project', choice: 'declined' }) + expect(saved.get('agentMcpSetup.project.codex')).toEqual({ url: NETLIFY_MCP_URL, choice: 'declined' }) await expect(run({}, { interactive: true })).resolves.toMatchObject({ selection: 'saved' }) expect(neverChoose).not.toHaveBeenCalled() @@ -492,7 +517,7 @@ describe('setupAgentMcp', () => { test('a choice saved for project scope does not answer a user-scope run', async () => { await install('codex') - saved.set('agentMcpSetup.codex', { url: NETLIFY_MCP_URL, scope: 'project', choice: 'declined' }) + saved.set('agentMcpSetup.project.codex', { url: NETLIFY_MCP_URL, choice: 'declined' }) const choose = chooseIds() await run({}, { interactive: true, choose, scope: 'user' }) @@ -500,6 +525,16 @@ describe('setupAgentMcp', () => { expect(offeredIds(choose)).toEqual(['codex']) }) + test('a user-scope run keeps the project answer, so the next default run does not ask again', async () => { + await install('codex') + await run({}, { interactive: true, choose: chooseIds() }) + await run({}, { interactive: true, choose: chooseIds(), scope: 'user' }) + + await expect(run({}, { interactive: true })).resolves.toMatchObject({ selection: 'saved' }) + expect(saved.get('agentMcpSetup.project.codex')).toEqual({ url: NETLIFY_MCP_URL, choice: 'declined' }) + expect(saved.get('agentMcpSetup.user.codex')).toEqual({ url: NETLIFY_MCP_URL, choice: 'declined' }) + }) + test('asks only about an agent installed since the last run', async () => { await install('codex') await run({}, { interactive: true, choose: chooseIds() }) @@ -521,10 +556,19 @@ describe('setupAgentMcp', () => { expect(offeredIds(choose)).toEqual(['codex']) }) - test('does not save a yes whose add failed or that kept another server', async () => { + test('remembers a kept server, so a rerun does not offer the agent again', async () => { await install('claude') execaMock.mockResolvedValueOnce(listed(LOCAL_LINE)) await run({}, { interactive: true, choose: chooseIds('claude-code') }) + + await expect(run({}, { interactive: true })).resolves.toMatchObject({ selection: 'saved' }) + expect(saved.get('agentMcpSetup.project.claude-code')).toEqual({ url: NETLIFY_MCP_URL, choice: 'kept' }) + }) + + test('does not save a yes whose add failed, so it is offered again', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(1, 'boom')) + await run({}, { interactive: true, choose: chooseIds('claude-code') }) const choose = chooseIds() await run({}, { interactive: true, choose }) @@ -553,7 +597,7 @@ describe('setupAgentMcp', () => { const written = JSON.parse(await readFile(stateFile(), 'utf8')) as Record expect(written).toEqual({ - agentMcpSetup: { 'claude-code': { url: NETLIFY_MCP_URL, scope: 'project', choice: 'accepted' } }, + agentMcpSetup: { project: { 'claude-code': { url: NETLIFY_MCP_URL, choice: 'accepted' } } }, }) await expect(run({}, { interactive: true, store: new LocalState(project) })).resolves.toMatchObject({ selection: 'saved', @@ -576,7 +620,7 @@ describe('setupAgentMcp', () => { await install('claude') const state = new LocalState(project) state.set('siteId', 'site_id') - state.set('agentMcpSetup.claude-code', { url: NETLIFY_MCP_URL, scope: 'project', choice: 'declined' }) + state.set('agentMcpSetup.project.claude-code', { url: NETLIFY_MCP_URL, choice: 'declined' }) const before = await readFile(stateFile(), 'utf8') execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(addResult) From d7c3ed7e1f37871b1bfa50a831bbbfabcd994a14 Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Thu, 22 Oct 2026 13:26:13 -0400 Subject: [PATCH 15/19] test(init): skip relative-PATH tests when temp is on another drive --- tests/unit/utils/init/agent-mcp.test.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/unit/utils/init/agent-mcp.test.ts b/tests/unit/utils/init/agent-mcp.test.ts index f3af2eb08a7..db8463f8c5d 100644 --- a/tests/unit/utils/init/agent-mcp.test.ts +++ b/tests/unit/utils/init/agent-mcp.test.ts @@ -1,6 +1,6 @@ import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { delimiter, join, relative, resolve } from 'node:path' +import { delimiter, join, parse, relative, resolve } from 'node:path' import { LocalState } from '@netlify/dev-utils' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' @@ -55,6 +55,8 @@ const neverChoose = vi.fn(() => Promise.reject(new Error('prompt s const executableName = (binary: string) => (process.platform === 'win32' ? `${binary}.CMD` : binary) +const tempOnAnotherDrive = parse(tmpdir()).root.toLowerCase() !== parse(resolve()).root.toLowerCase() + describe('setupAgentMcp', () => { let root: string let project: string @@ -293,7 +295,7 @@ describe('setupAgentMcp', () => { expect(logged()).toContain(`claude ${claudeAdd('local').join(' ')}`) }) - test('never runs an agent binary found through a relative PATH entry', async () => { + test.skipIf(tempOnAnotherDrive)('never runs an agent binary found through a relative PATH entry', async () => { await install('claude') const relativeBin = relative(resolve(), bin) expect(relativeBin).not.toBe(bin) @@ -305,14 +307,17 @@ describe('setupAgentMcp', () => { expect(execaMock).not.toHaveBeenCalled() }) - test('runs the agent binary from the same directory once it is an absolute PATH entry', async () => { - await install('claude') - execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) + test.skipIf(tempOnAnotherDrive)( + 'runs the agent binary from the same directory once it is an absolute PATH entry', + async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) - await run({ NETLIFY_AGENT: 'claude-code' }, { path: [relative(resolve(), bin), bin].join(delimiter) }) + await run({ NETLIFY_AGENT: 'claude-code' }, { path: [relative(resolve(), bin), bin].join(delimiter) }) - expect(execaMock).toHaveBeenNthCalledWith(1, installed('claude'), ['mcp', 'list'], expect.anything()) - }) + expect(execaMock).toHaveBeenNthCalledWith(1, installed('claude'), ['mcp', 'list'], expect.anything()) + }, + ) test('respects a saved no from the prompt for this project', async () => { await install('claude') From 5c7d7c4de147625cc91186ad257435273a625514 Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Fri, 23 Oct 2026 11:18:51 -0400 Subject: [PATCH 16/19] fix(init): address MCP setup review round four Check scope before treating a Claude entry as user-wide, skip pending .mcp.json servers, let reset replace or forget saved choices, allow --mcp-scope user from home, and tighten URL matching and printed names. --- src/commands/init/init.ts | 17 +- src/utils/init/agent-location.ts | 14 +- src/utils/init/agent-mcp.ts | 81 +++++-- tests/integration/commands/init/init.test.ts | 8 + tests/unit/utils/init/agent-location.test.ts | 10 +- tests/unit/utils/init/agent-mcp.test.ts | 226 +++++++++++++++++-- 6 files changed, 302 insertions(+), 54 deletions(-) diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index b9b9e9a578c..09c7dc28de9 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -262,17 +262,20 @@ const setUpAgents = async ( const { repositoryRoot } = command.netlify log() const blocker = await findProjectSetupBlocker(repositoryRoot) - if (blocker) { - const skipped = [skills && 'Netlify skills', mcp && 'the Netlify MCP server'].filter(Boolean).join(' and ') - log(`Skipped setting up ${skipped}: ${blocker}.`) + const skillsBlocked = skills && blocker !== undefined + const mcpBlocked = mcp && blocker !== undefined && (blocker.place === 'root' || mcpScope === 'project') + if (blocker && (skillsBlocked || mcpBlocked)) { + const skipped = [skillsBlocked && 'Netlify skills', mcpBlocked && 'the Netlify MCP server'] + .filter(Boolean) + .join(' and ') + log(`Skipped setting up ${skipped}: ${blocker.reason}.`) log(` Run ${chalk.cyanBright.bold(`${netlifyCommand()} init`)} from your project folder to set them up there.`) - await track('sites_agentSetupSkipped', { skills, mcp }) - return + await track('sites_agentSetupSkipped', { skills: skillsBlocked, mcp: mcpBlocked }) } - if (skills) { + if (skills && !skillsBlocked) { await installAgentSkills(repositoryRoot, reset) } - if (mcp) { + if (mcp && !mcpBlocked) { await addAgentMcp(command, resetMcp, mcpScope) } } diff --git a/src/utils/init/agent-location.ts b/src/utils/init/agent-location.ts index 29b8ba513ae..2d04512f589 100644 --- a/src/utils/init/agent-location.ts +++ b/src/utils/init/agent-location.ts @@ -10,13 +10,21 @@ const realpathOrSelf = async (target: string): Promise => { } } -export const findProjectSetupBlocker = async (root: string, home: string = homedir()): Promise => { +export interface ProjectSetupBlocker { + place: 'home' | 'root' + reason: string +} + +export const findProjectSetupBlocker = async ( + root: string, + home: string = homedir(), +): Promise => { const resolvedRoot = await realpathOrSelf(root) if (resolvedRoot === (await realpathOrSelf(home))) { - return 'this is your home directory, where they would apply to every project' + return { place: 'home', reason: 'this is your home directory, where they would apply to every project' } } if (resolvedRoot === path.parse(resolvedRoot).root) { - return 'this is the root of the file system' + return { place: 'root', reason: 'this is the root of the file system' } } return undefined } diff --git a/src/utils/init/agent-mcp.ts b/src/utils/init/agent-mcp.ts index 69ee088e7b3..71f2838adb5 100644 --- a/src/utils/init/agent-mcp.ts +++ b/src/utils/init/agent-mcp.ts @@ -249,9 +249,13 @@ const OUR_URL = new URL(NETLIFY_MCP_URL) const isOurUrl = (url: URL): boolean => url.protocol === OUR_URL.protocol && + url.username === '' && + url.password === '' && url.hostname.toLowerCase() === OUR_URL.hostname && url.port === OUR_URL.port && - url.pathname.replace(/\/$/, '') === OUR_URL.pathname + url.pathname.replace(/\/$/, '') === OUR_URL.pathname && + url.search === '' && + url.hash === '' const pointsAtNetlify = (target: string): boolean => urlsIn(target).some((url) => NETLIFY_MCP_HOSTS.has(url.hostname.toLowerCase())) || @@ -260,6 +264,16 @@ const pointsAtNetlify = (target: string): boolean => const isNetlifyName = (name: string): boolean => name === NETLIFY_MCP_SERVER_NAME || name.endsWith(`:${NETLIFY_MCP_SERVER_NAME}`) +const isControlCharacter = (character: string): boolean => { + const code = character.codePointAt(0) ?? 0 + return code < 0x20 || (code >= 0x7f && code <= 0x9f) +} + +const printable = (text: string): string => + Array.from(text) + .filter((character) => !isControlCharacter(character)) + .join('') + const shellQuote = (arg: string): string => { if (process.platform === 'win32') { if (/^[\w@%+=:,./\\-]+$/.test(arg)) { @@ -304,6 +318,9 @@ const describeFailure = ({ timedOut, output, exitCode }: NativeRun): string => type Existing = { found: 'ours' | 'other'; name: string } type Lookup = Existing | { found: 'none' } | { found: 'unknown'; run: NativeRun } +const PENDING_APPROVAL = 'Pending approval' +const USER_SCOPE_DETAIL = /^\s*Scope: User config\b/m + const listedServers = (stdout: string): { name: string; target: string }[] => stdout.split('\n').flatMap((line) => { const separator = line.indexOf(': ') @@ -312,6 +329,9 @@ const listedServers = (stdout: string): { name: string; target: string }[] => } const status = line.lastIndexOf(' - ') const end = status > separator ? status : line.length + if (line.slice(end).includes(PENDING_APPROVAL)) { + return [] + } return [{ name: line.slice(0, separator).trim(), target: line.slice(separator + 2, end).trim() }] }) @@ -350,27 +370,44 @@ const classify = (servers: { name: string; target: string }[]): Lookup => { return other ? { found: 'other', name: other.name } : { found: 'none' } } -const lookUp = async (executable: string, kind: LookupKind, workingDir: string): Promise => { +const isExisting = (lookup: Lookup): lookup is Existing => lookup.found === 'ours' || lookup.found === 'other' + +const lookUpAtUserScope = async (executable: string, lookup: Lookup, workingDir: string): Promise => { + if (!isExisting(lookup) || lookup.name.startsWith('plugin:')) { + return lookup + } + const details = await runNative(executable, ['mcp', 'get', lookup.name], workingDir, LIST_TIMEOUT_MS) + if (details.exitCode !== 0) { + return { found: 'unknown', run: details } + } + return USER_SCOPE_DETAIL.test(details.stdout) ? lookup : { found: 'none' } +} + +const lookUp = async (executable: string, kind: LookupKind, workingDir: string, scope: McpScope): Promise => { if (kind === 'list-json') { const run = await runNative(executable, ['mcp', 'list', '--json'], workingDir) const servers = run.exitCode === 0 ? codexServers(run.stdout) : undefined return servers ? classify(servers) : { found: 'unknown', run } } const run = await runNative(executable, ['mcp', 'list'], workingDir, LIST_TIMEOUT_MS) - return run.exitCode === 0 ? classify(listedServers(run.stdout)) : { found: 'unknown', run } + if (run.exitCode !== 0) { + return { found: 'unknown', run } + } + const lookup = classify(listedServers(run.stdout)) + return scope === 'user' ? await lookUpAtUserScope(executable, lookup, workingDir) : lookup } -const isExisting = (lookup: Lookup): lookup is Existing => lookup.found === 'ours' || lookup.found === 'other' - const reportExisting = (agent: McpAgent, { found, name }: Existing): AgentMcpStatus => { if (found === 'ours') { log(`${agent.label} already has the Netlify MCP server.`) 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.`, + `${agent.label} already has a Netlify MCP server (${chalk.cyan(printable(name))}) that isn't ${chalk.underline(NETLIFY_MCP_URL)}, so another one wasn't added.`, + ) + log( + ` To switch, remove ${chalk.cyan(printable(name))} in ${agent.label} and run ${chalk.cyanBright.bold(`${netlifyCommand()} init --reset-mcp-setup`)}`, ) - log(` To switch, remove ${chalk.cyan(name)} in ${agent.label} and run ${netlifyCommand()} init again.`) return 'kept' } @@ -378,6 +415,7 @@ const lookUpExisting = async ( agent: McpAgent, env: NodeJS.ProcessEnv, workingDir: string, + scope: McpScope, ): Promise<{ executable?: string; lookup?: Lookup }> => { if (agent.binary === undefined) { return {} @@ -387,7 +425,7 @@ const lookUpExisting = async ( if (!executable || !kind) { return { executable } } - return { executable, lookup: await lookUp(executable, kind, workingDir) } + return { executable, lookup: await lookUp(executable, kind, workingDir, scope) } } const restartHint = (agent: McpAgent, scope: McpScope): string => @@ -437,7 +475,7 @@ const add = async ( return 'unsupported' } const commands = agent.native?.scopes[scope] - const { executable, lookup } = await lookUpExisting(agent, env, workingDir) + const { executable, lookup } = await lookUpExisting(agent, env, workingDir, scope) if (lookup?.found === 'unknown') { warn(`Could not check ${agent.label} for an existing Netlify MCP server: ${describeFailure(lookup.run)}`) printCommand(agent, scope, ` Add it yourself with`) @@ -469,6 +507,7 @@ const add = async ( export interface McpChoiceStore { get(key: string): unknown set(key: string, value: unknown): void + delete(key: string): void } interface SavedChoice { @@ -485,6 +524,17 @@ const savedChoice = (store: McpChoiceStore, agent: McpAgent, scope: McpScope): S return saved?.url === NETLIFY_MCP_URL ? saved : undefined } +const saveChoice = (store: McpChoiceStore, agent: McpAgent, scope: McpScope, status: AgentMcpStatus): void => { + if (status === 'failed') { + if (store.get(savedChoiceKey(agent, scope)) !== undefined) { + store.delete(savedChoiceKey(agent, scope)) + } + return + } + const choice: SavedChoice['choice'] = status === 'declined' ? 'declined' : status === 'kept' ? 'kept' : 'accepted' + store.set(savedChoiceKey(agent, scope), { url: NETLIFY_MCP_URL, choice } satisfies SavedChoice) +} + const saveChoices = ( store: McpChoiceStore, scope: McpScope, @@ -493,12 +543,7 @@ const saveChoices = ( statuses: Record, ): void => { for (const agent of offered) { - const status = statuses[agent.id] - if (status === 'failed') { - continue - } - const choice: SavedChoice['choice'] = !chosen.includes(agent) ? 'declined' : status === 'kept' ? 'kept' : 'accepted' - store.set(savedChoiceKey(agent, scope), { url: NETLIFY_MCP_URL, choice } satisfies SavedChoice) + saveChoice(store, agent, scope, chosen.includes(agent) ? statuses[agent.id] : 'declined') } } @@ -590,7 +635,11 @@ export const setupAgentMcp = async ({ log(`You chose not to add the Netlify MCP server to ${agent.label}. ${resetHint()}`) summary.agents[agent.id] = 'declined' } else { - summary.agents[agent.id] = await add(agent, scope, env, workingDir) + const status = await add(agent, scope, env, workingDir) + summary.agents[agent.id] = status + if (reset && status !== 'failed' && status !== 'unsupported' && savedChoice(store, agent, scope)) { + saveChoice(store, agent, scope, status) + } } return summary } diff --git a/tests/integration/commands/init/init.test.ts b/tests/integration/commands/init/init.test.ts index e9589f763db..60c3a532855 100644 --- a/tests/integration/commands/init/init.test.ts +++ b/tests/integration/commands/init/init.test.ts @@ -949,6 +949,14 @@ describe.concurrent('commands/init', () => { .expect(readFile(path.join(builder.directory, '.agents', 'skills', 'netlify-functions', 'SKILL.md'))) .rejects.toThrow(/ENOENT/) await t.expect(readFile(claudeBin.calls, 'utf8')).rejects.toThrow(/ENOENT/) + + const userWide = await runInit('--mcp-scope', 'user') + t.expect(userWide.stdout).toContain('Skipped setting up Netlify skills: this is your home directory') + t.expect(userWide.stdout).toContain('Added the Netlify MCP server to Claude Code for all your projects') + t.expect((await readFile(claudeBin.calls, 'utf8')).trim().split('\n')).toEqual([ + 'mcp list', + 'mcp add --transport http --scope user netlify https://mcp.netlify.com/mcp', + ]) }) }) }) diff --git a/tests/unit/utils/init/agent-location.test.ts b/tests/unit/utils/init/agent-location.test.ts index 0193dc649a8..10663076b88 100644 --- a/tests/unit/utils/init/agent-location.test.ts +++ b/tests/unit/utils/init/agent-location.test.ts @@ -22,17 +22,21 @@ describe('findProjectSetupBlocker', () => { test('blocks the home directory even when it is a git repository', async () => { await mkdir(join(home, '.git')) - await expect(findProjectSetupBlocker(home, home)).resolves.toMatch(/home directory/) + const blocker = await findProjectSetupBlocker(home, home) + expect(blocker?.place).toBe('home') + expect(blocker?.reason).toMatch(/home directory/) }) test('blocks a path that resolves to the home directory through a symlink', async () => { const link = join(root, 'link-to-home') await symlink(home, link) - await expect(findProjectSetupBlocker(link, home)).resolves.toMatch(/home directory/) + await expect(findProjectSetupBlocker(link, home)).resolves.toMatchObject({ place: 'home' }) }) test('blocks the root of the file system', async () => { - await expect(findProjectSetupBlocker(parse(root).root, home)).resolves.toMatch(/root of the file system/) + const blocker = await findProjectSetupBlocker(parse(root).root, home) + expect(blocker?.place).toBe('root') + expect(blocker?.reason).toMatch(/root of the file system/) }) test('allows an empty folder, where an agent starts a new site', async () => { diff --git a/tests/unit/utils/init/agent-mcp.test.ts b/tests/unit/utils/init/agent-mcp.test.ts index db8463f8c5d..a7ba93c57bf 100644 --- a/tests/unit/utils/init/agent-mcp.test.ts +++ b/tests/unit/utils/init/agent-mcp.test.ts @@ -1,6 +1,6 @@ import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { delimiter, join, parse, relative, resolve } from 'node:path' +import { delimiter, join, relative, resolve } from 'node:path' import { LocalState } from '@netlify/dev-utils' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' @@ -55,7 +55,11 @@ const neverChoose = vi.fn(() => Promise.reject(new Error('prompt s const executableName = (binary: string) => (process.platform === 'win32' ? `${binary}.CMD` : binary) -const tempOnAnotherDrive = parse(tmpdir()).root.toLowerCase() !== parse(resolve()).root.toLowerCase() +const binUnderCwd = async () => { + const parent = resolve('tmp') + await mkdir(parent, { recursive: true }) + return await mkdtemp(join(parent, 'netlify-agent-mcp-bin-')) +} describe('setupAgentMcp', () => { let root: string @@ -74,6 +78,7 @@ describe('setupAgentMcp', () => { store = { get: (key) => saved.get(key), set: (key, value) => saved.set(key, value), + delete: (key) => saved.delete(key), } }) @@ -242,9 +247,89 @@ describe('setupAgentMcp', () => { }) expect(logged()).toContain(name) + expect(logged()).toContain('init --reset-mcp-setup') expect(logged()).not.toContain('mcp add') }) + test('prints an existing server name without its control characters', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed('\u001B[2J\u0007evil:netlify: npx -y @netlify/mcp - ✔ Connected')) + + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ + agents: { 'claude-code': 'kept' }, + }) + + expect(logged()).toContain('[2Jevil:netlify') + expect(logged()).not.toContain('\u001B[2J') + expect(logged()).not.toContain('\u0007') + }) + + test('adds the server when the only Netlify entry is a .mcp.json server still pending approval', async () => { + await install('claude') + execaMock + .mockResolvedValueOnce(listed(`netlify: ${NETLIFY_MCP_URL} (HTTP) - ⏸ Pending approval`)) + .mockResolvedValueOnce(exited(0)) + + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ + agents: { 'claude-code': 'added' }, + }) + }) + + test.each([ + ['another port', 'https://mcp.netlify.com:8443/mcp'], + ['another scheme', 'http://mcp.netlify.com/mcp'], + ['another path', 'https://mcp.netlify.com/mcp/v2'], + ['a query', 'https://mcp.netlify.com/mcp?token=x'], + ['a fragment', 'https://mcp.netlify.com/mcp#x'], + ['credentials', 'https://user:secret@mcp.netlify.com/mcp'], + ])('treats the hosted host with %s as another Netlify server', async (_, url) => { + await install('claude') + execaMock.mockResolvedValueOnce(listed(`netlify: ${url} (HTTP) - ✔ Connected`)) + + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ + agents: { 'claude-code': 'kept' }, + }) + }) + + test('adds user-wide when the only Netlify entry is set for this project', async () => { + await install('claude') + execaMock + .mockResolvedValueOnce(listed(OURS_LINE)) + .mockResolvedValueOnce(exited(0, '', 'netlify:\n Scope: Local config (private to you in this project)\n')) + .mockResolvedValueOnce(exited(0)) + + await expect(run({ NETLIFY_AGENT: 'claude-code' }, { scope: 'user' })).resolves.toMatchObject({ + agents: { 'claude-code': 'added' }, + }) + + expect(execaMock).toHaveBeenNthCalledWith(2, installed('claude'), ['mcp', 'get', 'netlify'], expect.anything()) + expect(execaMock).toHaveBeenNthCalledWith(3, installed('claude'), claudeAdd('user'), expect.anything()) + }) + + test('leaves a user-wide entry alone when the scope is user', async () => { + await install('claude') + execaMock + .mockResolvedValueOnce(listed(OURS_LINE)) + .mockResolvedValueOnce(exited(0, '', 'netlify:\n Scope: User config (available in all your projects)\n')) + + await expect(run({ NETLIFY_AGENT: 'claude-code' }, { scope: 'user' })).resolves.toMatchObject({ + agents: { 'claude-code': 'present' }, + }) + + expect(execaMock).toHaveBeenCalledTimes(2) + }) + + test('adds nothing user-wide when the scope of an existing entry cannot be read', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed(OURS_LINE)).mockResolvedValueOnce(exited(1, 'boom')) + + await expect(run({ NETLIFY_AGENT: 'claude-code' }, { scope: 'user' })).resolves.toMatchObject({ + agents: { 'claude-code': 'failed' }, + }) + + expect(execaMock).toHaveBeenCalledTimes(2) + }) + test('reads older claude mcp list output that has no health status', async () => { await install('claude') execaMock.mockResolvedValueOnce(listed(`netlify: ${NETLIFY_MCP_URL} (HTTP)`)) @@ -295,29 +380,65 @@ describe('setupAgentMcp', () => { expect(logged()).toContain(`claude ${claudeAdd('local').join(' ')}`) }) - test.skipIf(tempOnAnotherDrive)('never runs an agent binary found through a relative PATH entry', async () => { - await install('claude') - const relativeBin = relative(resolve(), bin) - expect(relativeBin).not.toBe(bin) + describe('with a PATH entry relative to the working directory', () => { + let cwdBin: string + + beforeEach(async () => { + cwdBin = await binUnderCwd() + const file = join(cwdBin, executableName('claude')) + await writeFile(file, '') + await chmod(file, 0o755) + }) + + afterEach(async () => { + await rm(cwdBin, { recursive: true, force: true }) + }) + + test('never runs an agent binary found through it', async () => { + const relativeBin = relative(resolve(), cwdBin) + expect(relativeBin).not.toBe(cwdBin) + + await expect(run({ NETLIFY_AGENT: 'claude-code' }, { path: relativeBin })).resolves.toMatchObject({ + agents: { 'claude-code': 'printed' }, + }) + + expect(execaMock).not.toHaveBeenCalled() + }) + + test('runs the agent binary from the same directory once it is absolute', async () => { + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) + + await run({ NETLIFY_AGENT: 'claude-code' }, { path: [relative(resolve(), cwdBin), cwdBin].join(delimiter) }) + + expect(execaMock).toHaveBeenNthCalledWith( + 1, + join(cwdBin, executableName('claude')), + ['mcp', 'list'], + expect.anything(), + ) + }) + }) + + test.skipIf(process.platform === 'win32')('never runs an agent binary that is not executable', async () => { + await writeFile(installed('claude'), '') + await chmod(installed('claude'), 0o644) - await expect(run({ NETLIFY_AGENT: 'claude-code' }, { path: relativeBin })).resolves.toMatchObject({ + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ agents: { 'claude-code': 'printed' }, }) expect(execaMock).not.toHaveBeenCalled() }) - test.skipIf(tempOnAnotherDrive)( - 'runs the agent binary from the same directory once it is an absolute PATH entry', - async () => { - await install('claude') - execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) + test('never runs a directory that has the agent binary name', async () => { + await mkdir(installed('claude')) - await run({ NETLIFY_AGENT: 'claude-code' }, { path: [relative(resolve(), bin), bin].join(delimiter) }) + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ + agents: { 'claude-code': 'printed' }, + }) - expect(execaMock).toHaveBeenNthCalledWith(1, installed('claude'), ['mcp', 'list'], expect.anything()) - }, - ) + expect(execaMock).not.toHaveBeenCalled() + }) test('respects a saved no from the prompt for this project', async () => { await install('claude') @@ -331,16 +452,26 @@ describe('setupAgentMcp', () => { expect(logged()).toContain('init --reset-mcp-setup') }) - test('--reset-mcp-setup ignores a saved no for the agent running init without changing it', async () => { + test('--reset-mcp-setup replaces a saved no once the agent running init has the server', async () => { await install('claude') - const declined = { url: NETLIFY_MCP_URL, choice: 'declined' } - saved.set('agentMcpSetup.project.claude-code', declined) + saved.set('agentMcpSetup.project.claude-code', { url: NETLIFY_MCP_URL, choice: 'declined' }) execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) await expect(run({ NETLIFY_AGENT: 'claude-code' }, { reset: true })).resolves.toMatchObject({ agents: { 'claude-code': 'added' }, }) + expect(saved.get('agentMcpSetup.project.claude-code')).toEqual({ url: NETLIFY_MCP_URL, choice: 'accepted' }) + }) + + test('--reset-mcp-setup keeps a saved no when the add fails for the agent running init', async () => { + await install('claude') + const declined = { url: NETLIFY_MCP_URL, choice: 'declined' } + saved.set('agentMcpSetup.project.claude-code', declined) + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(1, 'boom')) + + await run({ NETLIFY_AGENT: 'claude-code' }, { reset: true }) + expect(saved.get('agentMcpSetup.project.claude-code')).toEqual(declined) }) @@ -415,7 +546,9 @@ describe('setupAgentMcp', () => { ).resolves.toMatchObject({ agents: { vscode: 'printed' } }) expect(execaMock).not.toHaveBeenCalled() - expect(logged()).toContain('code --add-mcp') + const config = JSON.stringify({ name: 'netlify', type: 'http', url: NETLIFY_MCP_URL }) + const quoted = process.platform === 'win32' ? `"${config.replace(/"/g, '\\"')}"` : `'${config}'` + expect(logged()).toContain(`code --add-mcp ${quoted}`) expect(logged()).toContain('Running it replaces any') }) @@ -581,6 +714,19 @@ describe('setupAgentMcp', () => { expect(offeredIds(choose)).toEqual(['claude-code']) }) + test('forgets a saved no when the add fails after --reset-mcp-setup, so it is offered again', async () => { + await install('claude') + saved.set('agentMcpSetup.project.claude-code', { url: NETLIFY_MCP_URL, choice: 'declined' }) + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(1, 'boom')) + await run({}, { interactive: true, choose: chooseIds('claude-code'), reset: true }) + expect(saved.has('agentMcpSetup.project.claude-code')).toBe(false) + const choose = chooseIds() + + await run({}, { interactive: true, choose }) + + expect(offeredIds(choose)).toEqual(['claude-code']) + }) + test('an agent-driven run does not save anything', async () => { await install('claude') execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) @@ -618,21 +764,51 @@ describe('setupAgentMcp', () => { await expect(readFile(stateFile(), 'utf8')).rejects.toThrow(/ENOENT/) }) - test.each([ - ['the add succeeds', exited(0)], - ['the add fails', exited(1, 'boom')], - ])('an agent run with --reset-mcp-setup leaves the state file byte for byte when %s', async (_, addResult) => { + test('an agent run with --reset-mcp-setup leaves the state file byte for byte when the add fails', async () => { await install('claude') const state = new LocalState(project) state.set('siteId', 'site_id') state.set('agentMcpSetup.project.claude-code', { url: NETLIFY_MCP_URL, choice: 'declined' }) const before = await readFile(stateFile(), 'utf8') - execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(addResult) + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(1, 'boom')) await run({ NETLIFY_AGENT: 'claude-code' }, { reset: true, store: new LocalState(project) }) await expect(readFile(stateFile(), 'utf8')).resolves.toBe(before) }) + + test('an agent run with --reset-mcp-setup changes only the saved no once the add succeeds', async () => { + await install('claude') + const state = new LocalState(project) + state.set('siteId', 'site_id') + state.set('agentMcpSetup.project.claude-code', { url: NETLIFY_MCP_URL, choice: 'declined' }) + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) + + await run({ NETLIFY_AGENT: 'claude-code' }, { reset: true, store: new LocalState(project) }) + + await expect(readFile(stateFile(), 'utf8').then(JSON.parse)).resolves.toEqual({ + siteId: 'site_id', + agentMcpSetup: { project: { 'claude-code': { url: NETLIFY_MCP_URL, choice: 'accepted' } } }, + }) + }) + + test('forgets a saved no through the real state file when an add fails after --reset-mcp-setup', async () => { + await install('claude') + const state = new LocalState(project) + state.set('siteId', 'site_id') + state.set('agentMcpSetup.project.claude-code', { url: NETLIFY_MCP_URL, choice: 'declined' }) + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(1, 'boom')) + + await run( + {}, + { interactive: true, choose: chooseIds('claude-code'), reset: true, store: new LocalState(project) }, + ) + + await expect(readFile(stateFile(), 'utf8').then(JSON.parse)).resolves.toEqual({ + siteId: 'site_id', + agentMcpSetup: { project: {} }, + }) + }) }) describe('findCandidates', () => { From cdde3f918a62d6852e67ae7ae39cc47c7e96fed1 Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Mon, 26 Oct 2026 11:16:01 -0400 Subject: [PATCH 17/19] fix(init): check every Netlify entry at user scope Follow-up from review: a user-wide server under another name now counts, failure output is printed without control characters, and the skip hint names what was skipped. --- src/commands/init/init.ts | 4 ++- src/utils/init/agent-mcp.ts | 35 +++++++++++++++++-------- tests/unit/utils/init/agent-mcp.test.ts | 20 ++++++++++++++ 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index 09c7dc28de9..c002a46dec3 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -269,7 +269,9 @@ const setUpAgents = async ( .filter(Boolean) .join(' and ') log(`Skipped setting up ${skipped}: ${blocker.reason}.`) - log(` Run ${chalk.cyanBright.bold(`${netlifyCommand()} init`)} from your project folder to set them up there.`) + log( + ` Run ${chalk.cyanBright.bold(`${netlifyCommand()} init`)} from your project folder to set up ${skipped} there.`, + ) await track('sites_agentSetupSkipped', { skills: skillsBlocked, mcp: mcpBlocked }) } if (skills && !skillsBlocked) { diff --git a/src/utils/init/agent-mcp.ts b/src/utils/init/agent-mcp.ts index 71f2838adb5..5ca46dfc920 100644 --- a/src/utils/init/agent-mcp.ts +++ b/src/utils/init/agent-mcp.ts @@ -313,7 +313,7 @@ const runNative = async (executable: string, args: string[], cwd: string, timeou type NativeRun = Awaited> const describeFailure = ({ timedOut, output, exitCode }: NativeRun): string => - timedOut ? 'timed out' : output || `exit code ${String(exitCode)}` + timedOut ? 'timed out' : printable(output) || `exit code ${String(exitCode)}` type Existing = { found: 'ours' | 'other'; name: string } type Lookup = Existing | { found: 'none' } | { found: 'unknown'; run: NativeRun } @@ -372,15 +372,28 @@ const classify = (servers: { name: string; target: string }[]): Lookup => { const isExisting = (lookup: Lookup): lookup is Existing => lookup.found === 'ours' || lookup.found === 'other' -const lookUpAtUserScope = async (executable: string, lookup: Lookup, workingDir: string): Promise => { - if (!isExisting(lookup) || lookup.name.startsWith('plugin:')) { - return lookup - } - const details = await runNative(executable, ['mcp', 'get', lookup.name], workingDir, LIST_TIMEOUT_MS) - if (details.exitCode !== 0) { - return { found: 'unknown', run: details } +const lookUpAtUserScope = async ( + executable: string, + servers: { name: string; target: string }[], + workingDir: string, +): Promise => { + const netlifyServers = servers + .map((server) => ({ server, lookup: classify([server]) })) + .filter(({ lookup }) => isExisting(lookup)) + .sort((a, b) => Number(b.lookup.found === 'ours') - Number(a.lookup.found === 'ours')) + for (const { server, lookup } of netlifyServers) { + if (server.name.startsWith('plugin:')) { + return lookup + } + const details = await runNative(executable, ['mcp', 'get', server.name], workingDir, LIST_TIMEOUT_MS) + if (details.exitCode !== 0) { + return { found: 'unknown', run: details } + } + if (USER_SCOPE_DETAIL.test(details.stdout)) { + return lookup + } } - return USER_SCOPE_DETAIL.test(details.stdout) ? lookup : { found: 'none' } + return { found: 'none' } } const lookUp = async (executable: string, kind: LookupKind, workingDir: string, scope: McpScope): Promise => { @@ -393,8 +406,8 @@ const lookUp = async (executable: string, kind: LookupKind, workingDir: string, if (run.exitCode !== 0) { return { found: 'unknown', run } } - const lookup = classify(listedServers(run.stdout)) - return scope === 'user' ? await lookUpAtUserScope(executable, lookup, workingDir) : lookup + const servers = listedServers(run.stdout) + return scope === 'user' ? await lookUpAtUserScope(executable, servers, workingDir) : classify(servers) } const reportExisting = (agent: McpAgent, { found, name }: Existing): AgentMcpStatus => { diff --git a/tests/unit/utils/init/agent-mcp.test.ts b/tests/unit/utils/init/agent-mcp.test.ts index a7ba93c57bf..1dbddbf7bf4 100644 --- a/tests/unit/utils/init/agent-mcp.test.ts +++ b/tests/unit/utils/init/agent-mcp.test.ts @@ -319,6 +319,26 @@ describe('setupAgentMcp', () => { expect(execaMock).toHaveBeenCalledTimes(2) }) + test('leaves a user-wide Netlify server under another name alone when ours is only set for this project', async () => { + await install('claude') + execaMock + .mockResolvedValueOnce(listed(OURS_LINE, 'netlify-stdio: npx -y @netlify/mcp - ✔ Connected')) + .mockResolvedValueOnce(exited(0, '', 'netlify:\n Scope: Local config (private to you in this project)\n')) + .mockResolvedValueOnce(exited(0, '', 'netlify-stdio:\n Scope: User config (available in all your projects)\n')) + + await expect(run({ NETLIFY_AGENT: 'claude-code' }, { scope: 'user' })).resolves.toMatchObject({ + agents: { 'claude-code': 'kept' }, + }) + + expect(execaMock).toHaveBeenNthCalledWith( + 3, + installed('claude'), + ['mcp', 'get', 'netlify-stdio'], + expect.anything(), + ) + expect(execaMock).toHaveBeenCalledTimes(3) + }) + test('adds nothing user-wide when the scope of an existing entry cannot be read', async () => { await install('claude') execaMock.mockResolvedValueOnce(listed(OURS_LINE)).mockResolvedValueOnce(exited(1, 'boom')) From 49efae0aededc1364f63b44d0f922e00a111cab7 Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Tue, 27 Oct 2026 01:46:02 -0400 Subject: [PATCH 18/19] refactor(init): drop unrequested MCP edge cases Check only the matched entry's scope at user scope, print agent errors as-is, and drop two tests that repeat the in-memory reset checks. --- src/utils/init/agent-mcp.ts | 35 +++++----------- tests/unit/utils/init/agent-mcp.test.ts | 53 ------------------------- 2 files changed, 11 insertions(+), 77 deletions(-) diff --git a/src/utils/init/agent-mcp.ts b/src/utils/init/agent-mcp.ts index 5ca46dfc920..71f2838adb5 100644 --- a/src/utils/init/agent-mcp.ts +++ b/src/utils/init/agent-mcp.ts @@ -313,7 +313,7 @@ const runNative = async (executable: string, args: string[], cwd: string, timeou type NativeRun = Awaited> const describeFailure = ({ timedOut, output, exitCode }: NativeRun): string => - timedOut ? 'timed out' : printable(output) || `exit code ${String(exitCode)}` + timedOut ? 'timed out' : output || `exit code ${String(exitCode)}` type Existing = { found: 'ours' | 'other'; name: string } type Lookup = Existing | { found: 'none' } | { found: 'unknown'; run: NativeRun } @@ -372,28 +372,15 @@ const classify = (servers: { name: string; target: string }[]): Lookup => { const isExisting = (lookup: Lookup): lookup is Existing => lookup.found === 'ours' || lookup.found === 'other' -const lookUpAtUserScope = async ( - executable: string, - servers: { name: string; target: string }[], - workingDir: string, -): Promise => { - const netlifyServers = servers - .map((server) => ({ server, lookup: classify([server]) })) - .filter(({ lookup }) => isExisting(lookup)) - .sort((a, b) => Number(b.lookup.found === 'ours') - Number(a.lookup.found === 'ours')) - for (const { server, lookup } of netlifyServers) { - if (server.name.startsWith('plugin:')) { - return lookup - } - const details = await runNative(executable, ['mcp', 'get', server.name], workingDir, LIST_TIMEOUT_MS) - if (details.exitCode !== 0) { - return { found: 'unknown', run: details } - } - if (USER_SCOPE_DETAIL.test(details.stdout)) { - return lookup - } +const lookUpAtUserScope = async (executable: string, lookup: Lookup, workingDir: string): Promise => { + if (!isExisting(lookup) || lookup.name.startsWith('plugin:')) { + return lookup + } + const details = await runNative(executable, ['mcp', 'get', lookup.name], workingDir, LIST_TIMEOUT_MS) + if (details.exitCode !== 0) { + return { found: 'unknown', run: details } } - return { found: 'none' } + return USER_SCOPE_DETAIL.test(details.stdout) ? lookup : { found: 'none' } } const lookUp = async (executable: string, kind: LookupKind, workingDir: string, scope: McpScope): Promise => { @@ -406,8 +393,8 @@ const lookUp = async (executable: string, kind: LookupKind, workingDir: string, if (run.exitCode !== 0) { return { found: 'unknown', run } } - const servers = listedServers(run.stdout) - return scope === 'user' ? await lookUpAtUserScope(executable, servers, workingDir) : classify(servers) + const lookup = classify(listedServers(run.stdout)) + return scope === 'user' ? await lookUpAtUserScope(executable, lookup, workingDir) : lookup } const reportExisting = (agent: McpAgent, { found, name }: Existing): AgentMcpStatus => { diff --git a/tests/unit/utils/init/agent-mcp.test.ts b/tests/unit/utils/init/agent-mcp.test.ts index 1dbddbf7bf4..0dcaca41fcd 100644 --- a/tests/unit/utils/init/agent-mcp.test.ts +++ b/tests/unit/utils/init/agent-mcp.test.ts @@ -319,26 +319,6 @@ describe('setupAgentMcp', () => { expect(execaMock).toHaveBeenCalledTimes(2) }) - test('leaves a user-wide Netlify server under another name alone when ours is only set for this project', async () => { - await install('claude') - execaMock - .mockResolvedValueOnce(listed(OURS_LINE, 'netlify-stdio: npx -y @netlify/mcp - ✔ Connected')) - .mockResolvedValueOnce(exited(0, '', 'netlify:\n Scope: Local config (private to you in this project)\n')) - .mockResolvedValueOnce(exited(0, '', 'netlify-stdio:\n Scope: User config (available in all your projects)\n')) - - await expect(run({ NETLIFY_AGENT: 'claude-code' }, { scope: 'user' })).resolves.toMatchObject({ - agents: { 'claude-code': 'kept' }, - }) - - expect(execaMock).toHaveBeenNthCalledWith( - 3, - installed('claude'), - ['mcp', 'get', 'netlify-stdio'], - expect.anything(), - ) - expect(execaMock).toHaveBeenCalledTimes(3) - }) - test('adds nothing user-wide when the scope of an existing entry cannot be read', async () => { await install('claude') execaMock.mockResolvedValueOnce(listed(OURS_LINE)).mockResolvedValueOnce(exited(1, 'boom')) @@ -796,39 +776,6 @@ describe('setupAgentMcp', () => { await expect(readFile(stateFile(), 'utf8')).resolves.toBe(before) }) - - test('an agent run with --reset-mcp-setup changes only the saved no once the add succeeds', async () => { - await install('claude') - const state = new LocalState(project) - state.set('siteId', 'site_id') - state.set('agentMcpSetup.project.claude-code', { url: NETLIFY_MCP_URL, choice: 'declined' }) - execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) - - await run({ NETLIFY_AGENT: 'claude-code' }, { reset: true, store: new LocalState(project) }) - - await expect(readFile(stateFile(), 'utf8').then(JSON.parse)).resolves.toEqual({ - siteId: 'site_id', - agentMcpSetup: { project: { 'claude-code': { url: NETLIFY_MCP_URL, choice: 'accepted' } } }, - }) - }) - - test('forgets a saved no through the real state file when an add fails after --reset-mcp-setup', async () => { - await install('claude') - const state = new LocalState(project) - state.set('siteId', 'site_id') - state.set('agentMcpSetup.project.claude-code', { url: NETLIFY_MCP_URL, choice: 'declined' }) - execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(1, 'boom')) - - await run( - {}, - { interactive: true, choose: chooseIds('claude-code'), reset: true, store: new LocalState(project) }, - ) - - await expect(readFile(stateFile(), 'utf8').then(JSON.parse)).resolves.toEqual({ - siteId: 'site_id', - agentMcpSetup: { project: {} }, - }) - }) }) describe('findCandidates', () => { From 8613c1a2d8286242f146c8bf0320e6d14582fa9f Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Tue, 27 Oct 2026 12:15:49 -0400 Subject: [PATCH 19/19] fix(init): address MCP setup review round five Check Codex from the home folder at user scope so project config doesn't count, keep a saved no unless the server is installed, filter agent error output, and check the add's working folder. --- src/utils/init/agent-mcp.ts | 8 +++++--- tests/unit/utils/init/agent-mcp.test.ts | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/utils/init/agent-mcp.ts b/src/utils/init/agent-mcp.ts index 71f2838adb5..76803744ade 100644 --- a/src/utils/init/agent-mcp.ts +++ b/src/utils/init/agent-mcp.ts @@ -1,4 +1,5 @@ import { promises as fs } from 'node:fs' +import { homedir } from 'node:os' import path from 'node:path' import { checkbox } from '@inquirer/prompts' @@ -313,7 +314,7 @@ const runNative = async (executable: string, args: string[], cwd: string, timeou type NativeRun = Awaited> const describeFailure = ({ timedOut, output, exitCode }: NativeRun): string => - timedOut ? 'timed out' : output || `exit code ${String(exitCode)}` + timedOut ? 'timed out' : output.split('\n').map(printable).join('\n') || `exit code ${String(exitCode)}` type Existing = { found: 'ours' | 'other'; name: string } type Lookup = Existing | { found: 'none' } | { found: 'unknown'; run: NativeRun } @@ -385,7 +386,7 @@ const lookUpAtUserScope = async (executable: string, lookup: Lookup, workingDir: const lookUp = async (executable: string, kind: LookupKind, workingDir: string, scope: McpScope): Promise => { if (kind === 'list-json') { - const run = await runNative(executable, ['mcp', 'list', '--json'], workingDir) + const run = await runNative(executable, ['mcp', 'list', '--json'], scope === 'user' ? homedir() : workingDir) const servers = run.exitCode === 0 ? codexServers(run.stdout) : undefined return servers ? classify(servers) : { found: 'unknown', run } } @@ -637,7 +638,8 @@ export const setupAgentMcp = async ({ } else { const status = await add(agent, scope, env, workingDir) summary.agents[agent.id] = status - if (reset && status !== 'failed' && status !== 'unsupported' && savedChoice(store, agent, scope)) { + const installed = status === 'added' || status === 'present' || status === 'kept' + if (reset && installed && savedChoice(store, agent, scope)) { saveChoice(store, agent, scope, status) } } diff --git a/tests/unit/utils/init/agent-mcp.test.ts b/tests/unit/utils/init/agent-mcp.test.ts index 0dcaca41fcd..5aa8be4fb62 100644 --- a/tests/unit/utils/init/agent-mcp.test.ts +++ b/tests/unit/utils/init/agent-mcp.test.ts @@ -1,5 +1,5 @@ import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' +import { homedir, tmpdir } from 'node:os' import { delimiter, join, relative, resolve } from 'node:path' import { LocalState } from '@netlify/dev-utils' @@ -145,7 +145,12 @@ describe('setupAgentMcp', () => { ['mcp', 'list'], expect.objectContaining({ cwd: project, reject: false, stdin: 'ignore' }), ) - expect(execaMock).toHaveBeenNthCalledWith(2, installed('claude'), claudeAdd('local'), expect.anything()) + expect(execaMock).toHaveBeenNthCalledWith( + 2, + installed('claude'), + claudeAdd('local'), + expect.objectContaining({ cwd: project }), + ) expect(logged()).toContain("Saved in ~/.claude.json, under this project's path") expect(logged()).toContain('claude mcp remove --scope local netlify') expect(logged()).toContain('loads the next time you start Claude Code in this folder') @@ -189,6 +194,12 @@ describe('setupAgentMcp', () => { await expect(run({ CODEX_CI: '1' }, { scope: 'user' })).resolves.toMatchObject({ agents: { codex: 'added' } }) + expect(execaMock).toHaveBeenNthCalledWith( + 1, + installed('codex'), + ['mcp', 'list', '--json'], + expect.objectContaining({ cwd: homedir() }), + ) expect(execaMock).toHaveBeenNthCalledWith( 2, installed('codex'),