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:
Waleed
2025-12-12 11:22:57 -08:00
committed by GitHub
parent cd66fa84d1
commit ffcaa65590
5 changed files with 265 additions and 20 deletions

View File

@@ -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', {

View File

@@ -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'

View File

@@ -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
*/

View 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}$/)
})
})

View File

@@ -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)
}