Skip to content
Merged
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
58 changes: 53 additions & 5 deletions apps/sim/app/api/mcp/servers/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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')
Expand Down Expand Up @@ -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'
Expand Down
64 changes: 58 additions & 6 deletions apps/sim/hooks/queries/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
}
Expand Down Expand Up @@ -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', {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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
*/
Expand Down
72 changes: 72 additions & 0 deletions apps/sim/lib/mcp/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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}$/)
})
})
Loading