Files
sim/apps/sim/app/api/mcp/servers/route.ts
Waleed 7c7c0fd955 feat(audit-log): add audit events for templates, billing, credentials, env, deployments, passwords (#3246)
* feat(audit-log): add audit events for templates, billing, credentials, env, deployments, passwords

* improvement(audit-log): add actorName/actorEmail to all recordAudit calls

* fix(audit-log): resolve user for password reset, add CREDENTIAL_SET_INVITATION_RESENT action

* fix(audit-log): add workspaceId to deployment activation audit

* improvement(audit-log): use better-auth callback for password reset audit, remove cast

- Move password reset audit to onPasswordReset callback in auth config
  instead of coupling to better-auth's verification table internals
- Remove ugly double-cast on workflowData.workspaceId in deployment activation

* fix(audit-log): add missing actorName/actorEmail to workflow duplicate

* improvement(audit-log): add resourceName to credential set invitation accept
2026-02-18 11:53:08 -08:00

251 lines
7.8 KiB
TypeScript

import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpService } from '@/lib/mcp/service'
import {
createMcpErrorResponse,
createMcpSuccessResponse,
generateMcpServerId,
} 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 = withMcpAuth('read')(
async (request: NextRequest, { userId, workspaceId, requestId }) => {
try {
logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`)
const servers = await db
.select()
.from(mcpServers)
.where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt)))
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(
error instanceof Error ? error : new Error('Failed to list MCP servers'),
'Failed to list MCP servers',
500
)
}
}
)
/**
* 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, userName, userEmail, workspaceId, requestId }) => {
try {
const body = getParsedBody(request) || (await request.json())
logger.info(`[${requestId}] Registering MCP server:`, {
name: body.name,
transport: body.transport,
workspaceId,
})
if (!body.name || !body.transport) {
return createMcpErrorResponse(
new Error('Missing required fields: name or transport'),
'Missing required fields',
400
)
}
try {
validateMcpDomain(body.url)
} catch (e) {
if (e instanceof McpDomainNotAllowedError) {
return createMcpErrorResponse(e, e.message, 403)
}
throw e
}
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,
connectionStatus: 'connected',
lastConnected: new Date(),
updatedAt: new Date(),
deletedAt: null,
})
.where(eq(mcpServers.id, serverId))
await mcpService.clearCache(workspaceId)
logger.info(
`[${requestId}] Successfully updated MCP server: ${body.name} (ID: ${serverId})`
)
return createMcpSuccessResponse({ serverId, updated: true }, 200)
}
await db
.insert(mcpServers)
.values({
id: serverId,
workspaceId,
createdBy: userId,
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,
connectionStatus: 'connected',
lastConnected: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
await mcpService.clearCache(workspaceId)
logger.info(
`[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${serverId})`
)
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.mcpServerAdded({
serverId,
serverName: body.name,
transport: body.transport,
workspaceId,
})
} catch (_e) {
// Silently fail
}
recordAudit({
workspaceId,
actorId: userId,
actorName: userName,
actorEmail: userEmail,
action: AuditAction.MCP_SERVER_ADDED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
resourceName: body.name,
description: `Added MCP server "${body.name}"`,
metadata: { serverName: body.name, transport: body.transport },
request,
})
return createMcpSuccessResponse({ serverId }, 201)
} catch (error) {
logger.error(`[${requestId}] Error registering MCP server:`, error)
return createMcpErrorResponse(
error instanceof Error ? error : new Error('Failed to register MCP server'),
'Failed to register MCP server',
500
)
}
}
)
/**
* DELETE - Delete an MCP server from the workspace (requires admin permission)
*/
export const DELETE = withMcpAuth('admin')(
async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => {
try {
const { searchParams } = new URL(request.url)
const serverId = searchParams.get('serverId')
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 [deletedServer] = await db
.delete(mcpServers)
.where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId)))
.returning()
if (!deletedServer) {
return createMcpErrorResponse(
new Error('Server not found or access denied'),
'Server not found',
404
)
}
await mcpService.clearCache(workspaceId)
logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`)
recordAudit({
workspaceId,
actorId: userId,
actorName: userName,
actorEmail: userEmail,
action: AuditAction.MCP_SERVER_REMOVED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId!,
resourceName: deletedServer.name,
description: `Removed MCP server "${deletedServer.name}"`,
request,
})
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
} catch (error) {
logger.error(`[${requestId}] Error deleting MCP server:`, error)
return createMcpErrorResponse(
error instanceof Error ? error : new Error('Failed to delete MCP server'),
'Failed to delete MCP server',
500
)
}
}
)