d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
470 lines
16 KiB
TypeScript
470 lines
16 KiB
TypeScript
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
|
import { db, mcpServers } from '@sim/db'
|
|
import { mcpServerOauth } from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { generateId } from '@sim/utils/id'
|
|
import { and, eq, isNull } from 'drizzle-orm'
|
|
import type { NextRequest } from 'next/server'
|
|
import { encryptSecret } from '@/lib/core/security/encryption'
|
|
import {
|
|
McpDnsResolutionError,
|
|
McpDomainNotAllowedError,
|
|
McpSsrfError,
|
|
validateMcpDomain,
|
|
validateMcpServerSsrf,
|
|
} from '@/lib/mcp/domain-check'
|
|
import { detectMcpAuthType, oauthCredsChanged, revokeMcpOauthTokens } from '@/lib/mcp/oauth'
|
|
import { mcpService } from '@/lib/mcp/service'
|
|
import type { McpAuthType } from '@/lib/mcp/types'
|
|
import { generateMcpServerId } from '@/lib/mcp/utils'
|
|
import { captureServerEvent } from '@/lib/posthog/server'
|
|
|
|
const logger = createLogger('McpServerOrchestration')
|
|
|
|
export type McpServerOrchestrationErrorCode = 'not_found' | 'forbidden' | 'bad_gateway' | 'internal'
|
|
|
|
type McpServerTransport = (typeof mcpServers.$inferInsert)['transport']
|
|
|
|
interface ActorMetadata {
|
|
actorName?: string | null
|
|
actorEmail?: string | null
|
|
request?: NextRequest
|
|
}
|
|
|
|
export interface PerformCreateMcpServerParams extends ActorMetadata {
|
|
workspaceId: string
|
|
userId: string
|
|
name: string
|
|
description?: string | null
|
|
transport?: McpServerTransport
|
|
url: string
|
|
headers?: Record<string, string>
|
|
timeout?: number
|
|
retries?: number
|
|
enabled?: boolean
|
|
source?: string
|
|
authType?: McpAuthType
|
|
oauthClientId?: string | null
|
|
oauthClientIdProvided?: boolean
|
|
oauthClientSecret?: string | null
|
|
oauthClientSecretProvided?: boolean
|
|
}
|
|
|
|
export interface PerformUpdateMcpServerParams extends ActorMetadata {
|
|
workspaceId: string
|
|
userId: string
|
|
serverId: string
|
|
name?: string
|
|
description?: string | null
|
|
transport?: McpServerTransport
|
|
url?: string
|
|
headers?: Record<string, string>
|
|
timeout?: number
|
|
retries?: number
|
|
enabled?: boolean
|
|
authType?: McpAuthType
|
|
oauthClientId?: string | null
|
|
oauthClientIdProvided?: boolean
|
|
oauthClientSecret?: string | null
|
|
oauthClientSecretProvided?: boolean
|
|
}
|
|
|
|
export interface PerformDeleteMcpServerParams extends ActorMetadata {
|
|
workspaceId: string
|
|
userId: string
|
|
serverId: string
|
|
source?: string
|
|
}
|
|
|
|
export interface PerformMcpServerResult {
|
|
success: boolean
|
|
error?: string
|
|
errorCode?: McpServerOrchestrationErrorCode
|
|
serverId?: string
|
|
server?: typeof mcpServers.$inferSelect
|
|
updated?: boolean
|
|
authType?: McpAuthType
|
|
}
|
|
|
|
type ValidateMcpServerUrlResult =
|
|
| { ok: true; resolvedIP: string | null }
|
|
| { ok: false; result: PerformMcpServerResult }
|
|
|
|
async function validateMcpServerUrl(url: string): Promise<ValidateMcpServerUrlResult> {
|
|
try {
|
|
validateMcpDomain(url)
|
|
const resolvedIP = await validateMcpServerSsrf(url)
|
|
return { ok: true, resolvedIP }
|
|
} catch (error) {
|
|
if (error instanceof McpDomainNotAllowedError || error instanceof McpSsrfError) {
|
|
return { ok: false, result: { success: false, error: error.message, errorCode: 'forbidden' } }
|
|
}
|
|
if (error instanceof McpDnsResolutionError) {
|
|
return {
|
|
ok: false,
|
|
result: { success: false, error: error.message, errorCode: 'bad_gateway' },
|
|
}
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export async function performCreateMcpServer(
|
|
params: PerformCreateMcpServerParams
|
|
): Promise<PerformMcpServerResult> {
|
|
const validation = await validateMcpServerUrl(params.url)
|
|
if (!validation.ok) return validation.result
|
|
const validatedIP = validation.resolvedIP
|
|
|
|
const transport = params.transport || 'streamable-http'
|
|
const timeout = params.timeout || 30000
|
|
const retries = params.retries || 3
|
|
const enabled = params.enabled !== false
|
|
const serverId = params.url ? generateMcpServerId(params.workspaceId, params.url) : generateId()
|
|
|
|
const oauthClientSecretEncrypted = params.oauthClientSecret
|
|
? (await encryptSecret(params.oauthClientSecret)).encrypted
|
|
: null
|
|
const oauthClientId = params.oauthClientId || null
|
|
const hasHeaders = params.headers && Object.keys(params.headers).length > 0
|
|
|
|
try {
|
|
const [existingServer] = await db
|
|
.select({
|
|
id: mcpServers.id,
|
|
deletedAt: mcpServers.deletedAt,
|
|
url: mcpServers.url,
|
|
authType: mcpServers.authType,
|
|
oauthClientId: mcpServers.oauthClientId,
|
|
oauthClientSecret: mcpServers.oauthClientSecret,
|
|
})
|
|
.from(mcpServers)
|
|
.where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, params.workspaceId)))
|
|
.limit(1)
|
|
|
|
const urlChanged = existingServer ? existingServer.url !== params.url : true
|
|
|
|
let resolvedAuthType: McpAuthType = params.authType ?? 'headers'
|
|
if (!params.authType) {
|
|
if (existingServer && !urlChanged) {
|
|
resolvedAuthType = (existingServer.authType ?? 'headers') as McpAuthType
|
|
} else if (params.url && !hasHeaders) {
|
|
try {
|
|
resolvedAuthType = await detectMcpAuthType(params.url, validatedIP)
|
|
} catch (e) {
|
|
logger.warn('Probe failed, defaulting to headers', { url: params.url, error: e })
|
|
resolvedAuthType = 'headers'
|
|
}
|
|
}
|
|
}
|
|
if (params.oauthClientId) resolvedAuthType = 'oauth'
|
|
|
|
if (existingServer) {
|
|
const credsChanged = await oauthCredsChanged({
|
|
incomingClientId: oauthClientId,
|
|
incomingClientIdProvided: params.oauthClientIdProvided ?? false,
|
|
incomingClientSecret: params.oauthClientSecret,
|
|
incomingClientSecretProvided: params.oauthClientSecretProvided ?? false,
|
|
currentClientId: existingServer.oauthClientId,
|
|
currentEncryptedClientSecret: existingServer.oauthClientSecret,
|
|
})
|
|
const isRevival = existingServer.deletedAt !== null
|
|
const shouldClearOauth = urlChanged || credsChanged || isRevival
|
|
|
|
if (shouldClearOauth) await revokeMcpOauthTokens(serverId)
|
|
|
|
await db.transaction(async (tx) => {
|
|
if (shouldClearOauth) {
|
|
await tx.delete(mcpServerOauth).where(eq(mcpServerOauth.mcpServerId, serverId))
|
|
}
|
|
const updateValues: Record<string, unknown> = {
|
|
name: params.name,
|
|
description: params.description,
|
|
transport,
|
|
url: params.url,
|
|
authType: resolvedAuthType,
|
|
headers: params.headers || {},
|
|
timeout,
|
|
retries,
|
|
enabled,
|
|
updatedAt: new Date(),
|
|
deletedAt: null,
|
|
}
|
|
if (resolvedAuthType === 'oauth') {
|
|
if (shouldClearOauth) {
|
|
updateValues.connectionStatus = 'disconnected'
|
|
updateValues.lastConnected = null
|
|
}
|
|
} else {
|
|
updateValues.connectionStatus = 'connected'
|
|
updateValues.lastConnected = new Date()
|
|
}
|
|
if (params.oauthClientIdProvided) updateValues.oauthClientId = oauthClientId
|
|
if (params.oauthClientSecretProvided) {
|
|
updateValues.oauthClientSecret = oauthClientSecretEncrypted
|
|
}
|
|
await tx.update(mcpServers).set(updateValues).where(eq(mcpServers.id, serverId))
|
|
})
|
|
|
|
await mcpService.clearCache(params.workspaceId)
|
|
return { success: true, serverId, updated: true, authType: resolvedAuthType }
|
|
}
|
|
|
|
await db.insert(mcpServers).values({
|
|
id: serverId,
|
|
workspaceId: params.workspaceId,
|
|
createdBy: params.userId,
|
|
name: params.name,
|
|
description: params.description,
|
|
transport,
|
|
url: params.url,
|
|
authType: resolvedAuthType,
|
|
oauthClientId,
|
|
oauthClientSecret: oauthClientSecretEncrypted,
|
|
headers: params.headers || {},
|
|
timeout,
|
|
retries,
|
|
enabled,
|
|
connectionStatus: resolvedAuthType === 'oauth' ? 'disconnected' : 'connected',
|
|
lastConnected: resolvedAuthType === 'oauth' ? null : new Date(),
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
|
|
await mcpService.clearCache(params.workspaceId)
|
|
|
|
try {
|
|
const { PlatformEvents } = await import('@/lib/core/telemetry')
|
|
PlatformEvents.mcpServerAdded({
|
|
serverId,
|
|
serverName: params.name,
|
|
transport,
|
|
workspaceId: params.workspaceId,
|
|
})
|
|
} catch {}
|
|
|
|
const source =
|
|
params.source === 'settings' || params.source === 'tool_input' ? params.source : undefined
|
|
|
|
captureServerEvent(
|
|
params.userId,
|
|
'mcp_server_connected',
|
|
{ workspace_id: params.workspaceId, server_name: params.name, transport, source },
|
|
{
|
|
groups: { workspace: params.workspaceId },
|
|
setOnce: { first_mcp_connected_at: new Date().toISOString() },
|
|
}
|
|
)
|
|
|
|
recordAudit({
|
|
workspaceId: params.workspaceId,
|
|
actorId: params.userId,
|
|
actorName: params.actorName ?? undefined,
|
|
actorEmail: params.actorEmail ?? undefined,
|
|
action: AuditAction.MCP_SERVER_ADDED,
|
|
resourceType: AuditResourceType.MCP_SERVER,
|
|
resourceId: serverId,
|
|
resourceName: params.name,
|
|
description: `Added MCP server "${params.name}"`,
|
|
metadata: {
|
|
serverName: params.name,
|
|
transport,
|
|
url: params.url,
|
|
timeout,
|
|
retries,
|
|
source,
|
|
},
|
|
request: params.request,
|
|
})
|
|
|
|
return { success: true, serverId, updated: false, authType: resolvedAuthType }
|
|
} catch (error) {
|
|
logger.error('Failed to create MCP server', { error })
|
|
return { success: false, error: 'Failed to register MCP server', errorCode: 'internal' }
|
|
}
|
|
}
|
|
|
|
export async function performUpdateMcpServer(
|
|
params: PerformUpdateMcpServerParams
|
|
): Promise<PerformMcpServerResult> {
|
|
if (params.url) {
|
|
const validation = await validateMcpServerUrl(params.url)
|
|
if (!validation.ok) return validation.result
|
|
}
|
|
|
|
const oauthClientSecretEncrypted =
|
|
params.oauthClientSecretProvided && params.oauthClientSecret
|
|
? (await encryptSecret(params.oauthClientSecret)).encrypted
|
|
: null
|
|
|
|
const updateData: Partial<typeof mcpServers.$inferInsert> = { updatedAt: new Date() }
|
|
if (params.name !== undefined) updateData.name = params.name
|
|
if (params.description !== undefined) updateData.description = params.description
|
|
if (params.transport !== undefined) updateData.transport = params.transport
|
|
if (params.url !== undefined) updateData.url = params.url
|
|
if (params.headers !== undefined) updateData.headers = params.headers
|
|
if (params.timeout !== undefined) updateData.timeout = params.timeout
|
|
if (params.retries !== undefined) updateData.retries = params.retries
|
|
if (params.enabled !== undefined) updateData.enabled = params.enabled
|
|
if (params.authType !== undefined) updateData.authType = params.authType
|
|
if (params.oauthClientIdProvided) updateData.oauthClientId = params.oauthClientId || null
|
|
if (params.oauthClientSecretProvided) {
|
|
updateData.oauthClientSecret = oauthClientSecretEncrypted
|
|
}
|
|
|
|
try {
|
|
const [currentServer] = await db
|
|
.select({
|
|
url: mcpServers.url,
|
|
authType: mcpServers.authType,
|
|
oauthClientId: mcpServers.oauthClientId,
|
|
oauthClientSecret: mcpServers.oauthClientSecret,
|
|
})
|
|
.from(mcpServers)
|
|
.where(
|
|
and(
|
|
eq(mcpServers.id, params.serverId),
|
|
eq(mcpServers.workspaceId, params.workspaceId),
|
|
isNull(mcpServers.deletedAt)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
if (!currentServer) return { success: false, error: 'Server not found', errorCode: 'not_found' }
|
|
|
|
if (
|
|
params.oauthClientId &&
|
|
currentServer.authType !== 'oauth' &&
|
|
updateData.authType === undefined
|
|
) {
|
|
updateData.authType = 'oauth'
|
|
}
|
|
|
|
const urlChanged = params.url !== undefined && currentServer.url !== params.url
|
|
const credsChanged = await oauthCredsChanged({
|
|
incomingClientId: params.oauthClientId,
|
|
incomingClientIdProvided: params.oauthClientIdProvided ?? false,
|
|
incomingClientSecret: params.oauthClientSecret,
|
|
incomingClientSecretProvided: params.oauthClientSecretProvided ?? false,
|
|
currentClientId: currentServer.oauthClientId,
|
|
currentEncryptedClientSecret: currentServer.oauthClientSecret,
|
|
})
|
|
const shouldClearOauth = urlChanged || credsChanged
|
|
const resolvedAuthType = (updateData.authType ?? currentServer.authType) as McpAuthType
|
|
if (shouldClearOauth && resolvedAuthType === 'oauth') {
|
|
updateData.connectionStatus = 'disconnected'
|
|
updateData.lastConnected = null
|
|
}
|
|
|
|
if (shouldClearOauth) await revokeMcpOauthTokens(params.serverId)
|
|
|
|
const server = await db.transaction(async (tx) => {
|
|
const [updated] = await tx
|
|
.update(mcpServers)
|
|
.set(updateData)
|
|
.where(
|
|
and(
|
|
eq(mcpServers.id, params.serverId),
|
|
eq(mcpServers.workspaceId, params.workspaceId),
|
|
isNull(mcpServers.deletedAt)
|
|
)
|
|
)
|
|
.returning()
|
|
|
|
if (!updated) return null
|
|
|
|
if (shouldClearOauth) {
|
|
await tx.delete(mcpServerOauth).where(eq(mcpServerOauth.mcpServerId, params.serverId))
|
|
}
|
|
return updated
|
|
})
|
|
|
|
if (!server) return { success: false, error: 'Server not found', errorCode: 'not_found' }
|
|
|
|
const shouldClearCache =
|
|
urlChanged ||
|
|
credsChanged ||
|
|
params.enabled !== undefined ||
|
|
params.headers !== undefined ||
|
|
params.timeout !== undefined ||
|
|
params.retries !== undefined
|
|
|
|
if (shouldClearCache) await mcpService.clearCache(params.workspaceId)
|
|
|
|
recordAudit({
|
|
workspaceId: params.workspaceId,
|
|
actorId: params.userId,
|
|
actorName: params.actorName ?? undefined,
|
|
actorEmail: params.actorEmail ?? undefined,
|
|
action: AuditAction.MCP_SERVER_UPDATED,
|
|
resourceType: AuditResourceType.MCP_SERVER,
|
|
resourceId: params.serverId,
|
|
resourceName: server.name || params.serverId,
|
|
description: `Updated MCP server "${server.name || params.serverId}"`,
|
|
metadata: {
|
|
serverName: server.name,
|
|
transport: server.transport,
|
|
url: server.url,
|
|
updatedFields: Object.keys(updateData).filter((key) => key !== 'updatedAt'),
|
|
},
|
|
request: params.request,
|
|
})
|
|
|
|
return { success: true, server }
|
|
} catch (error) {
|
|
logger.error('Failed to update MCP server', { error })
|
|
return { success: false, error: 'Failed to update MCP server', errorCode: 'internal' }
|
|
}
|
|
}
|
|
|
|
export async function performDeleteMcpServer(
|
|
params: PerformDeleteMcpServerParams
|
|
): Promise<PerformMcpServerResult> {
|
|
try {
|
|
await revokeMcpOauthTokens(params.serverId)
|
|
const [server] = await db
|
|
.delete(mcpServers)
|
|
.where(
|
|
and(eq(mcpServers.id, params.serverId), eq(mcpServers.workspaceId, params.workspaceId))
|
|
)
|
|
.returning()
|
|
|
|
if (!server) return { success: false, error: 'Server not found', errorCode: 'not_found' }
|
|
|
|
await mcpService.clearCache(params.workspaceId)
|
|
const source =
|
|
params.source === 'settings' || params.source === 'tool_input' ? params.source : undefined
|
|
|
|
captureServerEvent(
|
|
params.userId,
|
|
'mcp_server_disconnected',
|
|
{ workspace_id: params.workspaceId, server_name: server.name, source },
|
|
{ groups: { workspace: params.workspaceId } }
|
|
)
|
|
|
|
recordAudit({
|
|
workspaceId: params.workspaceId,
|
|
actorId: params.userId,
|
|
actorName: params.actorName ?? undefined,
|
|
actorEmail: params.actorEmail ?? undefined,
|
|
action: AuditAction.MCP_SERVER_REMOVED,
|
|
resourceType: AuditResourceType.MCP_SERVER,
|
|
resourceId: params.serverId,
|
|
resourceName: server.name,
|
|
description: `Removed MCP server "${server.name}"`,
|
|
metadata: {
|
|
serverName: server.name,
|
|
transport: server.transport,
|
|
url: server.url,
|
|
source,
|
|
},
|
|
request: params.request,
|
|
})
|
|
|
|
return { success: true, server }
|
|
} catch (error) {
|
|
logger.error('Failed to delete MCP server', { error })
|
|
return { success: false, error: 'Failed to delete MCP server', errorCode: 'internal' }
|
|
}
|
|
}
|