Skip to content
Open
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
108 changes: 108 additions & 0 deletions apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* @vitest-environment node
*/
import type { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockDiscoverServerTools, mockSelect, mockUpdateSet } = vi.hoisted(() => ({
mockDiscoverServerTools: vi.fn(),
mockSelect: vi.fn(),
mockUpdateSet: vi.fn(),
}))

vi.mock('@sim/db', () => ({
db: {
select: mockSelect,
update: vi.fn().mockReturnValue({ set: mockUpdateSet }),
},
}))

vi.mock('@/lib/core/utils/with-route-handler', () => ({
withRouteHandler: (handler: unknown) => handler,
}))

vi.mock('@/lib/mcp/middleware', () => ({
withMcpAuth:
() =>
(
handler: (
request: NextRequest,
context: { userId: string; workspaceId: string; requestId: string },
routeContext: { params: Promise<{ id: string }> }
) => Promise<Response>
) =>
(request: NextRequest, routeContext: { params: Promise<{ id: string }> }) =>
handler(
request,
{ userId: 'user-1', workspaceId: 'workspace-1', requestId: 'request-1' },
routeContext
),
}))

vi.mock('@/lib/mcp/service', () => ({
mcpService: {
clearCache: vi.fn(),
discoverServerTools: mockDiscoverServerTools,
},
}))

import { POST } from '@/app/api/mcp/servers/[id]/refresh/route'

const initialServer = {
id: 'server-1',
workspaceId: 'workspace-1',
name: 'OAuth Server',
url: 'https://example.com/mcp',
connectionStatus: 'connected',
lastError: null,
lastConnected: new Date('2026-01-01T00:00:00.000Z'),
toolCount: 4,
statusConfig: { consecutiveFailures: 0, lastSuccessfulDiscovery: null },
}

const persistedServer = {
...initialServer,
connectionStatus: 'disconnected',
lastError: null,
toolCount: 0,
}

function selectRows(rows: unknown[]) {
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue(rows),
}),
}),
}
}

describe('MCP server refresh route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSelect.mockReturnValueOnce(selectRows([initialServer]))
mockUpdateSet.mockReturnValue({
where: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([persistedServer]) }),
})
})

it('preserves the service-persisted OAuth pending status', async () => {
mockDiscoverServerTools.mockRejectedValueOnce(new Error('OAuth authorization required'))

const request = new Request('http://localhost/api/mcp/servers/server-1/refresh', {
method: 'POST',
}) as NextRequest
const response = await POST(request, { params: Promise.resolve({ id: 'server-1' }) })
const body = await response.json()

expect(body.data).toEqual(
expect.objectContaining({
status: 'disconnected',
error: null,
})
)
expect(mockUpdateSet).not.toHaveBeenCalledWith(
expect.objectContaining({ connectionStatus: expect.anything() })
)
})
})
68 changes: 33 additions & 35 deletions apps/sim/app/api/mcp/servers/[id]/refresh/route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { db } from '@sim/db'
import { mcpServers, workflow, workflowBlocks } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { mcpServerIdParamsSchema } from '@/lib/api/contracts/mcp'
import { validationErrorResponse } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withMcpAuth } from '@/lib/mcp/middleware'
import { mcpService } from '@/lib/mcp/service'
import type { McpServerStatusConfig, McpTool, McpToolSchema } from '@/lib/mcp/types'
import type { McpTool, McpToolSchema } from '@/lib/mcp/types'
import {
createMcpErrorResponse,
createMcpSuccessResponse,
Expand Down Expand Up @@ -184,17 +185,9 @@ export const POST = withRouteHandler(
)
}

let connectionStatus: 'connected' | 'disconnected' | 'error' = 'error'
let toolCount = 0
let lastError: string | null = null
let syncResult: SyncResult = { updatedCount: 0, updatedWorkflowIds: [] }
let discoveredTools: McpTool[] = []

const currentStatusConfig: McpServerStatusConfig =
(server.statusConfig as McpServerStatusConfig | null) ?? {
consecutiveFailures: 0,
lastSuccessfulDiscovery: null,
}
let discoveryError: string | null = null

try {
discoveredTools = await mcpService.discoverServerTools(
Expand All @@ -203,48 +196,53 @@ export const POST = withRouteHandler(
workspaceId,
true
)
connectionStatus = 'connected'
toolCount = discoveredTools.length
logger.info(`[${requestId}] Discovered ${toolCount} tools from server ${serverId}`)
logger.info(
`[${requestId}] Discovered ${discoveredTools.length} tools from server ${serverId}`
)
} catch (error) {
discoveryError = truncate(
getErrorMessage(error, 'Connection failed').split('\n')[0],
200,
''
)
logger.warn(`[${requestId}] Failed to connect to server ${serverId}:`, error)
}

if (discoveryError === null) {
syncResult = await syncToolSchemasToWorkflows(
workspaceId,
serverId,
discoveredTools,
requestId,
{ url: server.url ?? undefined, name: server.name ?? undefined }
)
} catch (error) {
connectionStatus = 'error'
lastError =
error instanceof Error
? error.message.split('\n')[0].slice(0, 200)
: 'Connection failed'
logger.warn(`[${requestId}] Failed to connect to server ${serverId}:`, error)
}

const now = new Date()
const newStatusConfig =
connectionStatus === 'connected'
? { consecutiveFailures: 0, lastSuccessfulDiscovery: now.toISOString() }
: {
consecutiveFailures: currentStatusConfig.consecutiveFailures + 1,
lastSuccessfulDiscovery: currentStatusConfig.lastSuccessfulDiscovery,
}

const [refreshedServer] = await db
.update(mcpServers)
.set({
lastToolsRefresh: now,
connectionStatus,
lastError,
lastConnected: connectionStatus === 'connected' ? now : server.lastConnected,
toolCount,
statusConfig: newStatusConfig,
updatedAt: now,
})
.where(eq(mcpServers.id, serverId))
.returning()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refresh masks discovery failures

Medium Severity

When discoverServerTools throws, the handler still builds the HTTP response from the post-refresh database row. If persistence was skipped (for example the new discoveryStartedAt guard or a failed status write) while the row still shows connected, the API returns a successful connected refresh with a cleared error even though discovery failed and tool sync was skipped.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c0bd4e4. Configure here.

.where(
and(
eq(mcpServers.id, serverId),
eq(mcpServers.workspaceId, workspaceId),
isNull(mcpServers.deletedAt)
)
)
.returning({
connectionStatus: mcpServers.connectionStatus,
lastConnected: mcpServers.lastConnected,
lastError: mcpServers.lastError,
toolCount: mcpServers.toolCount,
})

const connectionStatus = refreshedServer?.connectionStatus ?? 'error'
const lastError = refreshedServer ? refreshedServer.lastError : discoveryError
const toolCount = refreshedServer?.toolCount ?? discoveredTools.length

if (connectionStatus === 'connected') {
await mcpService.clearCache(workspaceId)
Expand Down
Loading