mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 15:07:55 -05:00
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
This commit is contained in:
@@ -7,7 +7,11 @@ import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { mcpService } from '@/lib/mcp/service'
|
||||
import type { McpTransport } from '@/lib/mcp/types'
|
||||
import { validateMcpServerUrl } from '@/lib/mcp/url-validator'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
import {
|
||||
createMcpErrorResponse,
|
||||
createMcpSuccessResponse,
|
||||
generateMcpServerId,
|
||||
} from '@/lib/mcp/utils'
|
||||
|
||||
const logger = createLogger('McpServersAPI')
|
||||
|
||||
@@ -50,13 +54,20 @@ export const GET = withMcpAuth('read')(
|
||||
|
||||
/**
|
||||
* POST - Register a new MCP server for the workspace (requires write permission)
|
||||
*
|
||||
* Uses deterministic server IDs based on URL hash to ensure that re-adding
|
||||
* the same server produces the same ID. This prevents "server not found" errors
|
||||
* when workflows reference the old server ID after delete/re-add cycles.
|
||||
*
|
||||
* If a server with the same ID already exists (same URL in same workspace),
|
||||
* it will be updated instead of creating a duplicate.
|
||||
*/
|
||||
export const POST = withMcpAuth('write')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }) => {
|
||||
try {
|
||||
const body = getParsedBody(request) || (await request.json())
|
||||
|
||||
logger.info(`[${requestId}] Registering new MCP server:`, {
|
||||
logger.info(`[${requestId}] Registering MCP server:`, {
|
||||
name: body.name,
|
||||
transport: body.transport,
|
||||
workspaceId,
|
||||
@@ -82,7 +93,43 @@ export const POST = withMcpAuth('write')(
|
||||
body.url = urlValidation.normalizedUrl
|
||||
}
|
||||
|
||||
const serverId = body.id || crypto.randomUUID()
|
||||
const serverId = body.url ? generateMcpServerId(workspaceId, body.url) : crypto.randomUUID()
|
||||
|
||||
const [existingServer] = await db
|
||||
.select({ id: mcpServers.id, deletedAt: mcpServers.deletedAt })
|
||||
.from(mcpServers)
|
||||
.where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId)))
|
||||
.limit(1)
|
||||
|
||||
if (existingServer) {
|
||||
logger.info(
|
||||
`[${requestId}] Server with ID ${serverId} already exists, updating instead of creating`
|
||||
)
|
||||
|
||||
await db
|
||||
.update(mcpServers)
|
||||
.set({
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
transport: body.transport,
|
||||
url: body.url,
|
||||
headers: body.headers || {},
|
||||
timeout: body.timeout || 30000,
|
||||
retries: body.retries || 3,
|
||||
enabled: body.enabled !== false,
|
||||
updatedAt: new Date(),
|
||||
deletedAt: null,
|
||||
})
|
||||
.where(eq(mcpServers.id, serverId))
|
||||
|
||||
mcpService.clearCache(workspaceId)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Successfully updated MCP server: ${body.name} (ID: ${serverId})`
|
||||
)
|
||||
|
||||
return createMcpSuccessResponse({ serverId, updated: true }, 200)
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(mcpServers)
|
||||
@@ -105,9 +152,10 @@ export const POST = withMcpAuth('write')(
|
||||
|
||||
mcpService.clearCache(workspaceId)
|
||||
|
||||
logger.info(`[${requestId}] Successfully registered MCP server: ${body.name}`)
|
||||
logger.info(
|
||||
`[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${serverId})`
|
||||
)
|
||||
|
||||
// Track MCP server registration
|
||||
try {
|
||||
const { trackPlatformEvent } = await import('@/lib/core/telemetry')
|
||||
trackPlatformEvent('platform.mcp.server_added', {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
useDeleteMcpServer,
|
||||
useMcpServers,
|
||||
useMcpToolsQuery,
|
||||
useRefreshMcpServer,
|
||||
} from '@/hooks/queries/mcp'
|
||||
import { useMcpServerTest } from '@/hooks/use-mcp-server-test'
|
||||
import type { InputFieldType, McpServerFormData, McpServerTestResult } from './components'
|
||||
@@ -89,27 +90,24 @@ export function MCP() {
|
||||
} = useMcpToolsQuery(workspaceId)
|
||||
const createServerMutation = useCreateMcpServer()
|
||||
const deleteServerMutation = useDeleteMcpServer()
|
||||
const refreshServerMutation = useRefreshMcpServer()
|
||||
const { testResult, isTestingConnection, testConnection, clearTestResult } = useMcpServerTest()
|
||||
|
||||
const urlInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Form state
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [formData, setFormData] = useState<McpServerFormData>(DEFAULT_FORM_DATA)
|
||||
const [isAddingServer, setIsAddingServer] = useState(false)
|
||||
|
||||
// Search and filtering state
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [deletingServers, setDeletingServers] = useState<Set<string>>(new Set())
|
||||
|
||||
// Delete confirmation dialog state
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [serverToDelete, setServerToDelete] = useState<{ id: string; name: string } | null>(null)
|
||||
|
||||
// Server details view state
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(null)
|
||||
const [refreshStatus, setRefreshStatus] = useState<'idle' | 'refreshing' | 'refreshed'>('idle')
|
||||
|
||||
// Environment variable dropdown state
|
||||
const [showEnvVars, setShowEnvVars] = useState(false)
|
||||
const [envSearchTerm, setEnvSearchTerm] = useState('')
|
||||
const [cursorPosition, setCursorPosition] = useState(0)
|
||||
@@ -255,7 +253,6 @@ export function MCP() {
|
||||
workspaceId,
|
||||
}
|
||||
|
||||
// Test connection if not already tested
|
||||
if (!testResult) {
|
||||
const result = await testConnection(serverConfig)
|
||||
if (!result.success) return
|
||||
@@ -396,6 +393,25 @@ export function MCP() {
|
||||
setSelectedServerId(null)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Refreshes a server's tools by re-discovering them from the MCP server.
|
||||
*/
|
||||
const handleRefreshServer = useCallback(
|
||||
async (serverId: string) => {
|
||||
try {
|
||||
setRefreshStatus('refreshing')
|
||||
await refreshServerMutation.mutateAsync({ workspaceId, serverId })
|
||||
logger.info(`Refreshed MCP server: ${serverId}`)
|
||||
setRefreshStatus('refreshed')
|
||||
setTimeout(() => setRefreshStatus('idle'), 2000)
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh MCP server:', error)
|
||||
setRefreshStatus('idle')
|
||||
}
|
||||
},
|
||||
[refreshServerMutation, workspaceId]
|
||||
)
|
||||
|
||||
/**
|
||||
* Gets the selected server and its tools for the detail view.
|
||||
*/
|
||||
@@ -412,12 +428,10 @@ export function MCP() {
|
||||
const showEmptyState = !hasServers && !showAddForm
|
||||
const showNoResults = searchTerm.trim() && filteredServers.length === 0 && servers.length > 0
|
||||
|
||||
// Form validation state
|
||||
const isFormValid = formData.name.trim() && formData.url?.trim()
|
||||
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid
|
||||
const testButtonLabel = getTestButtonLabel(testResult, isTestingConnection)
|
||||
|
||||
// Show detail view if a server is selected
|
||||
if (selectedServer) {
|
||||
const { server, tools } = selectedServer
|
||||
const transportLabel = formatTransportLabel(server.transport || 'http')
|
||||
@@ -478,7 +492,18 @@ export function MCP() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-auto flex items-center justify-end'>
|
||||
<div className='mt-auto flex items-center justify-between'>
|
||||
<Button
|
||||
onClick={() => handleRefreshServer(server.id)}
|
||||
variant='default'
|
||||
disabled={refreshStatus !== 'idle'}
|
||||
>
|
||||
{refreshStatus === 'refreshing'
|
||||
? 'Refreshing...'
|
||||
: refreshStatus === 'refreshed'
|
||||
? 'Refreshed'
|
||||
: 'Refresh Tools'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBackToList}
|
||||
variant='primary'
|
||||
|
||||
@@ -56,7 +56,6 @@ export interface McpTool {
|
||||
async function fetchMcpServers(workspaceId: string): Promise<McpServer[]> {
|
||||
const response = await fetch(`/api/mcp/servers?workspaceId=${workspaceId}`)
|
||||
|
||||
// Treat 404 as "no servers configured" - return empty array
|
||||
if (response.status === 404) {
|
||||
return []
|
||||
}
|
||||
@@ -134,9 +133,6 @@ export function useCreateMcpServer() {
|
||||
const serverData = {
|
||||
...config,
|
||||
workspaceId,
|
||||
id: `mcp-${Date.now()}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const response = await fetch('/api/mcp/servers', {
|
||||
@@ -151,11 +147,21 @@ export function useCreateMcpServer() {
|
||||
throw new Error(data.error || 'Failed to create MCP server')
|
||||
}
|
||||
|
||||
logger.info(`Created MCP server: ${config.name} in workspace: ${workspaceId}`)
|
||||
const serverId = data.data?.serverId
|
||||
const wasUpdated = data.data?.updated === true
|
||||
|
||||
logger.info(
|
||||
wasUpdated
|
||||
? `Updated existing MCP server: ${config.name} (ID: ${serverId})`
|
||||
: `Created MCP server: ${config.name} (ID: ${serverId})`
|
||||
)
|
||||
|
||||
return {
|
||||
...serverData,
|
||||
id: serverId,
|
||||
connectionStatus: 'disconnected' as const,
|
||||
serverId: data.data?.serverId,
|
||||
serverId,
|
||||
updated: wasUpdated,
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
@@ -247,6 +253,52 @@ export function useUpdateMcpServer() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh MCP server mutation - re-discovers tools from the server
|
||||
*/
|
||||
interface RefreshMcpServerParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
}
|
||||
|
||||
export interface RefreshMcpServerResult {
|
||||
status: 'connected' | 'disconnected' | 'error'
|
||||
toolCount: number
|
||||
lastConnected: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export function useRefreshMcpServer() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
serverId,
|
||||
}: RefreshMcpServerParams): Promise<RefreshMcpServerResult> => {
|
||||
const response = await fetch(
|
||||
`/api/mcp/servers/${serverId}/refresh?workspaceId=${workspaceId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to refresh MCP server')
|
||||
}
|
||||
|
||||
logger.info(`Refreshed MCP server: ${serverId}`)
|
||||
return data.data
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.tools(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Test MCP server connection
|
||||
*/
|
||||
|
||||
72
apps/sim/lib/mcp/utils.test.ts
Normal file
72
apps/sim/lib/mcp/utils.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { generateMcpServerId } from './utils'
|
||||
|
||||
describe('generateMcpServerId', () => {
|
||||
const workspaceId = 'ws-test-123'
|
||||
const url = 'https://my-mcp-server.com/mcp'
|
||||
|
||||
it.concurrent('produces deterministic IDs for the same input', () => {
|
||||
const id1 = generateMcpServerId(workspaceId, url)
|
||||
const id2 = generateMcpServerId(workspaceId, url)
|
||||
expect(id1).toBe(id2)
|
||||
})
|
||||
|
||||
it.concurrent('normalizes trailing slashes', () => {
|
||||
const id1 = generateMcpServerId(workspaceId, url)
|
||||
const id2 = generateMcpServerId(workspaceId, `${url}/`)
|
||||
const id3 = generateMcpServerId(workspaceId, `${url}//`)
|
||||
expect(id1).toBe(id2)
|
||||
expect(id1).toBe(id3)
|
||||
})
|
||||
|
||||
it.concurrent('is case insensitive for URL', () => {
|
||||
const id1 = generateMcpServerId(workspaceId, url)
|
||||
const id2 = generateMcpServerId(workspaceId, 'https://MY-MCP-SERVER.com/mcp')
|
||||
const id3 = generateMcpServerId(workspaceId, 'HTTPS://My-Mcp-Server.COM/MCP')
|
||||
expect(id1).toBe(id2)
|
||||
expect(id1).toBe(id3)
|
||||
})
|
||||
|
||||
it.concurrent('ignores query parameters', () => {
|
||||
const id1 = generateMcpServerId(workspaceId, url)
|
||||
const id2 = generateMcpServerId(workspaceId, `${url}?token=abc123`)
|
||||
const id3 = generateMcpServerId(workspaceId, `${url}?foo=bar&baz=qux`)
|
||||
expect(id1).toBe(id2)
|
||||
expect(id1).toBe(id3)
|
||||
})
|
||||
|
||||
it.concurrent('ignores fragments', () => {
|
||||
const id1 = generateMcpServerId(workspaceId, url)
|
||||
const id2 = generateMcpServerId(workspaceId, `${url}#section`)
|
||||
expect(id1).toBe(id2)
|
||||
})
|
||||
|
||||
it.concurrent('produces different IDs for different workspaces', () => {
|
||||
const id1 = generateMcpServerId('ws-123', url)
|
||||
const id2 = generateMcpServerId('ws-456', url)
|
||||
expect(id1).not.toBe(id2)
|
||||
})
|
||||
|
||||
it.concurrent('produces different IDs for different URLs', () => {
|
||||
const id1 = generateMcpServerId(workspaceId, 'https://server1.com/mcp')
|
||||
const id2 = generateMcpServerId(workspaceId, 'https://server2.com/mcp')
|
||||
expect(id1).not.toBe(id2)
|
||||
})
|
||||
|
||||
it.concurrent('produces IDs in the correct format', () => {
|
||||
const id = generateMcpServerId(workspaceId, url)
|
||||
expect(id).toMatch(/^mcp-[a-f0-9]{8}$/)
|
||||
})
|
||||
|
||||
it.concurrent('handles URLs with ports', () => {
|
||||
const id1 = generateMcpServerId(workspaceId, 'https://localhost:3000/mcp')
|
||||
const id2 = generateMcpServerId(workspaceId, 'https://localhost:3000/mcp/')
|
||||
expect(id1).toBe(id2)
|
||||
expect(id1).toMatch(/^mcp-[a-f0-9]{8}$/)
|
||||
})
|
||||
|
||||
it.concurrent('handles invalid URLs gracefully', () => {
|
||||
const id = generateMcpServerId(workspaceId, 'not-a-valid-url')
|
||||
expect(id).toMatch(/^mcp-[a-f0-9]{8}$/)
|
||||
})
|
||||
})
|
||||
@@ -141,3 +141,51 @@ export function parseMcpToolId(toolId: string): { serverId: string; toolName: st
|
||||
|
||||
return { serverId, toolName }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a deterministic MCP server ID based on workspace and URL.
|
||||
*
|
||||
* This ensures that re-adding the same MCP server (same URL in the same workspace)
|
||||
* produces the same ID, preventing "server not found" errors when workflows
|
||||
* reference the old server ID.
|
||||
*
|
||||
* The ID is a hash of: workspaceId + normalized URL
|
||||
* Format: mcp-<8 char hash>
|
||||
*/
|
||||
export function generateMcpServerId(workspaceId: string, url: string): string {
|
||||
const normalizedUrl = normalizeUrlForHashing(url)
|
||||
|
||||
const input = `${workspaceId}:${normalizedUrl}`
|
||||
const hash = simpleHash(input)
|
||||
|
||||
return `mcp-${hash}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize URL for consistent hashing.
|
||||
* - Converts to lowercase
|
||||
* - Removes trailing slashes
|
||||
* - Removes query parameters and fragments
|
||||
*/
|
||||
function normalizeUrlForHashing(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
const normalized = `${parsed.origin}${parsed.pathname}`.toLowerCase().replace(/\/+$/, '')
|
||||
return normalized
|
||||
} catch {
|
||||
return url.toLowerCase().trim().replace(/\/+$/, '')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple deterministic hash function that produces an 8-character hex string.
|
||||
* Uses a variant of djb2 hash algorithm.
|
||||
*/
|
||||
function simpleHash(str: string): string {
|
||||
let hash = 5381
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) + hash + str.charCodeAt(i)
|
||||
hash = hash >>> 0
|
||||
}
|
||||
return hash.toString(16).padStart(8, '0').slice(0, 8)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user