From ce008cee5bd90efb8e127ff139ff1767c32a144e Mon Sep 17 00:00:00 2001 From: Rune Botten Date: Fri, 29 May 2026 17:36:02 +0200 Subject: [PATCH] fix(mcp): show all editors and treat selection as desired state Closes AIGRO-4989 --- .changeset/mcp-configure-source-of-truth.md | 5 + .../cli/src/actions/init/initAction.ts | 1 + .../mcp/__tests__/promptForMCPSetup.test.ts | 40 ++++- .../mcp/__tests__/removeMCPConfig.test.ts | 156 ++++++++++++++++++ .../actions/mcp/__tests__/setupMCP.test.ts | 129 ++++++++++++++- .../cli/src/actions/mcp/promptForMCPSetup.ts | 22 +-- .../cli/src/actions/mcp/removeMCPConfig.ts | 49 ++++++ .../@sanity/cli/src/actions/mcp/setupMCP.ts | 130 ++++++++------- .../cli/src/actions/mcp/writeMCPConfig.ts | 62 +++++-- .../init/init.create-new-project.test.ts | 2 + .../commands/mcp/__tests__/configure.test.ts | 23 ++- .../@sanity/cli/src/commands/mcp/configure.ts | 1 + .../cli/src/telemetry/init.telemetry.ts | 1 + .../cli/src/telemetry/mcp.telemetry.ts | 1 + 14 files changed, 518 insertions(+), 104 deletions(-) create mode 100644 .changeset/mcp-configure-source-of-truth.md create mode 100644 packages/@sanity/cli/src/actions/mcp/__tests__/removeMCPConfig.test.ts create mode 100644 packages/@sanity/cli/src/actions/mcp/removeMCPConfig.ts diff --git a/.changeset/mcp-configure-source-of-truth.md b/.changeset/mcp-configure-source-of-truth.md new file mode 100644 index 000000000..e2cb10c4d --- /dev/null +++ b/.changeset/mcp-configure-source-of-truth.md @@ -0,0 +1,5 @@ +--- +"@sanity/cli": patch +--- + +`sanity mcp configure` now lists every detected editor pre-selected by its current state; selecting an editor configures it and deselecting one removes its Sanity MCP entry. diff --git a/packages/@sanity/cli/src/actions/init/initAction.ts b/packages/@sanity/cli/src/actions/init/initAction.ts index a1fd41b74..a686263af 100644 --- a/packages/@sanity/cli/src/actions/init/initAction.ts +++ b/packages/@sanity/cli/src/actions/init/initAction.ts @@ -192,6 +192,7 @@ export async function initAction(options: InitOptions, context: InitContext): Pr trace.log({ configuredEditors: mcpResult.configuredEditors, detectedEditors: mcpResult.detectedEditors, + removedEditors: mcpResult.removedEditors, skipped: mcpResult.skipped, step: 'mcpSetup', }) diff --git a/packages/@sanity/cli/src/actions/mcp/__tests__/promptForMCPSetup.test.ts b/packages/@sanity/cli/src/actions/mcp/__tests__/promptForMCPSetup.test.ts index 5df006769..e522d93f4 100644 --- a/packages/@sanity/cli/src/actions/mcp/__tests__/promptForMCPSetup.test.ts +++ b/packages/@sanity/cli/src/actions/mcp/__tests__/promptForMCPSetup.test.ts @@ -23,7 +23,7 @@ describe('promptForMCPSetup', () => { vi.clearAllMocks() }) - test('labels unconfigured editors with plain name', async () => { + test('leaves unconfigured editors unchecked with plain name', async () => { mockCheckbox.mockResolvedValue(['Cursor']) const editors = [makeEditor({name: 'Cursor'})] @@ -31,7 +31,37 @@ describe('promptForMCPSetup', () => { expect(mockCheckbox).toHaveBeenCalledWith( expect.objectContaining({ - choices: [{checked: true, name: 'Cursor', value: 'Cursor'}], + choices: [{checked: false, name: 'Cursor', value: 'Cursor'}], + }), + ) + }) + + test('pre-checks configured editors with valid credentials', async () => { + mockCheckbox.mockResolvedValue(['Cursor']) + + const editors = [ + makeEditor({authStatus: 'valid', configured: true, existingToken: 'tok', name: 'Cursor'}), + ] + await promptForMCPSetup(editors) + + expect(mockCheckbox).toHaveBeenCalledWith( + expect.objectContaining({ + choices: [{checked: true, name: 'Cursor (configured)', value: 'Cursor'}], + }), + ) + }) + + test('labels an oauthOnly configured editor (no token) as "(configured)"', async () => { + mockCheckbox.mockResolvedValue(['Cursor']) + + // oauthOnly editors (Cursor, Claude Code) come back configured + valid with + // no existingToken — they must NOT be labelled "(missing credentials)". + const editors = [makeEditor({authStatus: 'valid', configured: true, name: 'Cursor'})] + await promptForMCPSetup(editors) + + expect(mockCheckbox).toHaveBeenCalledWith( + expect.objectContaining({ + choices: [{checked: true, name: 'Cursor (configured)', value: 'Cursor'}], }), ) }) @@ -69,13 +99,13 @@ describe('promptForMCPSetup', () => { ) }) - test('returns null when user deselects all editors', async () => { + test('returns an empty array when user deselects all editors', async () => { mockCheckbox.mockResolvedValue([]) const editors = [makeEditor({name: 'Cursor'})] const result = await promptForMCPSetup(editors) - expect(result).toBeNull() + expect(result).toEqual([]) }) test('returns only selected editors', async () => { @@ -85,6 +115,6 @@ describe('promptForMCPSetup', () => { const result = await promptForMCPSetup(editors) expect(result).toHaveLength(1) - expect(result![0].name).toBe('VS Code') + expect(result[0].name).toBe('VS Code') }) }) diff --git a/packages/@sanity/cli/src/actions/mcp/__tests__/removeMCPConfig.test.ts b/packages/@sanity/cli/src/actions/mcp/__tests__/removeMCPConfig.test.ts new file mode 100644 index 000000000..026e97f65 --- /dev/null +++ b/packages/@sanity/cli/src/actions/mcp/__tests__/removeMCPConfig.test.ts @@ -0,0 +1,156 @@ +import {existsSync} from 'node:fs' +import fs from 'node:fs/promises' + +import {afterEach, describe, expect, test, vi} from 'vitest' + +import {removeMCPConfig} from '../removeMCPConfig.js' +import {type Editor} from '../types.js' + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + default: { + readFile: vi.fn(), + writeFile: vi.fn(), + }, + } +}) + +const mockExistsSync = vi.mocked(existsSync) +const mockReadFile = vi.mocked(fs.readFile) +const mockWriteFile = vi.mocked(fs.writeFile) + +function makeEditor(overrides: Partial & {name: Editor['name']}): Editor { + return {configPath: `/home/user/.config/${overrides.name}`, configured: true, ...overrides} +} + +/** The content written to disk in the single expected writeFile call. */ +function writtenContent(): string { + expect(mockWriteFile).toHaveBeenCalledTimes(1) + return mockWriteFile.mock.calls[0]?.[1] as string +} + +describe('removeMCPConfig', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test('no-ops when the config file does not exist', async () => { + mockExistsSync.mockReturnValue(false) + + await removeMCPConfig(makeEditor({name: 'Cursor'})) + + expect(mockReadFile).not.toHaveBeenCalled() + expect(mockWriteFile).not.toHaveBeenCalled() + }) + + test('no-ops when the config file is empty or whitespace', async () => { + mockExistsSync.mockReturnValue(true) + mockReadFile.mockResolvedValue(' \n ') + + await removeMCPConfig(makeEditor({name: 'Cursor'})) + + expect(mockWriteFile).not.toHaveBeenCalled() + }) + + test('removes the Sanity entry from a JSONC config while preserving other servers', async () => { + mockExistsSync.mockReturnValue(true) + mockReadFile.mockResolvedValue( + JSON.stringify( + { + mcpServers: { + Other: {type: 'http', url: 'https://other.example'}, + Sanity: {type: 'http', url: 'https://mcp.sanity.io'}, + }, + }, + null, + 2, + ), + ) + + await removeMCPConfig(makeEditor({name: 'Cursor'})) + + const written = writtenContent() + expect(written).not.toContain('Sanity') + expect(written).toContain('Other') + expect(written).toContain('https://other.example') + }) + + test('preserves comments in a JSONC config when removing the Sanity entry', async () => { + mockExistsSync.mockReturnValue(true) + mockReadFile.mockResolvedValue( + `{ + // keep this comment + "mcpServers": { + "Other": {"type": "http", "url": "https://other.example"}, + "Sanity": {"type": "http", "url": "https://mcp.sanity.io"} + } +}`, + ) + + await removeMCPConfig(makeEditor({name: 'Cursor'})) + + const written = writtenContent() + expect(written).toContain('// keep this comment') + expect(written).not.toContain('Sanity') + }) + + test('no-ops when the JSONC config has no Sanity entry', async () => { + mockExistsSync.mockReturnValue(true) + mockReadFile.mockResolvedValue( + JSON.stringify({mcpServers: {Other: {type: 'http', url: 'https://other.example'}}}), + ) + + await removeMCPConfig(makeEditor({name: 'Cursor'})) + + expect(mockWriteFile).not.toHaveBeenCalled() + }) + + test('no-ops when the JSONC config has unrelated keys and no server map', async () => { + mockExistsSync.mockReturnValue(true) + mockReadFile.mockResolvedValue(JSON.stringify({somethingElse: true})) + + await removeMCPConfig(makeEditor({name: 'Cursor'})) + + expect(mockWriteFile).not.toHaveBeenCalled() + }) + + test('removes the Sanity entry from a TOML config while preserving other servers', async () => { + mockExistsSync.mockReturnValue(true) + mockReadFile.mockResolvedValue( + [ + '[mcp_servers.Other]', + 'type = "http"', + 'url = "https://other.example"', + '', + '[mcp_servers.Sanity]', + 'type = "http"', + 'url = "https://mcp.sanity.io"', + '', + ].join('\n'), + ) + + await removeMCPConfig(makeEditor({name: 'Codex CLI'})) + + const written = writtenContent() + expect(written).not.toContain('Sanity') + expect(written).toContain('Other') + expect(written).toContain('https://other.example') + }) + + test('no-ops when the TOML config has no Sanity entry', async () => { + mockExistsSync.mockReturnValue(true) + mockReadFile.mockResolvedValue( + ['[mcp_servers.Other]', 'type = "http"', 'url = "https://other.example"', ''].join('\n'), + ) + + await removeMCPConfig(makeEditor({name: 'Codex CLI'})) + + expect(mockWriteFile).not.toHaveBeenCalled() + }) +}) diff --git a/packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts b/packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts index 9ce131371..12ed4d758 100644 --- a/packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts +++ b/packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts @@ -7,6 +7,7 @@ const mockPromptForMCPSetup = vi.hoisted(() => vi.fn()) const mockValidateEditorTokens = vi.hoisted(() => vi.fn()) const mockCreateMCPToken = vi.hoisted(() => vi.fn()) const mockWriteMCPConfig = vi.hoisted(() => vi.fn()) +const mockRemoveMCPConfig = vi.hoisted(() => vi.fn()) vi.mock('../detectAvailableEditors.js', () => ({ detectAvailableEditors: mockDetectAvailableEditors, @@ -29,6 +30,10 @@ vi.mock('../writeMCPConfig.js', () => ({ writeMCPConfig: mockWriteMCPConfig, })) +vi.mock('../removeMCPConfig.js', () => ({ + removeMCPConfig: mockRemoveMCPConfig, +})) + describe('setupMCP', () => { afterEach(() => { vi.clearAllMocks() @@ -50,7 +55,7 @@ describe('setupMCP', () => { ]) mockValidateEditorTokens.mockResolvedValue(undefined) mockCreateMCPToken.mockResolvedValue('test-token') - mockWriteMCPConfig.mockResolvedValue(undefined) + mockWriteMCPConfig.mockResolvedValue(true) const result = await setupMCP({mode: 'auto'}) @@ -66,7 +71,7 @@ describe('setupMCP', () => { mockValidateEditorTokens.mockResolvedValue(undefined) mockPromptForMCPSetup.mockResolvedValue(editors) mockCreateMCPToken.mockResolvedValue('test-token') - mockWriteMCPConfig.mockResolvedValue(undefined) + mockWriteMCPConfig.mockResolvedValue(true) const result = await setupMCP({mode: 'prompt'}) @@ -81,7 +86,7 @@ describe('setupMCP', () => { mockValidateEditorTokens.mockResolvedValue(undefined) mockPromptForMCPSetup.mockResolvedValue(editors) mockCreateMCPToken.mockResolvedValue('test-token') - mockWriteMCPConfig.mockResolvedValue(undefined) + mockWriteMCPConfig.mockResolvedValue(true) await setupMCP() @@ -94,10 +99,126 @@ describe('setupMCP', () => { mockValidateEditorTokens.mockResolvedValue(undefined) mockPromptForMCPSetup.mockResolvedValue(editors) mockCreateMCPToken.mockResolvedValue('test-token') - mockWriteMCPConfig.mockResolvedValue(undefined) + mockWriteMCPConfig.mockResolvedValue(true) await setupMCP({explicit: true}) expect(mockPromptForMCPSetup).toHaveBeenCalledWith(editors) }) + + test('reports an editor whose config already matches as already-configured (no write needed)', async () => { + // VS Code is configured with a valid bearer token. writeMCPConfig returns + // false to signal a no-op — setupMCP should classify this as + // "alreadyConfigured" rather than "configured". + const vscode = { + authStatus: 'valid', + configured: true, + existingToken: 'valid-token', + name: 'VS Code', + } + mockDetectAvailableEditors.mockResolvedValue([vscode]) + mockValidateEditorTokens.mockResolvedValue(undefined) + mockPromptForMCPSetup.mockResolvedValue([vscode]) + mockWriteMCPConfig.mockResolvedValue(false) + + const result = await setupMCP({mode: 'prompt'}) + + expect(mockCreateMCPToken).not.toHaveBeenCalled() + expect(result.configuredEditors).toEqual([]) + expect(result.alreadyConfiguredEditors).toEqual(['VS Code']) + }) + + test('reports an editor whose config needed rewriting as configured', async () => { + // Existing config shape diverges from what writeMCPConfig would produce + // (e.g. oauthOnly toggled, token changed). writeMCPConfig returns true to + // signal it wrote. + const cursor = { + authStatus: 'valid', + configured: true, + existingToken: 'stale-bearer-token', + name: 'Cursor', + } + mockDetectAvailableEditors.mockResolvedValue([cursor]) + mockValidateEditorTokens.mockResolvedValue(undefined) + mockPromptForMCPSetup.mockResolvedValue([cursor]) + mockWriteMCPConfig.mockResolvedValue(true) + + const result = await setupMCP({mode: 'prompt'}) + + expect(mockWriteMCPConfig).toHaveBeenCalled() + expect(result.configuredEditors).toEqual(['Cursor']) + expect(result.alreadyConfiguredEditors).toEqual([]) + }) + + test('removes Sanity entry from configured editors that the user deselects', async () => { + const cursor = { + authStatus: 'valid', + configured: true, + existingToken: 'existing-token', + name: 'Cursor', + } + const vscode = {authStatus: 'unknown', configured: false, name: 'VS Code'} + mockDetectAvailableEditors.mockResolvedValue([cursor, vscode]) + mockValidateEditorTokens.mockResolvedValue(undefined) + // User keeps VS Code, drops Cursor + mockPromptForMCPSetup.mockResolvedValue([vscode]) + mockWriteMCPConfig.mockResolvedValue(true) + mockRemoveMCPConfig.mockResolvedValue(undefined) + + const result = await setupMCP({mode: 'prompt'}) + + // Reuses Cursor's still-valid token for the new VS Code write + expect(mockCreateMCPToken).not.toHaveBeenCalled() + expect(mockWriteMCPConfig).toHaveBeenCalledWith(vscode, 'existing-token') + expect(mockRemoveMCPConfig).toHaveBeenCalledWith(cursor) + expect(result.configuredEditors).toEqual(['VS Code']) + expect(result.removedEditors).toEqual(['Cursor']) + }) + + test('continues writing other editors when one write fails, reporting the first error', async () => { + const cursor = {authStatus: 'unknown', configured: false, name: 'Cursor'} + const vscode = {authStatus: 'unknown', configured: false, name: 'VS Code'} + mockDetectAvailableEditors.mockResolvedValue([cursor, vscode]) + mockValidateEditorTokens.mockResolvedValue(undefined) + mockPromptForMCPSetup.mockResolvedValue([cursor, vscode]) + mockCreateMCPToken.mockResolvedValue('test-token') + // Cursor fails, VS Code succeeds — the loop must not abort on the first failure. + mockWriteMCPConfig + .mockRejectedValueOnce(new Error('cursor write failed')) + .mockResolvedValueOnce(true) + + const result = await setupMCP({mode: 'prompt'}) + + expect(mockWriteMCPConfig).toHaveBeenCalledTimes(2) + expect(result.configuredEditors).toEqual(['VS Code']) + expect(result.error).toBeInstanceOf(Error) + expect(result.error?.message).toBe('cursor write failed') + expect(result.skipped).toBe(false) + }) + + test('reports an error and skips the editor when removal fails, still processing writes', async () => { + const cursor = { + authStatus: 'valid', + configured: true, + existingToken: 'existing-token', + name: 'Cursor', + } + const vscode = {authStatus: 'unknown', configured: false, name: 'VS Code'} + mockDetectAvailableEditors.mockResolvedValue([cursor, vscode]) + mockValidateEditorTokens.mockResolvedValue(undefined) + // User keeps VS Code, drops the configured Cursor — removal of Cursor fails. + mockPromptForMCPSetup.mockResolvedValue([vscode]) + mockWriteMCPConfig.mockResolvedValue(true) + mockRemoveMCPConfig.mockRejectedValue(new Error('cursor remove failed')) + + const result = await setupMCP({mode: 'prompt'}) + + expect(mockWriteMCPConfig).toHaveBeenCalledWith(vscode, 'existing-token') + expect(mockRemoveMCPConfig).toHaveBeenCalledWith(cursor) + expect(result.configuredEditors).toEqual(['VS Code']) + // Removal failed, so Cursor must not be reported as removed. + expect(result.removedEditors).toEqual([]) + expect(result.error).toBeInstanceOf(Error) + expect(result.error?.message).toBe('cursor remove failed') + }) }) diff --git a/packages/@sanity/cli/src/actions/mcp/promptForMCPSetup.ts b/packages/@sanity/cli/src/actions/mcp/promptForMCPSetup.ts index f01aae84a..7bfc56805 100644 --- a/packages/@sanity/cli/src/actions/mcp/promptForMCPSetup.ts +++ b/packages/@sanity/cli/src/actions/mcp/promptForMCPSetup.ts @@ -6,21 +6,28 @@ function getEditorLabel(editor: Editor): string { if (editor.configured && editor.authStatus === 'unauthorized') { return `${editor.name} (auth expired)` } + if (editor.configured && editor.authStatus === 'valid') { + return `${editor.name} (configured)` + } if (editor.configured && !editor.existingToken) { return `${editor.name} (missing credentials)` } + if (editor.configured) { + return `${editor.name} (configured)` + } return editor.name } /** - * Prompt user to select which editors to configure. + * Prompt the user to choose which editors should have a Sanity MCP entry. * - * Expects only actionable editors (unconfigured, or configured with - * invalid/missing credentials). Annotates entries with auth status. + * The returned array is the desired final state: editors selected here should + * end up configured, editors that were previously configured but are not + * selected here should have their Sanity entry removed. */ -export async function promptForMCPSetup(editors: Editor[]): Promise { +export async function promptForMCPSetup(editors: Editor[]): Promise { const editorChoices = editors.map((e) => ({ - checked: true, // Pre-select all actionable editors + checked: e.configured, name: getEditorLabel(e), value: e.name, })) @@ -30,10 +37,5 @@ export async function promptForMCPSetup(editors: Editor[]): Promise selectedNames.includes(e.name)) } diff --git a/packages/@sanity/cli/src/actions/mcp/removeMCPConfig.ts b/packages/@sanity/cli/src/actions/mcp/removeMCPConfig.ts new file mode 100644 index 000000000..8c29bf10e --- /dev/null +++ b/packages/@sanity/cli/src/actions/mcp/removeMCPConfig.ts @@ -0,0 +1,49 @@ +import {existsSync} from 'node:fs' +import fs from 'node:fs/promises' + +import {applyEdits, modify, parse as parseJsonc} from 'jsonc-parser' +import {parse as parseToml, stringify as stringifyToml} from 'smol-toml' + +import {EDITOR_CONFIGS} from './editorConfigs.js' +import {type Editor} from './types.js' + +/** + * Remove the Sanity MCP server entry from an editor's config file. + * Other entries are preserved. + */ +export async function removeMCPConfig(editor: Editor): Promise { + const {configKey, format} = EDITOR_CONFIGS[editor.name] + const {configPath} = editor + + if (!existsSync(configPath)) return + + const content = await fs.readFile(configPath, 'utf8') + if (!content.trim()) return + + let updated: string + if (format === 'toml') { + const config = parseToml(content) as Record + const servers = config[configKey] + if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return + const {Sanity: _removed, ...rest} = servers as Record + if (_removed === undefined) return + config[configKey] = rest + updated = stringifyToml(config) + } else { + // jsonc-parser's `modify` throws when asked to delete a path whose parent + // doesn't exist, so confirm the Sanity entry is actually present first — + // keeps this a safe no-op when the config has no (or a different) server map. + const parsed = parseJsonc(content) as Record | null + const servers = parsed?.[configKey] + if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return + if ((servers as Record).Sanity === undefined) return + + const edits = modify(content, [configKey, 'Sanity'], undefined, { + formattingOptions: {insertSpaces: true, tabSize: 2}, + }) + if (edits.length === 0) return + updated = applyEdits(content, edits) + } + + await fs.writeFile(configPath, updated, 'utf8') +} diff --git a/packages/@sanity/cli/src/actions/mcp/setupMCP.ts b/packages/@sanity/cli/src/actions/mcp/setupMCP.ts index 28bf6372a..1a26018f8 100644 --- a/packages/@sanity/cli/src/actions/mcp/setupMCP.ts +++ b/packages/@sanity/cli/src/actions/mcp/setupMCP.ts @@ -3,9 +3,11 @@ import {subdebug} from '@sanity/cli-core' import {logSymbols} from '@sanity/cli-core/ux' import {createMCPToken, MCP_SERVER_URL} from '../../services/mcp.js' +import {toError} from '../../util/getErrorMessage.js' import {detectAvailableEditors} from './detectAvailableEditors.js' import {EDITOR_CONFIGS, type EditorName} from './editorConfigs.js' import {promptForMCPSetup} from './promptForMCPSetup.js' +import {removeMCPConfig} from './removeMCPConfig.js' import {validateEditorTokens} from './validateEditorTokens.js' import {writeMCPConfig} from './writeMCPConfig.js' @@ -35,6 +37,7 @@ interface MCPSetupResult { alreadyConfiguredEditors: EditorName[] configuredEditors: EditorName[] detectedEditors: EditorName[] + removedEditors: EditorName[] skipped: boolean error?: Error @@ -47,133 +50,132 @@ interface MCPSetupResult { export async function setupMCP(options?: MCPSetupOptions): Promise { const {explicit = false, mode = 'prompt'} = options ?? {} - // 1. Check for explicit opt-out if (mode === 'skip') { mcpDebug('Skipping MCP configuration (mode: skip)') return { alreadyConfiguredEditors: [], configuredEditors: [], detectedEditors: [], + removedEditors: [], skipped: true, } } - // 2. Detect available editors (filters out unparseable configs) const editors = await detectAvailableEditors() const detectedEditors = editors.map((e) => e.name) mcpDebug('Detected %d editors: %s', detectedEditors.length, detectedEditors) if (editors.length === 0) { - if (explicit) { - ux.warn(NO_EDITORS_DETECTED_MESSAGE) - } + if (explicit) ux.warn(NO_EDITORS_DETECTED_MESSAGE) return { alreadyConfiguredEditors: [], configuredEditors: [], detectedEditors, + removedEditors: [], skipped: true, } } - // 3. Validate existing tokens against the Sanity API await validateEditorTokens(editors) - // 4. Check if there's anything actionable - const actionable = editors.filter((e) => !e.configured || e.authStatus !== 'valid') - - if (actionable.length === 0) { - mcpDebug('All editors configured with valid credentials') - const alreadyConfiguredEditors = editors - .filter((e) => e.configured && e.authStatus === 'valid') - .map((e) => e.name) - if (explicit) { - ux.stdout(`${logSymbols.success} All detected editors are already configured`) - } - return { - alreadyConfiguredEditors, - configuredEditors: [], - detectedEditors, - skipped: true, - } - } - - // Non-actionable editors are already configured with valid credentials - const alreadyConfiguredEditors = editors.filter((e) => !actionable.includes(e)).map((e) => e.name) + // The prompt shows every detected editor; what the user keeps checked is the + // desired final state. In auto mode, treat every editor as selected. + const selected = mode === 'auto' ? editors : await promptForMCPSetup(editors) + const selectedNames = new Set(selected.map((e) => e.name)) - // 5. Select editors to configure — prompt interactively or auto-select all - const selected = mode === 'auto' ? actionable : await promptForMCPSetup(actionable) + const editorsToRemove = editors.filter((e) => e.configured && !selectedNames.has(e.name)) - if (!selected || selected.length === 0) { - // User deselected all editors - ux.stdout('MCP configuration skipped') + if (selected.length === 0 && editorsToRemove.length === 0) { + if (explicit) ux.stdout('MCP configuration skipped') return { - alreadyConfiguredEditors, + alreadyConfiguredEditors: [], configuredEditors: [], detectedEditors, + removedEditors: [], skipped: true, } } - // 6. Get a token — reuse a valid existing one or create a new one let token: string | undefined + let firstError: Error | undefined - // Look for an existing valid token we can reuse - const validEditor = editors.find((e) => e.authStatus === 'valid' && e.existingToken) - if (validEditor?.existingToken) { - mcpDebug('Reusing valid token from %s', validEditor.name) - token = validEditor.existingToken + const reusable = editors.find((e) => e.authStatus === 'valid' && e.existingToken) + if (reusable?.existingToken) { + mcpDebug('Reusing valid token from %s', reusable.name) + token = reusable.existingToken } - - const allOAuth = selected.every((e) => EDITOR_CONFIGS[e.name].oauthOnly) - - // Fall back to creating a new token - // If all editors use OAuth, we don't need to create a token - if (!token && !allOAuth) { + const needsToken = selected.some((e) => !EDITOR_CONFIGS[e.name].oauthOnly) + if (!token && needsToken) { try { token = await createMCPToken() } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)) + const err = toError(error) mcpDebug('Error creating MCP token', error) ux.warn(`Could not configure MCP: ${err.message}`) ux.warn('You can set up MCP manually later using https://mcp.sanity.io') return { - alreadyConfiguredEditors, + alreadyConfiguredEditors: [], configuredEditors: [], detectedEditors, error: err, + removedEditors: [], skipped: false, } } } - // 7. Write configs for each selected editor const configuredEditors: EditorName[] = [] - try { - for (const editor of selected) { - await writeMCPConfig(editor, token) - configuredEditors.push(editor.name) + const alreadyConfiguredEditors: EditorName[] = [] + for (const editor of selected) { + try { + const wrote = await writeMCPConfig(editor, token) + if (wrote) configuredEditors.push(editor.name) + else alreadyConfiguredEditors.push(editor.name) + } catch (error) { + const err = toError(error) + mcpDebug('Error writing MCP config for %s: %s', editor.name, err) + ux.warn(`Could not configure MCP for ${editor.name}: ${err.message}`) + firstError ??= err } - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)) - mcpDebug('Error writing MCP config', error) - ux.warn(`Could not configure MCP: ${err.message}`) - ux.warn('You can set up MCP manually later using https://mcp.sanity.io') - return { - alreadyConfiguredEditors, - configuredEditors, - detectedEditors, - error: err, - skipped: false, + } + + const removedEditors: EditorName[] = [] + for (const editor of editorsToRemove) { + try { + await removeMCPConfig(editor) + removedEditors.push(editor.name) + } catch (error) { + const err = toError(error) + mcpDebug('Error removing MCP config for %s: %s', editor.name, err) + ux.warn(`Could not remove MCP configuration for ${editor.name}: ${err.message}`) + firstError ??= err } } - ux.stdout(`${logSymbols.success} MCP configured for ${configuredEditors.join(', ')}`) + if (configuredEditors.length > 0) { + ux.stdout(`${logSymbols.success} MCP configured for ${configuredEditors.join(', ')}`) + } + if (removedEditors.length > 0) { + ux.stdout(`${logSymbols.success} MCP removed from ${removedEditors.join(', ')}`) + } + if (firstError) { + ux.warn('Some editors could not be updated. See https://mcp.sanity.io for manual setup.') + } else if ( + explicit && + configuredEditors.length === 0 && + removedEditors.length === 0 && + alreadyConfiguredEditors.length > 0 + ) { + ux.stdout(`${logSymbols.success} All detected editors are already configured`) + } return { alreadyConfiguredEditors, configuredEditors, detectedEditors, + error: firstError, + removedEditors, skipped: false, } } diff --git a/packages/@sanity/cli/src/actions/mcp/writeMCPConfig.ts b/packages/@sanity/cli/src/actions/mcp/writeMCPConfig.ts index e5c749895..67ede9489 100644 --- a/packages/@sanity/cli/src/actions/mcp/writeMCPConfig.ts +++ b/packages/@sanity/cli/src/actions/mcp/writeMCPConfig.ts @@ -2,7 +2,7 @@ import {existsSync} from 'node:fs' import fs from 'node:fs/promises' import path from 'node:path' -import {applyEdits, modify} from 'jsonc-parser' +import {applyEdits, modify, parse as parseJsonc} from 'jsonc-parser' import {parse as parseToml, stringify as stringifyToml} from 'smol-toml' import {EDITOR_CONFIGS} from './editorConfigs.js' @@ -13,25 +13,59 @@ interface TomlConfig { } /** - * Write MCP configuration to editor config file - * Uses jsonc-parser's modify/applyEdits to preserve comments + * Stable JSON for structural comparison — sorts object keys recursively. * - * Note: Config parseability is already validated in detectAvailableEditors() + * Scope: only handles strings and plain nested objects. That is the entire + * shape produced by `buildServerConfig` across every editor in + * `EDITOR_CONFIGS` (type/url/headers/etc — no arrays, no null, no numbers). + * If a future server config grows new value types, extend this here. */ -export async function writeMCPConfig(editor: Editor, token?: string): Promise { +function canonical(value: unknown): string { + if (typeof value !== 'object' || value === null) return JSON.stringify(value) + const record = value as Record + const entries = Object.keys(record) + .toSorted() + .map((k) => `${JSON.stringify(k)}:${canonical(record[k])}`) + return `{${entries.join(',')}}` +} + +function readExistingSanityEntry( + content: string, + configKey: string, + format: 'jsonc' | 'toml', +): unknown { + if (!content.trim()) return undefined + try { + const parsed = format === 'toml' ? parseToml(content) : parseJsonc(content) + const servers = (parsed as Record | null)?.[configKey] + if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return undefined + return (servers as Record).Sanity + } catch { + return undefined + } +} + +/** + * Write MCP configuration to an editor's config file. Returns `true` if the + * file was written, `false` if the existing Sanity entry already matches and + * the write was skipped. + * + * Note: Config parseability is already validated in detectAvailableEditors(). + */ +export async function writeMCPConfig(editor: Editor, token?: string): Promise { const configPath = editor.configPath const {buildServerConfig, configKey, format, oauthOnly} = EDITOR_CONFIGS[editor.name] const serverConfig = oauthOnly ? buildServerConfig('') : buildServerConfig(token!) - // Read existing content or start with empty object/document - let content = format === 'toml' ? '' : '{}' - if (existsSync(configPath)) { - const fileContent = await fs.readFile(configPath, 'utf8') - if (fileContent.trim()) { - content = fileContent - } + const existingContent = existsSync(configPath) ? await fs.readFile(configPath, 'utf8') : '' + + const existingEntry = readExistingSanityEntry(existingContent, configKey, format) + if (existingEntry && canonical(existingEntry) === canonical(serverConfig)) { + return false } + let content = existingContent.trim() ? existingContent : format === 'toml' ? '' : '{}' + if (format === 'toml') { const tomlConfig = content.trim() ? (parseToml(content) as TomlConfig) : {} const existingServers = tomlConfig[configKey] @@ -43,15 +77,13 @@ export async function writeMCPConfig(editor: Editor, token?: string): Promise { alreadyConfiguredEditors: ['VS Code'], configuredEditors: [], detectedEditors: ['VS Code'], + removedEditors: [], skipped: true, }) @@ -401,6 +402,7 @@ describe('#init: create new project', () => { alreadyConfiguredEditors: ['VS Code', 'Cursor'], configuredEditors: [], detectedEditors: ['VS Code', 'Cursor'], + removedEditors: [], skipped: true, }) diff --git a/packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts b/packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts index b56375eeb..49f061b0d 100644 --- a/packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts +++ b/packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts @@ -497,11 +497,14 @@ describe('#mcp:configure', () => { // Token lifecycle // ------------------------------------------------------------------------- - test('skips prompt when all configured editors have valid auth', async () => { + test('rewrites an oauthOnly editor whose existing config still has a stale bearer header', async () => { mockExistsSync.mockImplementation((path: PathLike) => { return String(path).includes('.cursor') }) + // Cursor is oauthOnly today, but the existing config has a stale bearer + // header from a previous CLI version. The shape no longer matches what + // we'd write, so the file should be rewritten without the header. mockReadFile.mockResolvedValue( JSON.stringify({ mcpServers: { @@ -522,14 +525,19 @@ describe('#mcp:configure', () => { name: 'Test User', }) + mockCheckbox.mockResolvedValue(['Cursor']) + const {stdout} = await testCommand(ConfigureMcpCommand, []) - // Should NOT prompt the user — everything is already configured - expect(mockCheckbox).not.toHaveBeenCalled() - expect(stdout).toContain('All detected editors are already configured') + expect(mockCheckbox).toHaveBeenCalled() + expect(mockWriteFile).toHaveBeenCalledTimes(1) + const written = mockWriteFile.mock.calls[0]?.[1] as string + expect(written).not.toContain('Bearer') + expect(written).not.toContain('Authorization') + expect(stdout).toContain('MCP configured for Cursor') }) - test('skips prompt for oauthOnly editor configured without a bearer token', async () => { + test('leaves an oauthOnly configured editor untouched when user accepts the default selection', async () => { mockExistsSync.mockImplementation((path: PathLike) => { return String(path).includes('.cursor') }) @@ -548,9 +556,12 @@ describe('#mcp:configure', () => { // No /users/me mock — oauthOnly editors with no token skip validation entirely + mockCheckbox.mockResolvedValue(['Cursor']) + const {stdout} = await testCommand(ConfigureMcpCommand, []) - expect(mockCheckbox).not.toHaveBeenCalled() + expect(mockCheckbox).toHaveBeenCalled() + expect(mockWriteFile).not.toHaveBeenCalled() expect(stdout).toContain('All detected editors are already configured') }) diff --git a/packages/@sanity/cli/src/commands/mcp/configure.ts b/packages/@sanity/cli/src/commands/mcp/configure.ts index 41959cead..545b04622 100644 --- a/packages/@sanity/cli/src/commands/mcp/configure.ts +++ b/packages/@sanity/cli/src/commands/mcp/configure.ts @@ -45,6 +45,7 @@ export class ConfigureMcpCommand extends SanityCommand({