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,31 @@
|
||||
export {
|
||||
type McpServerOrchestrationErrorCode,
|
||||
type PerformCreateMcpServerParams,
|
||||
type PerformDeleteMcpServerParams,
|
||||
type PerformMcpServerResult,
|
||||
type PerformUpdateMcpServerParams,
|
||||
performCreateMcpServer,
|
||||
performDeleteMcpServer,
|
||||
performUpdateMcpServer,
|
||||
} from './server-lifecycle'
|
||||
export {
|
||||
type PerformCreateWorkflowMcpServerParams,
|
||||
type PerformCreateWorkflowMcpServerResult,
|
||||
type PerformCreateWorkflowMcpToolParams,
|
||||
type PerformCreateWorkflowMcpToolResult,
|
||||
type PerformDeleteWorkflowMcpServerParams,
|
||||
type PerformDeleteWorkflowMcpServerResult,
|
||||
type PerformDeleteWorkflowMcpToolParams,
|
||||
type PerformDeleteWorkflowMcpToolResult,
|
||||
type PerformUpdateWorkflowMcpServerParams,
|
||||
type PerformUpdateWorkflowMcpServerResult,
|
||||
type PerformUpdateWorkflowMcpToolParams,
|
||||
type PerformUpdateWorkflowMcpToolResult,
|
||||
performCreateWorkflowMcpServer,
|
||||
performCreateWorkflowMcpTool,
|
||||
performDeleteWorkflowMcpServer,
|
||||
performDeleteWorkflowMcpTool,
|
||||
performUpdateWorkflowMcpServer,
|
||||
performUpdateWorkflowMcpTool,
|
||||
type WorkflowMcpOrchestrationErrorCode,
|
||||
} from './workflow-mcp-lifecycle'
|
||||
@@ -0,0 +1,469 @@
|
||||
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' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { dbChainMock, dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/audit', () => ({
|
||||
AuditAction: {
|
||||
MCP_SERVER_UPDATED: 'mcp_server_updated',
|
||||
MCP_TOOL_UPDATED: 'mcp_tool_updated',
|
||||
},
|
||||
AuditResourceType: {
|
||||
MCP_SERVER: 'mcp_server',
|
||||
MCP_TOOL: 'mcp_tool',
|
||||
},
|
||||
recordAudit: vi.fn(),
|
||||
}))
|
||||
vi.mock('@sim/db', () => ({
|
||||
...dbChainMock,
|
||||
workflow: schemaMock.workflow,
|
||||
workflowMcpServer: schemaMock.workflowMcpServer,
|
||||
workflowMcpTool: schemaMock.workflowMcpTool,
|
||||
}))
|
||||
vi.mock('@sim/db/schema', () => schemaMock)
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn(),
|
||||
asc: vi.fn(),
|
||||
eq: vi.fn(),
|
||||
inArray: vi.fn(),
|
||||
isNull: vi.fn(),
|
||||
ne: vi.fn(),
|
||||
sql: Object.assign(vi.fn(), { raw: vi.fn((value: string) => value) }),
|
||||
}))
|
||||
vi.mock('@/lib/mcp/pubsub', () => ({ mcpPubSub: undefined }))
|
||||
vi.mock('@/lib/workflows/triggers/trigger-utils.server', () => ({
|
||||
hasValidStartBlock: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({
|
||||
generateParameterSchemaForWorkflow: vi.fn().mockResolvedValue({ type: 'object', properties: {} }),
|
||||
}))
|
||||
|
||||
import { MAX_MCP_PARAMETER_SCHEMA_BYTES, MAX_MCP_TOOLS_PER_SERVER } from '@/lib/mcp/constants'
|
||||
import {
|
||||
performCreateWorkflowMcpServer,
|
||||
performUpdateWorkflowMcpTool,
|
||||
} from '@/lib/mcp/orchestration/workflow-mcp-lifecycle'
|
||||
import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server'
|
||||
|
||||
describe('workflow MCP lifecycle orchestration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('rejects over-limit workflow server creation before inserting a server row', async () => {
|
||||
const result = await performCreateWorkflowMcpServer({
|
||||
workspaceId: 'workspace-1',
|
||||
userId: 'user-1',
|
||||
name: 'Too Many Tools',
|
||||
workflowIds: Array.from(
|
||||
{ length: MAX_MCP_TOOLS_PER_SERVER + 1 },
|
||||
(_, index) => `wf-${index}`
|
||||
),
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
errorCode: 'validation',
|
||||
})
|
||||
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects duplicate workflow IDs before inserting a server row', async () => {
|
||||
const result = await performCreateWorkflowMcpServer({
|
||||
workspaceId: 'workspace-1',
|
||||
userId: 'user-1',
|
||||
name: 'Duplicate Tools',
|
||||
workflowIds: ['wf-1', 'wf-1'],
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
errorCode: 'validation',
|
||||
})
|
||||
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rechecks deployed workflow state inside the create transaction', async () => {
|
||||
dbChainMockFns.where.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'wf-1',
|
||||
name: 'Workflow',
|
||||
description: null,
|
||||
isDeployed: true,
|
||||
workspaceId: 'workspace-1',
|
||||
deployedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
},
|
||||
])
|
||||
vi.mocked(hasValidStartBlock).mockResolvedValueOnce(true)
|
||||
dbChainMockFns.for.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'wf-1',
|
||||
name: 'Workflow',
|
||||
description: null,
|
||||
isDeployed: false,
|
||||
workspaceId: 'workspace-1',
|
||||
deployedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
},
|
||||
])
|
||||
|
||||
const result = await performCreateWorkflowMcpServer({
|
||||
workspaceId: 'workspace-1',
|
||||
userId: 'user-1',
|
||||
name: 'Server',
|
||||
workflowIds: ['wf-1'],
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
errorCode: 'validation',
|
||||
})
|
||||
expect(dbChainMockFns.transaction).toHaveBeenCalled()
|
||||
expect(dbChainMockFns.for).toHaveBeenCalledTimes(1)
|
||||
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects workflow MCP server fan-out above the per-workflow limit', async () => {
|
||||
vi.mocked(hasValidStartBlock).mockResolvedValueOnce(true)
|
||||
dbChainMockFns.where.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'wf-1',
|
||||
name: 'Workflow',
|
||||
description: null,
|
||||
isDeployed: true,
|
||||
workspaceId: 'workspace-1',
|
||||
deployedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
},
|
||||
])
|
||||
dbChainMockFns.for.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'wf-1',
|
||||
name: 'Workflow',
|
||||
description: null,
|
||||
isDeployed: true,
|
||||
workspaceId: 'workspace-1',
|
||||
deployedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
},
|
||||
])
|
||||
dbChainMockFns.groupBy.mockResolvedValueOnce([{ workflowId: 'wf-1', serverCount: 100 }])
|
||||
|
||||
const result = await performCreateWorkflowMcpServer({
|
||||
workspaceId: 'workspace-1',
|
||||
userId: 'user-1',
|
||||
name: 'Server',
|
||||
workflowIds: ['wf-1'],
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
errorCode: 'validation',
|
||||
})
|
||||
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows updating tool metadata when an unchanged stored schema exceeds the new cap', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'server-1' }]).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'tool-1',
|
||||
toolName: 'tool_a',
|
||||
toolDescription: null,
|
||||
parameterSchemaBytes: MAX_MCP_PARAMETER_SCHEMA_BYTES + 1,
|
||||
},
|
||||
])
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'tool-1',
|
||||
serverId: 'server-1',
|
||||
toolName: 'tool_a',
|
||||
toolDescription: 'Updated description',
|
||||
},
|
||||
])
|
||||
|
||||
const result = await performUpdateWorkflowMcpTool({
|
||||
workspaceId: 'workspace-1',
|
||||
userId: 'user-1',
|
||||
serverId: 'server-1',
|
||||
toolId: 'tool-1',
|
||||
toolDescription: 'Updated description',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
tool: {
|
||||
toolDescription: 'Updated description',
|
||||
},
|
||||
})
|
||||
expect(dbChainMockFns.update).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user