chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,267 @@
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 { 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 {
createMcpErrorResponse,
createMcpSuccessResponse,
MCP_TOOL_CORE_PARAMS,
} from '@/lib/mcp/utils'
const logger = createLogger('McpServerRefreshAPI')
export const dynamic = 'force-dynamic'
/** Schema stored in workflow blocks includes description from the tool. */
type StoredToolSchema = McpToolSchema & { description?: string }
interface StoredTool {
type: string
title: string
toolId: string
params: {
serverId: string
serverUrl?: string
toolName: string
serverName?: string
}
schema?: StoredToolSchema
[key: string]: unknown
}
interface SyncResult {
updatedCount: number
updatedWorkflowIds: string[]
}
interface ServerMetadata {
url?: string
name?: string
}
/**
* Syncs tool schemas and server metadata from discovered MCP tools to all
* workflow blocks using those tools. Updates stored serverUrl/serverName
* when the server's details have changed, preventing stale badges after
* a server URL edit.
*/
async function syncToolSchemasToWorkflows(
workspaceId: string,
serverId: string,
tools: McpTool[],
requestId: string,
serverMeta?: ServerMetadata
): Promise<SyncResult> {
const toolsByName = new Map(tools.map((t) => [t.name, t]))
const workspaceWorkflows = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.workspaceId, workspaceId))
const workflowIds = workspaceWorkflows.map((w) => w.id)
if (workflowIds.length === 0) return { updatedCount: 0, updatedWorkflowIds: [] }
const agentBlocks = await db
.select({
id: workflowBlocks.id,
workflowId: workflowBlocks.workflowId,
subBlocks: workflowBlocks.subBlocks,
})
.from(workflowBlocks)
.where(and(eq(workflowBlocks.type, 'agent'), inArray(workflowBlocks.workflowId, workflowIds)))
const updatedWorkflowIds = new Set<string>()
for (const block of agentBlocks) {
const subBlocks = block.subBlocks as Record<string, unknown> | null
if (!subBlocks) continue
const toolsSubBlock = subBlocks.tools as { value?: StoredTool[] } | undefined
if (!toolsSubBlock?.value || !Array.isArray(toolsSubBlock.value)) continue
let hasUpdates = false
const updatedTools = toolsSubBlock.value.map((tool) => {
if (tool.type !== 'mcp' || tool.params?.serverId !== serverId) {
return tool
}
const freshTool = toolsByName.get(tool.params.toolName)
if (!freshTool) return tool
const newSchema: StoredToolSchema = {
...freshTool.inputSchema,
description: freshTool.description,
}
const schemasMatch = JSON.stringify(tool.schema) === JSON.stringify(newSchema)
const urlChanged = serverMeta?.url != null && tool.params.serverUrl !== serverMeta.url
const nameChanged = serverMeta?.name != null && tool.params.serverName !== serverMeta.name
if (!schemasMatch || urlChanged || nameChanged) {
hasUpdates = true
const validParamKeys = new Set(Object.keys(newSchema.properties || {}))
const cleanedParams: Record<string, unknown> = {}
for (const [key, value] of Object.entries(tool.params || {})) {
if (MCP_TOOL_CORE_PARAMS.has(key) || validParamKeys.has(key)) {
cleanedParams[key] = value
}
}
if (urlChanged) cleanedParams.serverUrl = serverMeta.url
if (nameChanged) cleanedParams.serverName = serverMeta.name
return { ...tool, schema: newSchema, params: cleanedParams }
}
return tool
})
if (hasUpdates) {
const updatedSubBlocks = {
...subBlocks,
tools: { ...toolsSubBlock, value: updatedTools },
}
await db
.update(workflowBlocks)
.set({ subBlocks: updatedSubBlocks, updatedAt: new Date() })
.where(eq(workflowBlocks.id, block.id))
updatedWorkflowIds.add(block.workflowId)
}
}
if (updatedWorkflowIds.size > 0) {
logger.info(
`[${requestId}] Synced tool schemas to ${updatedWorkflowIds.size} workflow(s) for server ${serverId}`
)
}
return {
updatedCount: updatedWorkflowIds.size,
updatedWorkflowIds: Array.from(updatedWorkflowIds),
}
}
export const POST = withRouteHandler(
withMcpAuth<{ id: string }>('read')(
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
try {
const paramsValidation = mcpServerIdParamsSchema.safeParse(await params)
if (!paramsValidation.success) return validationErrorResponse(paramsValidation.error)
const { id: serverId } = paramsValidation.data
logger.info(`[${requestId}] Refreshing MCP server: ${serverId}`)
const [server] = await db
.select()
.from(mcpServers)
.where(
and(
eq(mcpServers.id, serverId),
eq(mcpServers.workspaceId, workspaceId),
isNull(mcpServers.deletedAt)
)
)
.limit(1)
if (!server) {
return createMcpErrorResponse(
new Error('Server not found or access denied'),
'Server not found',
404
)
}
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,
}
try {
discoveredTools = await mcpService.discoverServerTools(
userId,
serverId,
workspaceId,
true
)
connectionStatus = 'connected'
toolCount = discoveredTools.length
logger.info(`[${requestId}] Discovered ${toolCount} tools from server ${serverId}`)
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()
if (connectionStatus === 'connected') {
await mcpService.clearCache(workspaceId)
}
return createMcpSuccessResponse({
status: connectionStatus,
toolCount,
lastConnected: refreshedServer?.lastConnected?.toISOString() || null,
error: lastError,
workflowsUpdated: syncResult.updatedCount,
updatedWorkflowIds: syncResult.updatedWorkflowIds,
})
} catch (error) {
logger.error(`[${requestId}] Error refreshing MCP server:`, error)
return createMcpErrorResponse(toError(error), 'Failed to refresh MCP server', 500)
}
}
)
)
@@ -0,0 +1,96 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { updateMcpServerBodySchema } from '@/lib/api/contracts/mcp'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
mcpBodyReadErrorResponse,
readMcpJsonBodyWithLimit,
withMcpAuth,
} from '@/lib/mcp/middleware'
import { performUpdateMcpServer } from '@/lib/mcp/orchestration'
import {
createMcpErrorResponse,
createMcpSuccessResponse,
mcpOrchestrationStatus,
} from '@/lib/mcp/utils'
const logger = createLogger('McpServerAPI')
export const dynamic = 'force-dynamic'
/**
* PATCH - Update an MCP server in the workspace (requires write or admin permission)
*/
export const PATCH = withRouteHandler(
withMcpAuth<{ id: string }>('write')(
async (
request: NextRequest,
{ userId, userName, userEmail, workspaceId, requestId },
{ params }
) => {
try {
const { id: serverId } = await params
const rawBody = await readMcpJsonBodyWithLimit(request)
const parsedBody = updateMcpServerBodySchema.safeParse(rawBody)
if (!parsedBody.success) {
return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400)
}
const body = parsedBody.data
logger.info(
`[${requestId}] Updating MCP server: ${serverId} in workspace: ${workspaceId}`,
{
userId,
updates: Object.keys(body).filter((k) => k !== 'workspaceId'),
}
)
const result = await performUpdateMcpServer({
workspaceId,
userId,
actorName: userName,
actorEmail: userEmail,
serverId,
name: body.name,
description: body.description,
transport: body.transport,
url: body.url,
headers: body.headers,
timeout: body.timeout,
retries: body.retries,
enabled: body.enabled,
authType: body.authType,
oauthClientId: body.oauthClientId || null,
oauthClientIdProvided: body.oauthClientId !== undefined,
oauthClientSecret: body.oauthClientSecret,
oauthClientSecretProvided: body.oauthClientSecret !== undefined,
request,
})
if (!result.success || !result.server) {
return createMcpErrorResponse(
new Error('Server not found or access denied'),
result.error || 'Server not found',
mcpOrchestrationStatus(result.errorCode)
)
}
const updatedServer = result.server
logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`)
const { oauthClientSecret: _secret, ...rest } = updatedServer
return createMcpSuccessResponse({
server: { ...rest, hasOauthClientSecret: !!_secret },
})
} catch (error) {
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
if (bodyErrorResponse) return bodyErrorResponse
logger.error(`[${requestId}] Error updating MCP server:`, error)
return createMcpErrorResponse(toError(error), 'Failed to update MCP server', 500)
}
}
)
)
@@ -0,0 +1,89 @@
/**
* @vitest-environment node
*/
import type { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockPerformDeleteMcpServer } = vi.hoisted(() => ({
mockPerformDeleteMcpServer: vi.fn(),
}))
vi.mock('@sim/db', () => ({
db: {
select: vi.fn(),
},
}))
vi.mock('@/lib/mcp/middleware', () => ({
getParsedBody: () => undefined,
withMcpAuth:
() =>
(
handler: (
request: NextRequest,
context: {
userId: string
userName: string
userEmail: string
workspaceId: string
requestId: string
}
) => Promise<Response>
) =>
(request: NextRequest) =>
handler(request, {
userId: 'user-1',
userName: 'Test User',
userEmail: 'test@example.com',
workspaceId: 'workspace-1',
requestId: 'request-1',
}),
}))
vi.mock('@/lib/mcp/orchestration', () => ({
performCreateMcpServer: vi.fn(),
performDeleteMcpServer: mockPerformDeleteMcpServer,
}))
import { DELETE } from '@/app/api/mcp/servers/route'
function createDeleteRequest(serverId = 'server-1') {
return new Request(
`http://localhost:3000/api/mcp/servers?workspaceId=workspace-1&serverId=${serverId}`,
{ method: 'DELETE' }
) as NextRequest
}
describe('MCP servers DELETE route', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns 404 when orchestration reports a missing server', async () => {
mockPerformDeleteMcpServer.mockResolvedValueOnce({
success: false,
error: 'Server not found',
errorCode: 'not_found',
})
const response = await DELETE(createDeleteRequest())
const body = await response.json()
expect(response.status).toBe(404)
expect(body).toEqual({ success: false, error: 'Server not found' })
})
it('returns 500 when orchestration reports an internal delete failure', async () => {
mockPerformDeleteMcpServer.mockResolvedValueOnce({
success: false,
error: 'Failed to delete MCP server',
errorCode: 'internal',
})
const response = await DELETE(createDeleteRequest())
const body = await response.json()
expect(response.status).toBe(500)
expect(body).toEqual({ success: false, error: 'Failed to delete MCP server' })
})
})
+192
View File
@@ -0,0 +1,192 @@
import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { createMcpServerBodySchema, deleteMcpServerByQuerySchema } from '@/lib/api/contracts/mcp'
import { validationErrorResponse } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
mcpBodyReadErrorResponse,
readMcpJsonBodyWithLimit,
withMcpAuth,
} from '@/lib/mcp/middleware'
import { performCreateMcpServer, performDeleteMcpServer } from '@/lib/mcp/orchestration'
import {
createMcpErrorResponse,
createMcpSuccessResponse,
mcpOrchestrationStatus,
} from '@/lib/mcp/utils'
const logger = createLogger('McpServersAPI')
export const dynamic = 'force-dynamic'
/**
* GET - List all registered MCP servers for the workspace
*/
export const GET = withRouteHandler(
withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => {
try {
logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`)
const rows = await db
.select()
.from(mcpServers)
.where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt)))
const servers = rows.map(({ oauthClientSecret: _secret, ...rest }) => ({
...rest,
hasOauthClientSecret: !!_secret,
}))
logger.info(
`[${requestId}] Listed ${servers.length} MCP servers for workspace ${workspaceId}`
)
return createMcpSuccessResponse({ servers })
} catch (error) {
logger.error(`[${requestId}] Error listing MCP servers:`, error)
return createMcpErrorResponse(toError(error), 'Failed to list MCP servers', 500)
}
})
)
/**
* POST - Register a new MCP server for the workspace (requires write permission)
*/
export const POST = withRouteHandler(
withMcpAuth('write')(
async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => {
try {
const rawBody = await readMcpJsonBodyWithLimit(request)
const parsedBody = createMcpServerBodySchema.safeParse(rawBody)
if (!parsedBody.success) {
return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400)
}
const body = parsedBody.data
logger.info(`[${requestId}] Registering MCP server:`, {
name: body.name,
transport: body.transport,
workspaceId,
})
const sourceParam = body.source as string | undefined
const source =
sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined
if (!body.url) {
return createMcpErrorResponse(
new Error('url is required'),
'Missing required parameter',
400
)
}
const result = await performCreateMcpServer({
workspaceId,
userId,
actorName: userName,
actorEmail: userEmail,
name: body.name,
description: body.description,
transport: body.transport,
url: body.url,
headers: body.headers,
timeout: body.timeout,
retries: body.retries,
enabled: body.enabled,
source,
authType: body.authType,
oauthClientId: body.oauthClientId || null,
oauthClientIdProvided: body.oauthClientId !== undefined,
oauthClientSecret: body.oauthClientSecret,
oauthClientSecretProvided: body.oauthClientSecret !== undefined,
request,
})
if (!result.success || !result.serverId) {
return createMcpErrorResponse(
new Error(result.error || 'Failed to register MCP server'),
result.error || 'Failed to register MCP server',
mcpOrchestrationStatus(result.errorCode)
)
}
logger.info(
`[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${result.serverId})`
)
return createMcpSuccessResponse(
result.updated
? { serverId: result.serverId, updated: true, authType: result.authType }
: { serverId: result.serverId, authType: result.authType },
result.updated ? 200 : 201
)
} catch (error) {
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
if (bodyErrorResponse) return bodyErrorResponse
logger.error(`[${requestId}] Error registering MCP server:`, error)
return createMcpErrorResponse(toError(error), 'Failed to register MCP server', 500)
}
}
)
)
/**
* DELETE - Delete an MCP server from the workspace (requires write permission)
*/
export const DELETE = withRouteHandler(
withMcpAuth('write')(
async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => {
try {
const { searchParams } = new URL(request.url)
const queryValidation = deleteMcpServerByQuerySchema.safeParse(
Object.fromEntries(searchParams)
)
if (!queryValidation.success) return validationErrorResponse(queryValidation.error)
const query = queryValidation.data
const serverId = query.serverId
const sourceParam = query.source
const source =
sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined
if (!serverId) {
return createMcpErrorResponse(
new Error('serverId parameter is required'),
'Missing required parameter',
400
)
}
logger.info(
`[${requestId}] Deleting MCP server: ${serverId} from workspace: ${workspaceId}`
)
const result = await performDeleteMcpServer({
workspaceId,
userId,
actorName: userName,
actorEmail: userEmail,
serverId,
source,
request,
})
if (!result.success || !result.server) {
return createMcpErrorResponse(
new Error(result.error || 'Failed to delete MCP server'),
result.error || 'Failed to delete MCP server',
mcpOrchestrationStatus(result.errorCode)
)
}
logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`)
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
} catch (error) {
logger.error(`[${requestId}] Error deleting MCP server:`, error)
return createMcpErrorResponse(toError(error), 'Failed to delete MCP server', 500)
}
}
)
)
@@ -0,0 +1,249 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import type { NextRequest } from 'next/server'
import { mcpServerTestBodySchema } from '@/lib/api/contracts/mcp'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { McpClient } from '@/lib/mcp/client'
import {
McpDnsResolutionError,
McpDomainNotAllowedError,
McpSsrfError,
validateMcpDomain,
validateMcpServerSsrf,
} from '@/lib/mcp/domain-check'
import {
mcpBodyReadErrorResponse,
readMcpJsonBodyWithLimit,
withMcpAuth,
} from '@/lib/mcp/middleware'
import { detectMcpAuthType } from '@/lib/mcp/oauth'
import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config'
import type { McpAuthType, McpTransport } from '@/lib/mcp/types'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
const logger = createLogger('McpServerTestAPI')
export const dynamic = 'force-dynamic'
/**
* Check if transport type requires a URL
* All modern MCP connections use Streamable HTTP which requires a URL
*/
function isUrlBasedTransport(transport: McpTransport): boolean {
return transport === 'streamable-http'
}
interface TestConnectionResult {
success: boolean
error?: string
authRequired?: boolean
authType?: McpAuthType
serverInfo?: {
name: string
version: string
}
negotiatedVersion?: string
supportedCapabilities?: string[]
toolCount?: number
warnings?: string[]
}
/**
* Extracts a user-friendly error message from connection errors.
* Keeps diagnostic info (timeout, DNS, HTTP status) but strips
* verbose internals (Zod details, full response bodies, stack traces).
*/
function sanitizeConnectionError(error: unknown): string {
if (!(error instanceof Error)) {
return 'Unknown connection error'
}
const firstLine = error.message.split('\n')[0]
return truncate(firstLine, 200)
}
/**
* POST - Test connection to an MCP server before registering it
*/
export const POST = withRouteHandler(
withMcpAuth('write')(async (request: NextRequest, { userId, workspaceId, requestId }) => {
try {
const rawBody = await readMcpJsonBodyWithLimit(request)
const parsedBody = mcpServerTestBodySchema.safeParse(rawBody)
if (!parsedBody.success) {
return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400)
}
const body = parsedBody.data
logger.info(`[${requestId}] Testing MCP server connection:`, {
name: body.name,
transport: body.transport,
url: body.url ? `${body.url.substring(0, 50)}...` : undefined, // Partial URL for security
workspaceId,
})
if (isUrlBasedTransport(body.transport) && !body.url) {
return createMcpErrorResponse(
new Error('URL is required for HTTP-based transports'),
'Missing required URL',
400
)
}
try {
validateMcpDomain(body.url)
} catch (e) {
if (e instanceof McpDomainNotAllowedError) {
return createMcpErrorResponse(e, e.message, 403)
}
throw e
}
try {
// Initial pre-resolution check; the authoritative resolved IP is
// captured after env-var resolution below and used to pin the
// connection against DNS rebinding.
await validateMcpServerSsrf(body.url)
} catch (e) {
if (e instanceof McpDnsResolutionError) {
return createMcpErrorResponse(e, e.message, 502)
}
if (e instanceof McpSsrfError) {
return createMcpErrorResponse(e, e.message, 403)
}
throw e
}
// Build initial config for resolution
const initialConfig = {
id: `test-${requestId}`,
name: body.name,
transport: body.transport,
url: body.url,
headers: body.headers || {},
timeout: body.timeout || 10000,
retries: 1, // Only one retry for tests
enabled: true,
}
// Resolve env vars using shared utility (non-strict mode for testing)
const { config: testConfig, missingVars } = await resolveMcpConfigEnvVars(
initialConfig,
userId,
workspaceId,
{ strict: false }
)
if (missingVars.length > 0) {
logger.warn(`[${requestId}] Some environment variables not found:`, { missingVars })
}
// Re-validate domain and SSRF after env var resolution
try {
validateMcpDomain(testConfig.url)
} catch (e) {
if (e instanceof McpDomainNotAllowedError) {
return createMcpErrorResponse(e, e.message, 403)
}
throw e
}
let resolvedIP: string | null
try {
resolvedIP = await validateMcpServerSsrf(testConfig.url)
} catch (e) {
if (e instanceof McpDnsResolutionError) {
return createMcpErrorResponse(e, e.message, 502)
}
if (e instanceof McpSsrfError) {
return createMcpErrorResponse(e, e.message, 403)
}
throw e
}
const testSecurityPolicy = {
requireConsent: false,
auditLevel: 'none' as const,
maxToolExecutionsPerHour: 0,
}
const result: TestConnectionResult = { success: false }
// Skip unauth connect when the server returns an RFC 9728 OAuth challenge.
if (testConfig.url) {
const detectedAuthType = await detectMcpAuthType(testConfig.url, resolvedIP)
if (detectedAuthType === 'oauth') {
result.authRequired = true
result.authType = 'oauth'
return createMcpSuccessResponse(result, 200)
}
result.authType = detectedAuthType
}
let client: McpClient | null = null
try {
client = new McpClient({
config: testConfig,
securityPolicy: testSecurityPolicy,
resolvedIP: resolvedIP ?? undefined,
})
await client.connect()
result.negotiatedVersion = client.getNegotiatedVersion()
try {
const tools = await client.listTools()
result.toolCount = tools.length
result.success = true
} catch (toolError) {
logger.warn(`[${requestId}] Connection established but could not list tools:`, toolError)
result.success = false
result.error = 'Connection established but could not list tools'
result.warnings = result.warnings || []
result.warnings.push(
'Server connected but tool listing failed - connection may be incomplete'
)
}
const clientVersionInfo = McpClient.getVersionInfo()
if (result.negotiatedVersion !== clientVersionInfo.preferred) {
result.warnings = result.warnings || []
result.warnings.push(
`Server uses protocol version '${result.negotiatedVersion}' instead of preferred '${clientVersionInfo.preferred}'`
)
}
logger.info(`[${requestId}] MCP server test successful:`, {
name: body.name,
negotiatedVersion: result.negotiatedVersion,
toolCount: result.toolCount,
capabilities: result.supportedCapabilities,
})
} catch (error) {
logger.warn(`[${requestId}] MCP server test failed:`, error)
result.success = false
result.error = sanitizeConnectionError(error)
} finally {
if (client) {
try {
await client.disconnect()
} catch (disconnectError) {
logger.debug(`[${requestId}] Test client disconnect error (expected):`, disconnectError)
}
}
}
return createMcpSuccessResponse(result, result.success ? 200 : 400)
} catch (error) {
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
if (bodyErrorResponse) return bodyErrorResponse
logger.error(`[${requestId}] Error testing MCP server connection:`, error)
return createMcpErrorResponse(toError(error), 'Failed to test server connection', 500)
}
})
)