Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-configure-source-of-truth.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/@sanity/cli/src/actions/init/initAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,45 @@ 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'})]
await promptForMCPSetup(editors)

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'}],
}),
)
})
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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')
})
})
Original file line number Diff line number Diff line change
@@ -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<typeof fs>()
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<Editor> & {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()
})
})
Loading
Loading