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
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:
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
)
|
||||
Reference in New Issue
Block a user