Skip to content

Commit ffcaa65

Browse files
authored
feat(mcp): added the ability to refresh servers to grab new tools (#2335)
* feat(mcp): added the ability to refresh servers to grab new tools * added tests
1 parent cd66fa8 commit ffcaa65

File tree

5 files changed

+265
-20
lines changed

5 files changed

+265
-20
lines changed

apps/sim/app/api/mcp/servers/route.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
77
import { mcpService } from '@/lib/mcp/service'
88
import type { McpTransport } from '@/lib/mcp/types'
99
import { validateMcpServerUrl } from '@/lib/mcp/url-validator'
10-
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
10+
import {
11+
createMcpErrorResponse,
12+
createMcpSuccessResponse,
13+
generateMcpServerId,
14+
} from '@/lib/mcp/utils'
1115

1216
const logger = createLogger('McpServersAPI')
1317

@@ -50,13 +54,20 @@ export const GET = withMcpAuth('read')(
5054

5155
/**
5256
* POST - Register a new MCP server for the workspace (requires write permission)
57+
*
58+
* Uses deterministic server IDs based on URL hash to ensure that re-adding
59+
* the same server produces the same ID. This prevents "server not found" errors
60+
* when workflows reference the old server ID after delete/re-add cycles.
61+
*
62+
* If a server with the same ID already exists (same URL in same workspace),
63+
* it will be updated instead of creating a duplicate.
5364
*/
5465
export const POST = withMcpAuth('write')(
5566
async (request: NextRequest, { userId, workspaceId, requestId }) => {
5667
try {
5768
const body = getParsedBody(request) || (await request.json())
5869

59-
logger.info(`[${requestId}] Registering new MCP server:`, {
70+
logger.info(`[${requestId}] Registering MCP server:`, {
6071
name: body.name,
6172
transport: body.transport,
6273
workspaceId,
@@ -82,7 +93,43 @@ export const POST = withMcpAuth('write')(
8293
body.url = urlValidation.normalizedUrl
8394
}
8495

85-
const serverId = body.id || crypto.randomUUID()
96+
const serverId = body.url ? generateMcpServerId(workspaceId, body.url) : crypto.randomUUID()
97+
98+
const [existingServer] = await db
99+
.select({ id: mcpServers.id, deletedAt: mcpServers.deletedAt })
100+
.from(mcpServers)
101+
.where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId)))
102+
.limit(1)
103+
104+
if (existingServer) {
105+
logger.info(
106+
`[${requestId}] Server with ID ${serverId} already exists, updating instead of creating`
107+
)
108+
109+
await db
110+
.update(mcpServers)
111+
.set({
112+
name: body.name,
113+
description: body.description,
114+
transport: body.transport,
115+
url: body.url,
116+
headers: body.headers || {},
117+
timeout: body.timeout || 30000,
118+
retries: body.retries || 3,
119+
enabled: body.enabled !== false,
120+
updatedAt: new Date(),
121+
deletedAt: null,
122+
})
123+
.where(eq(mcpServers.id, serverId))
124+
125+
mcpService.clearCache(workspaceId)
126+
127+
logger.info(
128+
`[${requestId}] Successfully updated MCP server: ${body.name} (ID: ${serverId})`
129+
)
130+
131+
return createMcpSuccessResponse({ serverId, updated: true }, 200)
132+
}
86133

87134
await db
88135
.insert(mcpServers)
@@ -105,9 +152,10 @@ export const POST = withMcpAuth('write')(
105152

106153
mcpService.clearCache(workspaceId)
107154

108-
logger.info(`[${requestId}] Successfully registered MCP server: ${body.name}`)
155+
logger.info(
156+
`[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${serverId})`
157+
)
109158

110-
// Track MCP server registration
111159
try {
112160
const { trackPlatformEvent } = await import('@/lib/core/telemetry')
113161
trackPlatformEvent('platform.mcp.server_added', {

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
useDeleteMcpServer,
2121
useMcpServers,
2222
useMcpToolsQuery,
23+
useRefreshMcpServer,
2324
} from '@/hooks/queries/mcp'
2425
import { useMcpServerTest } from '@/hooks/use-mcp-server-test'
2526
import type { InputFieldType, McpServerFormData, McpServerTestResult } from './components'
@@ -89,27 +90,24 @@ export function MCP() {
8990
} = useMcpToolsQuery(workspaceId)
9091
const createServerMutation = useCreateMcpServer()
9192
const deleteServerMutation = useDeleteMcpServer()
93+
const refreshServerMutation = useRefreshMcpServer()
9294
const { testResult, isTestingConnection, testConnection, clearTestResult } = useMcpServerTest()
9395

9496
const urlInputRef = useRef<HTMLInputElement>(null)
9597

96-
// Form state
9798
const [showAddForm, setShowAddForm] = useState(false)
9899
const [formData, setFormData] = useState<McpServerFormData>(DEFAULT_FORM_DATA)
99100
const [isAddingServer, setIsAddingServer] = useState(false)
100101

101-
// Search and filtering state
102102
const [searchTerm, setSearchTerm] = useState('')
103103
const [deletingServers, setDeletingServers] = useState<Set<string>>(new Set())
104104

105-
// Delete confirmation dialog state
106105
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
107106
const [serverToDelete, setServerToDelete] = useState<{ id: string; name: string } | null>(null)
108107

109-
// Server details view state
110108
const [selectedServerId, setSelectedServerId] = useState<string | null>(null)
109+
const [refreshStatus, setRefreshStatus] = useState<'idle' | 'refreshing' | 'refreshed'>('idle')
111110

112-
// Environment variable dropdown state
113111
const [showEnvVars, setShowEnvVars] = useState(false)
114112
const [envSearchTerm, setEnvSearchTerm] = useState('')
115113
const [cursorPosition, setCursorPosition] = useState(0)
@@ -255,7 +253,6 @@ export function MCP() {
255253
workspaceId,
256254
}
257255

258-
// Test connection if not already tested
259256
if (!testResult) {
260257
const result = await testConnection(serverConfig)
261258
if (!result.success) return
@@ -396,6 +393,25 @@ export function MCP() {
396393
setSelectedServerId(null)
397394
}, [])
398395

396+
/**
397+
* Refreshes a server's tools by re-discovering them from the MCP server.
398+
*/
399+
const handleRefreshServer = useCallback(
400+
async (serverId: string) => {
401+
try {
402+
setRefreshStatus('refreshing')
403+
await refreshServerMutation.mutateAsync({ workspaceId, serverId })
404+
logger.info(`Refreshed MCP server: ${serverId}`)
405+
setRefreshStatus('refreshed')
406+
setTimeout(() => setRefreshStatus('idle'), 2000)
407+
} catch (error) {
408+
logger.error('Failed to refresh MCP server:', error)
409+
setRefreshStatus('idle')
410+
}
411+
},
412+
[refreshServerMutation, workspaceId]
413+
)
414+
399415
/**
400416
* Gets the selected server and its tools for the detail view.
401417
*/
@@ -412,12 +428,10 @@ export function MCP() {
412428
const showEmptyState = !hasServers && !showAddForm
413429
const showNoResults = searchTerm.trim() && filteredServers.length === 0 && servers.length > 0
414430

415-
// Form validation state
416431
const isFormValid = formData.name.trim() && formData.url?.trim()
417432
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid
418433
const testButtonLabel = getTestButtonLabel(testResult, isTestingConnection)
419434

420-
// Show detail view if a server is selected
421435
if (selectedServer) {
422436
const { server, tools } = selectedServer
423437
const transportLabel = formatTransportLabel(server.transport || 'http')
@@ -478,7 +492,18 @@ export function MCP() {
478492
</div>
479493
</div>
480494

481-
<div className='mt-auto flex items-center justify-end'>
495+
<div className='mt-auto flex items-center justify-between'>
496+
<Button
497+
onClick={() => handleRefreshServer(server.id)}
498+
variant='default'
499+
disabled={refreshStatus !== 'idle'}
500+
>
501+
{refreshStatus === 'refreshing'
502+
? 'Refreshing...'
503+
: refreshStatus === 'refreshed'
504+
? 'Refreshed'
505+
: 'Refresh Tools'}
506+
</Button>
482507
<Button
483508
onClick={handleBackToList}
484509
variant='primary'

apps/sim/hooks/queries/mcp.ts

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ export interface McpTool {
5656
async function fetchMcpServers(workspaceId: string): Promise<McpServer[]> {
5757
const response = await fetch(`/api/mcp/servers?workspaceId=${workspaceId}`)
5858

59-
// Treat 404 as "no servers configured" - return empty array
6059
if (response.status === 404) {
6160
return []
6261
}
@@ -134,9 +133,6 @@ export function useCreateMcpServer() {
134133
const serverData = {
135134
...config,
136135
workspaceId,
137-
id: `mcp-${Date.now()}`,
138-
createdAt: new Date().toISOString(),
139-
updatedAt: new Date().toISOString(),
140136
}
141137

142138
const response = await fetch('/api/mcp/servers', {
@@ -151,11 +147,21 @@ export function useCreateMcpServer() {
151147
throw new Error(data.error || 'Failed to create MCP server')
152148
}
153149

154-
logger.info(`Created MCP server: ${config.name} in workspace: ${workspaceId}`)
150+
const serverId = data.data?.serverId
151+
const wasUpdated = data.data?.updated === true
152+
153+
logger.info(
154+
wasUpdated
155+
? `Updated existing MCP server: ${config.name} (ID: ${serverId})`
156+
: `Created MCP server: ${config.name} (ID: ${serverId})`
157+
)
158+
155159
return {
156160
...serverData,
161+
id: serverId,
157162
connectionStatus: 'disconnected' as const,
158-
serverId: data.data?.serverId,
163+
serverId,
164+
updated: wasUpdated,
159165
}
160166
},
161167
onSuccess: (_data, variables) => {
@@ -247,6 +253,52 @@ export function useUpdateMcpServer() {
247253
})
248254
}
249255

256+
/**
257+
* Refresh MCP server mutation - re-discovers tools from the server
258+
*/
259+
interface RefreshMcpServerParams {
260+
workspaceId: string
261+
serverId: string
262+
}
263+
264+
export interface RefreshMcpServerResult {
265+
status: 'connected' | 'disconnected' | 'error'
266+
toolCount: number
267+
lastConnected: string | null
268+
error: string | null
269+
}
270+
271+
export function useRefreshMcpServer() {
272+
const queryClient = useQueryClient()
273+
274+
return useMutation({
275+
mutationFn: async ({
276+
workspaceId,
277+
serverId,
278+
}: RefreshMcpServerParams): Promise<RefreshMcpServerResult> => {
279+
const response = await fetch(
280+
`/api/mcp/servers/${serverId}/refresh?workspaceId=${workspaceId}`,
281+
{
282+
method: 'POST',
283+
}
284+
)
285+
286+
const data = await response.json()
287+
288+
if (!response.ok) {
289+
throw new Error(data.error || 'Failed to refresh MCP server')
290+
}
291+
292+
logger.info(`Refreshed MCP server: ${serverId}`)
293+
return data.data
294+
},
295+
onSuccess: (_data, variables) => {
296+
queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) })
297+
queryClient.invalidateQueries({ queryKey: mcpKeys.tools(variables.workspaceId) })
298+
},
299+
})
300+
}
301+
250302
/**
251303
* Test MCP server connection
252304
*/

apps/sim/lib/mcp/utils.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { generateMcpServerId } from './utils'
3+
4+
describe('generateMcpServerId', () => {
5+
const workspaceId = 'ws-test-123'
6+
const url = 'https://my-mcp-server.com/mcp'
7+
8+
it.concurrent('produces deterministic IDs for the same input', () => {
9+
const id1 = generateMcpServerId(workspaceId, url)
10+
const id2 = generateMcpServerId(workspaceId, url)
11+
expect(id1).toBe(id2)
12+
})
13+
14+
it.concurrent('normalizes trailing slashes', () => {
15+
const id1 = generateMcpServerId(workspaceId, url)
16+
const id2 = generateMcpServerId(workspaceId, `${url}/`)
17+
const id3 = generateMcpServerId(workspaceId, `${url}//`)
18+
expect(id1).toBe(id2)
19+
expect(id1).toBe(id3)
20+
})
21+
22+
it.concurrent('is case insensitive for URL', () => {
23+
const id1 = generateMcpServerId(workspaceId, url)
24+
const id2 = generateMcpServerId(workspaceId, 'https://MY-MCP-SERVER.com/mcp')
25+
const id3 = generateMcpServerId(workspaceId, 'HTTPS://My-Mcp-Server.COM/MCP')
26+
expect(id1).toBe(id2)
27+
expect(id1).toBe(id3)
28+
})
29+
30+
it.concurrent('ignores query parameters', () => {
31+
const id1 = generateMcpServerId(workspaceId, url)
32+
const id2 = generateMcpServerId(workspaceId, `${url}?token=abc123`)
33+
const id3 = generateMcpServerId(workspaceId, `${url}?foo=bar&baz=qux`)
34+
expect(id1).toBe(id2)
35+
expect(id1).toBe(id3)
36+
})
37+
38+
it.concurrent('ignores fragments', () => {
39+
const id1 = generateMcpServerId(workspaceId, url)
40+
const id2 = generateMcpServerId(workspaceId, `${url}#section`)
41+
expect(id1).toBe(id2)
42+
})
43+
44+
it.concurrent('produces different IDs for different workspaces', () => {
45+
const id1 = generateMcpServerId('ws-123', url)
46+
const id2 = generateMcpServerId('ws-456', url)
47+
expect(id1).not.toBe(id2)
48+
})
49+
50+
it.concurrent('produces different IDs for different URLs', () => {
51+
const id1 = generateMcpServerId(workspaceId, 'https://server1.com/mcp')
52+
const id2 = generateMcpServerId(workspaceId, 'https://server2.com/mcp')
53+
expect(id1).not.toBe(id2)
54+
})
55+
56+
it.concurrent('produces IDs in the correct format', () => {
57+
const id = generateMcpServerId(workspaceId, url)
58+
expect(id).toMatch(/^mcp-[a-f0-9]{8}$/)
59+
})
60+
61+
it.concurrent('handles URLs with ports', () => {
62+
const id1 = generateMcpServerId(workspaceId, 'https://localhost:3000/mcp')
63+
const id2 = generateMcpServerId(workspaceId, 'https://localhost:3000/mcp/')
64+
expect(id1).toBe(id2)
65+
expect(id1).toMatch(/^mcp-[a-f0-9]{8}$/)
66+
})
67+
68+
it.concurrent('handles invalid URLs gracefully', () => {
69+
const id = generateMcpServerId(workspaceId, 'not-a-valid-url')
70+
expect(id).toMatch(/^mcp-[a-f0-9]{8}$/)
71+
})
72+
})

0 commit comments

Comments
 (0)