Skip to content

Commit 4b9857f

Browse files
committed
Fix MCP edit verification feedback (#5596)
1 parent 81b47d6 commit 4b9857f

5 files changed

Lines changed: 179 additions & 28 deletions

File tree

apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ describe('executeManageMcpTool verification', () => {
149149
mockUpdateMcpServer.mockResolvedValue({
150150
success: true,
151151
server: { id: 'mcp-1', name: 'Memory', enabled: true },
152+
connectionChanged: true,
152153
})
153154
mockVerifyServerConnection.mockResolvedValue({
154155
verified: true,
@@ -177,6 +178,7 @@ describe('executeManageMcpTool verification', () => {
177178
mockUpdateMcpServer.mockResolvedValue({
178179
success: true,
179180
server: { id: 'mcp-1', name: 'Renamed Memory', enabled: true },
181+
connectionChanged: false,
180182
})
181183

182184
const result = await executeManageMcpTool(
@@ -199,4 +201,89 @@ describe('executeManageMcpTool verification', () => {
199201
})
200202
)
201203
})
204+
205+
it('skips verification when an edit echoes unchanged connection config', async () => {
206+
mockUpdateMcpServer.mockResolvedValue({
207+
success: true,
208+
server: { id: 'mcp-1', name: 'Renamed Memory', enabled: true },
209+
connectionChanged: false,
210+
})
211+
212+
const result = await executeManageMcpTool(
213+
{
214+
operation: 'edit',
215+
serverId: 'mcp-1',
216+
config: {
217+
name: 'Renamed Memory',
218+
url: 'https://memory.example.com/mcp',
219+
transport: 'streamable-http',
220+
headers: { Authorization: 'Bearer {{MEMORY_KEY}}' },
221+
timeout: 30000,
222+
enabled: true,
223+
},
224+
},
225+
context
226+
)
227+
228+
expect(mockVerifyServerConnection).not.toHaveBeenCalled()
229+
expect(result.output).toEqual(
230+
expect.objectContaining({
231+
verification: expect.objectContaining({
232+
skipped: true,
233+
reason: 'connection_unchanged',
234+
}),
235+
})
236+
)
237+
})
238+
239+
it('re-verifies a server when an edit re-enables it', async () => {
240+
mockUpdateMcpServer.mockResolvedValue({
241+
success: true,
242+
server: { id: 'mcp-1', name: 'Memory', enabled: true },
243+
connectionChanged: true,
244+
})
245+
mockVerifyServerConnection.mockResolvedValue({
246+
verified: true,
247+
toolCount: 2,
248+
requiresAuthorization: false,
249+
})
250+
251+
await executeManageMcpTool(
252+
{
253+
operation: 'edit',
254+
serverId: 'mcp-1',
255+
config: { enabled: true },
256+
},
257+
context
258+
)
259+
260+
expect(mockVerifyServerConnection).toHaveBeenCalledWith('user-1', 'mcp-1', 'workspace-1')
261+
})
262+
263+
it('skips verification when the edited server is disabled', async () => {
264+
mockUpdateMcpServer.mockResolvedValue({
265+
success: true,
266+
server: { id: 'mcp-1', name: 'Memory', enabled: false },
267+
connectionChanged: true,
268+
})
269+
270+
const result = await executeManageMcpTool(
271+
{
272+
operation: 'edit',
273+
serverId: 'mcp-1',
274+
config: { url: 'https://new-memory.example.com/mcp', enabled: false },
275+
},
276+
context
277+
)
278+
279+
expect(mockVerifyServerConnection).not.toHaveBeenCalled()
280+
expect(result.output).toEqual(
281+
expect.objectContaining({
282+
verification: expect.objectContaining({
283+
skipped: true,
284+
reason: 'server_disabled',
285+
}),
286+
})
287+
)
288+
})
202289
})

apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,12 +167,9 @@ export async function executeManageMcpTool(
167167
return { success: false, error: `MCP server not found: ${params.serverId}` }
168168
}
169169

170-
const changesConnection = ['url', 'transport', 'headers', 'timeout', 'enabled'].some(
171-
(key) => config[key as keyof ManageMcpToolConfig] !== undefined
172-
)
173170
const verification = !result.server.enabled
174171
? skippedVerification('server_disabled')
175-
: changesConnection
172+
: result.connectionChanged
176173
? await mcpService.verifyServerConnection(context.userId, params.serverId, workspaceId)
177174
: skippedVerification('connection_unchanged')
178175

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { hasMcpServerConnectionChanged } from '@/lib/mcp/orchestration/server-lifecycle'
6+
7+
const currentConnection = {
8+
url: 'https://memory.example.com/mcp',
9+
transport: 'streamable-http',
10+
headers: {
11+
Authorization: 'Bearer {{MEMORY_KEY}}',
12+
'X-Workspace': 'workspace-1',
13+
},
14+
timeout: 30000,
15+
retries: 3,
16+
enabled: true,
17+
authType: 'headers',
18+
}
19+
20+
describe('hasMcpServerConnectionChanged', () => {
21+
it('treats echoed connection settings as unchanged regardless of header key order', () => {
22+
expect(
23+
hasMcpServerConnectionChanged(currentConnection, {
24+
url: currentConnection.url,
25+
transport: currentConnection.transport,
26+
headers: {
27+
'X-Workspace': 'workspace-1',
28+
Authorization: 'Bearer {{MEMORY_KEY}}',
29+
},
30+
timeout: currentConnection.timeout ?? undefined,
31+
retries: currentConnection.retries ?? undefined,
32+
enabled: currentConnection.enabled,
33+
authType: currentConnection.authType as 'headers',
34+
})
35+
).toBe(false)
36+
})
37+
38+
it.each([
39+
[{ url: 'https://other.example.com/mcp' }],
40+
[{ transport: 'sse' }],
41+
[{ headers: { Authorization: 'Bearer changed' } }],
42+
[{ timeout: 15000 }],
43+
[{ retries: 1 }],
44+
[{ enabled: false }],
45+
[{ authType: 'oauth' as const }],
46+
])('detects an actual connection setting change', (updates) => {
47+
expect(hasMcpServerConnectionChanged(currentConnection, updates)).toBe(true)
48+
})
49+
50+
it('detects an OAuth credential change', () => {
51+
expect(hasMcpServerConnectionChanged(currentConnection, {}, true)).toBe(true)
52+
})
53+
})

apps/sim/lib/mcp/orchestration/server-lifecycle.ts

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { mcpServerOauth } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
55
import { generateId } from '@sim/utils/id'
66
import { and, eq, isNull } from 'drizzle-orm'
7+
import { isEqual } from 'es-toolkit'
78
import type { NextRequest } from 'next/server'
89
import { encryptSecret } from '@/lib/core/security/encryption'
910
import {
@@ -84,6 +85,30 @@ export interface PerformMcpServerResult {
8485
server?: typeof mcpServers.$inferSelect
8586
updated?: boolean
8687
authType?: McpAuthType
88+
connectionChanged?: boolean
89+
}
90+
91+
export function hasMcpServerConnectionChanged(
92+
currentServer: Pick<
93+
typeof mcpServers.$inferSelect,
94+
'url' | 'transport' | 'headers' | 'timeout' | 'retries' | 'enabled' | 'authType'
95+
>,
96+
updates: Pick<
97+
PerformUpdateMcpServerParams,
98+
'url' | 'transport' | 'headers' | 'timeout' | 'retries' | 'enabled' | 'authType'
99+
>,
100+
oauthCredentialsChanged = false
101+
): boolean {
102+
return (
103+
oauthCredentialsChanged ||
104+
(updates.url !== undefined && updates.url !== currentServer.url) ||
105+
(updates.transport !== undefined && updates.transport !== currentServer.transport) ||
106+
(updates.headers !== undefined && !isEqual(updates.headers, currentServer.headers ?? {})) ||
107+
(updates.timeout !== undefined && updates.timeout !== currentServer.timeout) ||
108+
(updates.retries !== undefined && updates.retries !== currentServer.retries) ||
109+
(updates.enabled !== undefined && updates.enabled !== currentServer.enabled) ||
110+
(updates.authType !== undefined && updates.authType !== currentServer.authType)
111+
)
87112
}
88113

89114
type ValidateMcpServerUrlResult =
@@ -316,6 +341,11 @@ export async function performUpdateMcpServer(
316341
const [currentServer] = await db
317342
.select({
318343
url: mcpServers.url,
344+
transport: mcpServers.transport,
345+
headers: mcpServers.headers,
346+
timeout: mcpServers.timeout,
347+
retries: mcpServers.retries,
348+
enabled: mcpServers.enabled,
319349
authType: mcpServers.authType,
320350
oauthClientId: mcpServers.oauthClientId,
321351
oauthClientSecret: mcpServers.oauthClientSecret,
@@ -349,6 +379,11 @@ export async function performUpdateMcpServer(
349379
currentClientId: currentServer.oauthClientId,
350380
currentEncryptedClientSecret: currentServer.oauthClientSecret,
351381
})
382+
const connectionChanged = hasMcpServerConnectionChanged(
383+
currentServer,
384+
{ ...params, authType: updateData.authType as McpAuthType | undefined },
385+
credsChanged
386+
)
352387
const shouldClearOauth = urlChanged || credsChanged
353388
const resolvedAuthType = (updateData.authType ?? currentServer.authType) as McpAuthType
354389
if (shouldClearOauth && resolvedAuthType === 'oauth') {
@@ -381,15 +416,7 @@ export async function performUpdateMcpServer(
381416

382417
if (!server) return { success: false, error: 'Server not found', errorCode: 'not_found' }
383418

384-
const shouldClearCache =
385-
urlChanged ||
386-
credsChanged ||
387-
params.enabled !== undefined ||
388-
params.headers !== undefined ||
389-
params.timeout !== undefined ||
390-
params.retries !== undefined
391-
392-
if (shouldClearCache) await mcpService.clearCache(params.workspaceId)
419+
if (connectionChanged) await mcpService.clearCache(params.workspaceId)
393420

394421
recordAudit({
395422
workspaceId: params.workspaceId,
@@ -410,7 +437,7 @@ export async function performUpdateMcpServer(
410437
request: params.request,
411438
})
412439

413-
return { success: true, server }
440+
return { success: true, server, connectionChanged }
414441
} catch (error) {
415442
logger.error('Failed to update MCP server', { error })
416443
return { success: false, error: 'Failed to update MCP server', errorCode: 'internal' }

apps/sim/lib/mcp/service.ts

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -594,20 +594,7 @@ class McpService {
594594
if (outcome.kind === 'oauth-pending') {
595595
// Mark disconnected so the UI surfaces the re-auth button.
596596
logger.info(`[${requestId}] Skipping server ${server.name}: OAuth authorization pending`)
597-
deferredSideEffects.push(
598-
db
599-
.update(mcpServers)
600-
.set({
601-
connectionStatus: 'disconnected',
602-
lastError: null,
603-
updatedAt: new Date(),
604-
})
605-
.where(eq(mcpServers.id, server.id))
606-
.then(() => undefined)
607-
.catch((err) => {
608-
logger.warn(`[${requestId}] Failed to mark server ${server.id} disconnected:`, err)
609-
})
610-
)
597+
deferredSideEffects.push(this.updateServerAuthorizationRequiredStatus(server.id))
611598
return
612599
}
613600
if (outcome.kind === 'unhealthy') {

0 commit comments

Comments
 (0)