Skip to content

Commit e11f495

Browse files
authored
fix(mcp): use explicit mode for setupMCP during init (#744)
1 parent 32f0884 commit e11f495

File tree

7 files changed

+125
-39
lines changed

7 files changed

+125
-39
lines changed

packages/@sanity/cli-core/src/util/__tests__/isInteractive.test.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,6 @@ describe('isInteractive', () => {
5555
expect(isInteractive()).toBe(true)
5656
})
5757

58-
test('returns true when skipCi is true even if CI env var is set', () => {
59-
process.stdin.isTTY = true
60-
vi.stubEnv('TERM', undefined)
61-
vi.stubEnv('CI', 'true')
62-
63-
expect(isInteractive({skipCi: true})).toBe(true)
64-
})
65-
6658
test('returns false when stdin is not a TTY even if stdout is', () => {
6759
// stdin is piped (e.g., `echo "input" | sanity command`)
6860
process.stdin.isTTY = false
Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,3 @@
1-
export function isInteractive({
2-
skipCi = false,
3-
}: {
4-
/**
5-
* IF true, skip checking the CI environment variable
6-
*/
7-
skipCi?: boolean
8-
} = {}): boolean {
9-
return (
10-
Boolean(process.stdin.isTTY) &&
11-
process.env.TERM !== 'dumb' &&
12-
(skipCi || !('CI' in process.env))
13-
)
1+
export function isInteractive(): boolean {
2+
return Boolean(process.stdin.isTTY) && process.env.TERM !== 'dumb' && !('CI' in process.env)
143
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import {afterEach, describe, expect, test, vi} from 'vitest'
2+
3+
import {setupMCP} from '../setupMCP.js'
4+
5+
const mockDetectAvailableEditors = vi.hoisted(() => vi.fn())
6+
const mockPromptForMCPSetup = vi.hoisted(() => vi.fn())
7+
const mockValidateEditorTokens = vi.hoisted(() => vi.fn())
8+
const mockCreateMCPToken = vi.hoisted(() => vi.fn())
9+
const mockWriteMCPConfig = vi.hoisted(() => vi.fn())
10+
11+
vi.mock('../detectAvailableEditors.js', () => ({
12+
detectAvailableEditors: mockDetectAvailableEditors,
13+
}))
14+
15+
vi.mock('../promptForMCPSetup.js', () => ({
16+
promptForMCPSetup: mockPromptForMCPSetup,
17+
}))
18+
19+
vi.mock('../validateEditorTokens.js', () => ({
20+
validateEditorTokens: mockValidateEditorTokens,
21+
}))
22+
23+
vi.mock('../../../services/mcp.js', () => ({
24+
createMCPToken: mockCreateMCPToken,
25+
MCP_SERVER_URL: 'https://mcp.sanity.io',
26+
}))
27+
28+
vi.mock('../writeMCPConfig.js', () => ({
29+
writeMCPConfig: mockWriteMCPConfig,
30+
}))
31+
32+
describe('setupMCP', () => {
33+
afterEach(() => {
34+
vi.clearAllMocks()
35+
})
36+
37+
test('mode: skip returns early without detecting editors', async () => {
38+
const result = await setupMCP({mode: 'skip'})
39+
40+
expect(result.skipped).toBe(true)
41+
expect(result.configuredEditors).toEqual([])
42+
expect(result.detectedEditors).toEqual([])
43+
expect(mockDetectAvailableEditors).not.toHaveBeenCalled()
44+
})
45+
46+
test('mode: auto auto-selects all actionable editors without prompting', async () => {
47+
mockDetectAvailableEditors.mockResolvedValue([
48+
{authStatus: 'unknown', configured: false, name: 'Cursor'},
49+
{authStatus: 'unknown', configured: false, name: 'VS Code'},
50+
])
51+
mockValidateEditorTokens.mockResolvedValue(undefined)
52+
mockCreateMCPToken.mockResolvedValue('test-token')
53+
mockWriteMCPConfig.mockResolvedValue(undefined)
54+
55+
const result = await setupMCP({mode: 'auto'})
56+
57+
expect(mockPromptForMCPSetup).not.toHaveBeenCalled()
58+
expect(mockWriteMCPConfig).toHaveBeenCalledTimes(2)
59+
expect(result.configuredEditors).toEqual(['Cursor', 'VS Code'])
60+
expect(result.skipped).toBe(false)
61+
})
62+
63+
test('mode: prompt calls promptForMCPSetup', async () => {
64+
const editors = [{authStatus: 'unknown', configured: false, name: 'Cursor'}]
65+
mockDetectAvailableEditors.mockResolvedValue(editors)
66+
mockValidateEditorTokens.mockResolvedValue(undefined)
67+
mockPromptForMCPSetup.mockResolvedValue(editors)
68+
mockCreateMCPToken.mockResolvedValue('test-token')
69+
mockWriteMCPConfig.mockResolvedValue(undefined)
70+
71+
const result = await setupMCP({mode: 'prompt'})
72+
73+
expect(mockPromptForMCPSetup).toHaveBeenCalledWith(editors)
74+
expect(result.configuredEditors).toEqual(['Cursor'])
75+
expect(result.skipped).toBe(false)
76+
})
77+
78+
test('defaults to prompt mode when no mode specified', async () => {
79+
const editors = [{authStatus: 'unknown', configured: false, name: 'Cursor'}]
80+
mockDetectAvailableEditors.mockResolvedValue(editors)
81+
mockValidateEditorTokens.mockResolvedValue(undefined)
82+
mockPromptForMCPSetup.mockResolvedValue(editors)
83+
mockCreateMCPToken.mockResolvedValue('test-token')
84+
mockWriteMCPConfig.mockResolvedValue(undefined)
85+
86+
await setupMCP()
87+
88+
expect(mockPromptForMCPSetup).toHaveBeenCalledWith(editors)
89+
})
90+
91+
test('defaults to prompt mode when options provided without mode', async () => {
92+
const editors = [{authStatus: 'unknown', configured: false, name: 'Cursor'}]
93+
mockDetectAvailableEditors.mockResolvedValue(editors)
94+
mockValidateEditorTokens.mockResolvedValue(undefined)
95+
mockPromptForMCPSetup.mockResolvedValue(editors)
96+
mockCreateMCPToken.mockResolvedValue('test-token')
97+
mockWriteMCPConfig.mockResolvedValue(undefined)
98+
99+
await setupMCP({explicit: true})
100+
101+
expect(mockPromptForMCPSetup).toHaveBeenCalledWith(editors)
102+
})
103+
})

packages/@sanity/cli/src/actions/mcp/setupMCP.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {ux} from '@oclif/core'
2-
import {isInteractive, subdebug} from '@sanity/cli-core'
2+
import {subdebug} from '@sanity/cli-core'
33
import {logSymbols} from '@sanity/cli-core/ux'
44

55
import {createMCPToken, MCP_SERVER_URL} from '../../services/mcp.js'
@@ -22,9 +22,12 @@ interface MCPSetupOptions {
2222
explicit?: boolean
2323

2424
/**
25-
* If true, skip MCP configuration entirely (e.g. --no-mcp flag).
25+
* Controls how MCP setup behaves:
26+
* - 'prompt': Ask the user which editors to configure (default)
27+
* - 'auto': Auto-configure all detected editors without prompting
28+
* - 'skip': Skip MCP configuration entirely
2629
*/
27-
skip?: boolean
30+
mode?: 'auto' | 'prompt' | 'skip'
2831
}
2932

3033
interface MCPSetupResult {
@@ -42,11 +45,11 @@ interface MCPSetupResult {
4245
* Opt-out by default: runs automatically unless skip option is set
4346
*/
4447
export async function setupMCP(options?: MCPSetupOptions): Promise<MCPSetupResult> {
45-
const {explicit = false, skip = false} = options ?? {}
48+
const {explicit = false, mode = 'prompt'} = options ?? {}
4649

4750
// 1. Check for explicit opt-out
48-
if (skip) {
49-
ux.warn('Skipping MCP configuration due to --no-mcp flag')
51+
if (mode === 'skip') {
52+
mcpDebug('Skipping MCP configuration (mode: skip)')
5053
return {
5154
alreadyConfiguredEditors: [],
5255
configuredEditors: [],
@@ -98,12 +101,8 @@ export async function setupMCP(options?: MCPSetupOptions): Promise<MCPSetupResul
98101
// Non-actionable editors are already configured with valid credentials
99102
const alreadyConfiguredEditors = editors.filter((e) => !actionable.includes(e)).map((e) => e.name)
100103

101-
// 5. Select editors to configure — prompt interactively or auto-select all if non interactive
102-
const selected = isInteractive({
103-
skipCi: true,
104-
})
105-
? await promptForMCPSetup(actionable)
106-
: actionable
104+
// 5. Select editors to configure — prompt interactively or auto-select all
105+
const selected = mode === 'auto' ? actionable : await promptForMCPSetup(actionable)
107106

108107
if (!selected || selected.length === 0) {
109108
// User deselected all editors

packages/@sanity/cli/src/commands/init.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,13 @@ export class InitCommand extends SanityCommand<typeof InitCommand> {
464464
})
465465

466466
// Set up MCP integration
467-
const mcpResult = await setupMCP({skip: !this.flags.mcp})
467+
let mcpMode: 'auto' | 'prompt' | 'skip' = 'prompt'
468+
if (!this.flags.mcp || !this.resolveIsInteractive()) {
469+
mcpMode = 'skip'
470+
} else if (this.flags.yes) {
471+
mcpMode = 'auto'
472+
}
473+
const mcpResult = await setupMCP({mode: mcpMode})
468474

469475
this._trace.log({
470476
configuredEditors: mcpResult.configuredEditors,

packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1157,9 +1157,6 @@ describe('#mcp:configure', () => {
11571157

11581158
const {stdout} = await testCommand(ConfigureMcpCommand, [])
11591159

1160-
// Verify isInteractive was called with skipCi: true (MCP setup should work in CI if TTY present)
1161-
expect(mockIsInteractive).toHaveBeenCalledWith({skipCi: true})
1162-
11631160
expect(mockCheckbox).not.toHaveBeenCalled()
11641161
expect(mockWriteFile).toHaveBeenCalledWith(
11651162
expect.stringContaining(convertToSystemPath('.cursor/mcp.json')),

packages/@sanity/cli/src/commands/mcp/configure.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {SanityCommand, subdebug} from '@sanity/cli-core'
1+
import {isInteractive, SanityCommand, subdebug} from '@sanity/cli-core'
22

33
import {ensureAuthenticated} from '../../actions/auth/ensureAuthenticated.js'
44
import {setupMCP} from '../../actions/mcp/setupMCP.js'
@@ -40,7 +40,7 @@ export class ConfigureMcpCommand extends SanityCommand<typeof ConfigureMcpComman
4040
}
4141

4242
try {
43-
const mcpResult = await setupMCP({explicit: true})
43+
const mcpResult = await setupMCP({explicit: true, mode: isInteractive() ? 'prompt' : 'auto'})
4444

4545
trace.log({
4646
configuredEditors: mcpResult.configuredEditors,

0 commit comments

Comments
 (0)