mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-11 07:58:06 -05:00
Compare commits
11 Commits
v0.5.50
...
SIM-514-us
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f81c0ba9bf | ||
|
|
6c10f31a40 | ||
|
|
896e9674c2 | ||
|
|
f2450d3c26 | ||
|
|
cfbe4a4790 | ||
|
|
1f22d7a9ec | ||
|
|
2259bfcb8f | ||
|
|
85af046754 | ||
|
|
57f3697dd5 | ||
|
|
a15ac7360d | ||
|
|
93217438ef |
129
apps/sim/app/api/mcp/discover/route.ts
Normal file
129
apps/sim/app/api/mcp/discover/route.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, workflowMcpServer, workspace } from '@sim/db/schema'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('McpDiscoverAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* GET - Discover all published MCP servers available to the authenticated user
|
||||
*
|
||||
* This endpoint allows external MCP clients to discover available servers
|
||||
* using just their API key, without needing to know workspace IDs.
|
||||
*
|
||||
* Authentication: API Key (X-API-Key header) or Session
|
||||
*
|
||||
* Returns all published MCP servers from workspaces the user has access to.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Authenticate the request
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Authentication required. Provide X-API-Key header with your Sim API key.',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const userId = auth.userId
|
||||
|
||||
// Get all workspaces the user has access to via permissions table
|
||||
const userWorkspacePermissions = await db
|
||||
.select({ entityId: permissions.entityId })
|
||||
.from(permissions)
|
||||
.where(and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace')))
|
||||
|
||||
const workspaceIds = userWorkspacePermissions.map((w) => w.entityId)
|
||||
|
||||
if (workspaceIds.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
servers: [],
|
||||
message: 'No workspaces found for this user',
|
||||
})
|
||||
}
|
||||
|
||||
// Get all published MCP servers from user's workspaces with tool count
|
||||
const servers = await db
|
||||
.select({
|
||||
id: workflowMcpServer.id,
|
||||
name: workflowMcpServer.name,
|
||||
description: workflowMcpServer.description,
|
||||
workspaceId: workflowMcpServer.workspaceId,
|
||||
workspaceName: workspace.name,
|
||||
isPublished: workflowMcpServer.isPublished,
|
||||
publishedAt: workflowMcpServer.publishedAt,
|
||||
toolCount: sql<number>`(
|
||||
SELECT COUNT(*)::int
|
||||
FROM "workflow_mcp_tool"
|
||||
WHERE "workflow_mcp_tool"."server_id" = "workflow_mcp_server"."id"
|
||||
)`.as('tool_count'),
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.leftJoin(workspace, eq(workflowMcpServer.workspaceId, workspace.id))
|
||||
.where(
|
||||
and(
|
||||
eq(workflowMcpServer.isPublished, true),
|
||||
sql`${workflowMcpServer.workspaceId} IN ${workspaceIds}`
|
||||
)
|
||||
)
|
||||
.orderBy(workflowMcpServer.name)
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
// Format response with connection URLs
|
||||
const formattedServers = servers.map((server) => ({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
workspace: {
|
||||
id: server.workspaceId,
|
||||
name: server.workspaceName,
|
||||
},
|
||||
toolCount: server.toolCount || 0,
|
||||
publishedAt: server.publishedAt,
|
||||
urls: {
|
||||
http: `${baseUrl}/api/mcp/serve/${server.id}`,
|
||||
sse: `${baseUrl}/api/mcp/serve/${server.id}/sse`,
|
||||
},
|
||||
}))
|
||||
|
||||
logger.info(`User ${userId} discovered ${formattedServers.length} MCP servers`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
servers: formattedServers,
|
||||
authentication: {
|
||||
method: 'API Key',
|
||||
header: 'X-API-Key',
|
||||
description: 'Include your Sim API key in the X-API-Key header for all MCP requests',
|
||||
},
|
||||
usage: {
|
||||
listTools: {
|
||||
method: 'POST',
|
||||
body: '{"jsonrpc":"2.0","id":1,"method":"tools/list"}',
|
||||
},
|
||||
callTool: {
|
||||
method: 'POST',
|
||||
body: '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"TOOL_NAME","arguments":{}}}',
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error discovering MCP servers:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to discover MCP servers' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
360
apps/sim/app/api/mcp/serve/[serverId]/route.ts
Normal file
360
apps/sim/app/api/mcp/serve/[serverId]/route.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServeAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface RouteParams {
|
||||
serverId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP JSON-RPC Request
|
||||
*/
|
||||
interface JsonRpcRequest {
|
||||
jsonrpc: '2.0'
|
||||
id: string | number
|
||||
method: string
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP JSON-RPC Response
|
||||
*/
|
||||
interface JsonRpcResponse {
|
||||
jsonrpc: '2.0'
|
||||
id: string | number
|
||||
result?: unknown
|
||||
error?: {
|
||||
code: number
|
||||
message: string
|
||||
data?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create JSON-RPC success response
|
||||
*/
|
||||
function createJsonRpcResponse(id: string | number, result: unknown): JsonRpcResponse {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create JSON-RPC error response
|
||||
*/
|
||||
function createJsonRpcError(
|
||||
id: string | number,
|
||||
code: number,
|
||||
message: string,
|
||||
data?: unknown
|
||||
): JsonRpcResponse {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: { code, message, data },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the server exists and is published
|
||||
*/
|
||||
async function validateServer(serverId: string) {
|
||||
const [server] = await db
|
||||
.select({
|
||||
id: workflowMcpServer.id,
|
||||
name: workflowMcpServer.name,
|
||||
workspaceId: workflowMcpServer.workspaceId,
|
||||
isPublished: workflowMcpServer.isPublished,
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.where(eq(workflowMcpServer.id, serverId))
|
||||
.limit(1)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - Server info and capabilities (MCP initialize)
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { serverId } = await params
|
||||
|
||||
try {
|
||||
const server = await validateServer(serverId)
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!server.isPublished) {
|
||||
return NextResponse.json({ error: 'Server is not published' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Return server capabilities
|
||||
return NextResponse.json({
|
||||
name: server.name,
|
||||
version: '1.0.0',
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
instructions: `This MCP server exposes workflow tools from Sim Studio. Each tool executes a deployed workflow.`,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error getting MCP server info:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Handle MCP JSON-RPC requests
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { serverId } = await params
|
||||
|
||||
try {
|
||||
// Validate server
|
||||
const server = await validateServer(serverId)
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!server.isPublished) {
|
||||
return NextResponse.json({ error: 'Server is not published' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Authenticate the request
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Parse JSON-RPC request
|
||||
const body = await request.json()
|
||||
const rpcRequest = body as JsonRpcRequest
|
||||
|
||||
if (rpcRequest.jsonrpc !== '2.0' || !rpcRequest.method) {
|
||||
return NextResponse.json(createJsonRpcError(rpcRequest?.id || 0, -32600, 'Invalid Request'), {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle different MCP methods
|
||||
switch (rpcRequest.method) {
|
||||
case 'initialize':
|
||||
return NextResponse.json(
|
||||
createJsonRpcResponse(rpcRequest.id, {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
serverInfo: {
|
||||
name: server.name,
|
||||
version: '1.0.0',
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
case 'tools/list':
|
||||
return handleToolsList(rpcRequest, serverId)
|
||||
|
||||
case 'tools/call': {
|
||||
// Get the API key from the request to forward to the workflow execute call
|
||||
const apiKey =
|
||||
request.headers.get('X-API-Key') ||
|
||||
request.headers.get('Authorization')?.replace('Bearer ', '')
|
||||
return handleToolsCall(rpcRequest, serverId, auth.userId, server.workspaceId, apiKey)
|
||||
}
|
||||
|
||||
case 'ping':
|
||||
return NextResponse.json(createJsonRpcResponse(rpcRequest.id, {}))
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
createJsonRpcError(rpcRequest.id, -32601, `Method not found: ${rpcRequest.method}`),
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling MCP request:', error)
|
||||
return NextResponse.json(createJsonRpcError(0, -32603, 'Internal error'), { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tools/list method
|
||||
*/
|
||||
async function handleToolsList(
|
||||
rpcRequest: JsonRpcRequest,
|
||||
serverId: string
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const tools = await db
|
||||
.select({
|
||||
id: workflowMcpTool.id,
|
||||
toolName: workflowMcpTool.toolName,
|
||||
toolDescription: workflowMcpTool.toolDescription,
|
||||
parameterSchema: workflowMcpTool.parameterSchema,
|
||||
isEnabled: workflowMcpTool.isEnabled,
|
||||
workflowId: workflowMcpTool.workflowId,
|
||||
})
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.serverId, serverId))
|
||||
|
||||
const mcpTools = tools
|
||||
.filter((tool) => tool.isEnabled)
|
||||
.map((tool) => ({
|
||||
name: tool.toolName,
|
||||
description: tool.toolDescription || `Execute workflow tool: ${tool.toolName}`,
|
||||
inputSchema: tool.parameterSchema || {
|
||||
type: 'object',
|
||||
properties: {
|
||||
input: {
|
||||
type: 'object',
|
||||
description: 'Input data for the workflow',
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
return NextResponse.json(createJsonRpcResponse(rpcRequest.id, { tools: mcpTools }))
|
||||
} catch (error) {
|
||||
logger.error('Error listing tools:', error)
|
||||
return NextResponse.json(createJsonRpcError(rpcRequest.id, -32603, 'Failed to list tools'), {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tools/call method
|
||||
*/
|
||||
async function handleToolsCall(
|
||||
rpcRequest: JsonRpcRequest,
|
||||
serverId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
apiKey?: string | null
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const params = rpcRequest.params as
|
||||
| { name: string; arguments?: Record<string, unknown> }
|
||||
| undefined
|
||||
|
||||
if (!params?.name) {
|
||||
return NextResponse.json(
|
||||
createJsonRpcError(rpcRequest.id, -32602, 'Invalid params: tool name required'),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Find the tool
|
||||
const [tool] = await db
|
||||
.select({
|
||||
id: workflowMcpTool.id,
|
||||
toolName: workflowMcpTool.toolName,
|
||||
workflowId: workflowMcpTool.workflowId,
|
||||
isEnabled: workflowMcpTool.isEnabled,
|
||||
})
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.serverId, serverId))
|
||||
.then((tools) => tools.filter((t) => t.toolName === params.name))
|
||||
|
||||
if (!tool) {
|
||||
return NextResponse.json(
|
||||
createJsonRpcError(rpcRequest.id, -32602, `Tool not found: ${params.name}`),
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!tool.isEnabled) {
|
||||
return NextResponse.json(
|
||||
createJsonRpcError(rpcRequest.id, -32602, `Tool is disabled: ${params.name}`),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify workflow is still deployed
|
||||
const [workflowRecord] = await db
|
||||
.select({ id: workflow.id, isDeployed: workflow.isDeployed })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, tool.workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowRecord || !workflowRecord.isDeployed) {
|
||||
return NextResponse.json(
|
||||
createJsonRpcError(rpcRequest.id, -32603, 'Workflow is not deployed'),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Execute the workflow
|
||||
const baseUrl = getBaseUrl()
|
||||
const executeUrl = `${baseUrl}/api/workflows/${tool.workflowId}/execute`
|
||||
|
||||
logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`)
|
||||
|
||||
// Build headers for the internal execute call
|
||||
const executeHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
// Forward the API key for authentication
|
||||
if (apiKey) {
|
||||
executeHeaders['X-API-Key'] = apiKey
|
||||
}
|
||||
|
||||
const executeResponse = await fetch(executeUrl, {
|
||||
method: 'POST',
|
||||
headers: executeHeaders,
|
||||
body: JSON.stringify({
|
||||
input: params.arguments || {},
|
||||
triggerType: 'mcp',
|
||||
}),
|
||||
})
|
||||
|
||||
const executeResult = await executeResponse.json()
|
||||
|
||||
if (!executeResponse.ok) {
|
||||
return NextResponse.json(
|
||||
createJsonRpcError(
|
||||
rpcRequest.id,
|
||||
-32603,
|
||||
executeResult.error || 'Workflow execution failed'
|
||||
),
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Format response for MCP
|
||||
const content = [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(executeResult.output || executeResult, null, 2),
|
||||
},
|
||||
]
|
||||
|
||||
return NextResponse.json(
|
||||
createJsonRpcResponse(rpcRequest.id, {
|
||||
content,
|
||||
isError: !executeResult.success,
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Error calling tool:', error)
|
||||
return NextResponse.json(createJsonRpcError(rpcRequest.id, -32603, 'Tool execution failed'), {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
197
apps/sim/app/api/mcp/serve/[serverId]/sse/route.ts
Normal file
197
apps/sim/app/api/mcp/serve/[serverId]/sse/route.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* MCP SSE/HTTP Endpoint
|
||||
*
|
||||
* Implements MCP protocol using the official @modelcontextprotocol/sdk
|
||||
* with a Next.js-compatible transport adapter.
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { workflowMcpServer } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { createMcpSseStream, handleMcpRequest } from '@/lib/mcp/workflow-mcp-server'
|
||||
|
||||
const logger = createLogger('WorkflowMcpSSE')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
interface RouteParams {
|
||||
serverId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the server exists and is published
|
||||
*/
|
||||
async function validateServer(serverId: string) {
|
||||
const [server] = await db
|
||||
.select({
|
||||
id: workflowMcpServer.id,
|
||||
name: workflowMcpServer.name,
|
||||
workspaceId: workflowMcpServer.workspaceId,
|
||||
isPublished: workflowMcpServer.isPublished,
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.where(eq(workflowMcpServer.id, serverId))
|
||||
.limit(1)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - SSE endpoint for MCP protocol
|
||||
* Establishes a Server-Sent Events connection for MCP notifications
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { serverId } = await params
|
||||
|
||||
try {
|
||||
// Validate server exists and is published
|
||||
const server = await validateServer(serverId)
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!server.isPublished) {
|
||||
return NextResponse.json({ error: 'Server is not published' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const apiKey =
|
||||
request.headers.get('X-API-Key') ||
|
||||
request.headers.get('Authorization')?.replace('Bearer ', '')
|
||||
|
||||
// Create SSE stream using the SDK-based server
|
||||
const stream = createMcpSseStream({
|
||||
serverId,
|
||||
serverName: server.name,
|
||||
userId: auth.userId,
|
||||
workspaceId: server.workspaceId,
|
||||
apiKey,
|
||||
})
|
||||
|
||||
return new NextResponse(stream, {
|
||||
headers: {
|
||||
...SSE_HEADERS,
|
||||
'X-MCP-Server-Id': serverId,
|
||||
'X-MCP-Server-Name': server.name,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error establishing SSE connection:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Handle MCP JSON-RPC messages
|
||||
* This is the primary endpoint for MCP protocol messages using the SDK
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { serverId } = await params
|
||||
|
||||
try {
|
||||
// Validate server
|
||||
const server = await validateServer(serverId)
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: { code: -32000, message: 'Server not found' },
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!server.isPublished) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: { code: -32000, message: 'Server is not published' },
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: { code: -32000, message: 'Unauthorized' },
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const apiKey =
|
||||
request.headers.get('X-API-Key') ||
|
||||
request.headers.get('Authorization')?.replace('Bearer ', '')
|
||||
|
||||
// Handle the request using the SDK-based server
|
||||
return handleMcpRequest(
|
||||
{
|
||||
serverId,
|
||||
serverName: server.name,
|
||||
userId: auth.userId,
|
||||
workspaceId: server.workspaceId,
|
||||
apiKey,
|
||||
},
|
||||
request
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Error handling MCP POST request:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: { code: -32603, message: 'Internal error' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE - Handle session termination
|
||||
* MCP clients may send DELETE to end a session
|
||||
*/
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { serverId } = await params
|
||||
|
||||
try {
|
||||
// Validate server exists
|
||||
const server = await validateServer(serverId)
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
logger.info(`MCP session terminated for server ${serverId}`)
|
||||
|
||||
return new NextResponse(null, { status: 204 })
|
||||
} catch (error) {
|
||||
logger.error('Error handling MCP DELETE request:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
150
apps/sim/app/api/mcp/workflow-servers/[id]/publish/route.ts
Normal file
150
apps/sim/app/api/mcp/workflow-servers/[id]/publish/route.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServerPublishAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Publish a workflow MCP server (make it accessible via OAuth)
|
||||
*/
|
||||
export const POST = withMcpAuth<RouteParams>('admin')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
|
||||
logger.info(`[${requestId}] Publishing workflow MCP server: ${serverId}`)
|
||||
|
||||
const [existingServer] = await db
|
||||
.select({ id: workflowMcpServer.id, isPublished: workflowMcpServer.isPublished })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingServer) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
if (existingServer.isPublished) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('Server is already published'),
|
||||
'Server is already published',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// Check if server has at least one tool
|
||||
const tools = await db
|
||||
.select({ id: workflowMcpTool.id })
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.serverId, serverId))
|
||||
.limit(1)
|
||||
|
||||
if (tools.length === 0) {
|
||||
return createMcpErrorResponse(
|
||||
new Error(
|
||||
'Cannot publish server without any tools. Add at least one workflow as a tool first.'
|
||||
),
|
||||
'Server has no tools',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
const [updatedServer] = await db
|
||||
.update(workflowMcpServer)
|
||||
.set({
|
||||
isPublished: true,
|
||||
publishedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflowMcpServer.id, serverId))
|
||||
.returning()
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
const mcpServerUrl = `${baseUrl}/api/mcp/serve/${serverId}/sse`
|
||||
|
||||
logger.info(`[${requestId}] Successfully published workflow MCP server: ${serverId}`)
|
||||
|
||||
return createMcpSuccessResponse({
|
||||
server: updatedServer,
|
||||
mcpServerUrl,
|
||||
message: 'Server published successfully. External MCP clients can now connect using OAuth.',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error publishing workflow MCP server:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to publish workflow MCP server'),
|
||||
'Failed to publish workflow MCP server',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE - Unpublish a workflow MCP server
|
||||
*/
|
||||
export const DELETE = withMcpAuth<RouteParams>('admin')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
|
||||
logger.info(`[${requestId}] Unpublishing workflow MCP server: ${serverId}`)
|
||||
|
||||
const [existingServer] = await db
|
||||
.select({ id: workflowMcpServer.id, isPublished: workflowMcpServer.isPublished })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingServer) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
if (!existingServer.isPublished) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('Server is not published'),
|
||||
'Server is not published',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
const [updatedServer] = await db
|
||||
.update(workflowMcpServer)
|
||||
.set({
|
||||
isPublished: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflowMcpServer.id, serverId))
|
||||
.returning()
|
||||
|
||||
logger.info(`[${requestId}] Successfully unpublished workflow MCP server: ${serverId}`)
|
||||
|
||||
return createMcpSuccessResponse({
|
||||
server: updatedServer,
|
||||
message: 'Server unpublished successfully. External MCP clients can no longer connect.',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error unpublishing workflow MCP server:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to unpublish workflow MCP server'),
|
||||
'Failed to unpublish workflow MCP server',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
157
apps/sim/app/api/mcp/workflow-servers/[id]/route.ts
Normal file
157
apps/sim/app/api/mcp/workflow-servers/[id]/route.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServerAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - Get a specific workflow MCP server with its tools
|
||||
*/
|
||||
export const GET = withMcpAuth<RouteParams>('read')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
|
||||
logger.info(`[${requestId}] Getting workflow MCP server: ${serverId}`)
|
||||
|
||||
const [server] = await db
|
||||
.select({
|
||||
id: workflowMcpServer.id,
|
||||
workspaceId: workflowMcpServer.workspaceId,
|
||||
createdBy: workflowMcpServer.createdBy,
|
||||
name: workflowMcpServer.name,
|
||||
description: workflowMcpServer.description,
|
||||
isPublished: workflowMcpServer.isPublished,
|
||||
publishedAt: workflowMcpServer.publishedAt,
|
||||
createdAt: workflowMcpServer.createdAt,
|
||||
updatedAt: workflowMcpServer.updatedAt,
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!server) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
const tools = await db
|
||||
.select()
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.serverId, serverId))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Found workflow MCP server: ${server.name} with ${tools.length} tools`
|
||||
)
|
||||
|
||||
return createMcpSuccessResponse({ server, tools })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error getting workflow MCP server:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to get workflow MCP server'),
|
||||
'Failed to get workflow MCP server',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PATCH - Update a workflow MCP server
|
||||
*/
|
||||
export const PATCH = withMcpAuth<RouteParams>('write')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
const body = getParsedBody(request) || (await request.json())
|
||||
|
||||
logger.info(`[${requestId}] Updating workflow MCP server: ${serverId}`)
|
||||
|
||||
const [existingServer] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingServer) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
if (body.name !== undefined) {
|
||||
updateData.name = body.name.trim()
|
||||
}
|
||||
if (body.description !== undefined) {
|
||||
updateData.description = body.description?.trim() || null
|
||||
}
|
||||
|
||||
const [updatedServer] = await db
|
||||
.update(workflowMcpServer)
|
||||
.set(updateData)
|
||||
.where(eq(workflowMcpServer.id, serverId))
|
||||
.returning()
|
||||
|
||||
logger.info(`[${requestId}] Successfully updated workflow MCP server: ${serverId}`)
|
||||
|
||||
return createMcpSuccessResponse({ server: updatedServer })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error updating workflow MCP server:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to update workflow MCP server'),
|
||||
'Failed to update workflow MCP server',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE - Delete a workflow MCP server and all its tools
|
||||
*/
|
||||
export const DELETE = withMcpAuth<RouteParams>('admin')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
|
||||
logger.info(`[${requestId}] Deleting workflow MCP server: ${serverId}`)
|
||||
|
||||
const [deletedServer] = await db
|
||||
.delete(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!deletedServer) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Successfully deleted workflow MCP server: ${serverId}`)
|
||||
|
||||
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting workflow MCP server:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to delete workflow MCP server'),
|
||||
'Failed to delete workflow MCP server',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
|
||||
const logger = createLogger('WorkflowMcpToolAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
toolId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - Get a specific tool
|
||||
*/
|
||||
export const GET = withMcpAuth<RouteParams>('read')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId, toolId } = await params
|
||||
|
||||
logger.info(`[${requestId}] Getting tool ${toolId} from server ${serverId}`)
|
||||
|
||||
// Verify server exists and belongs to workspace
|
||||
const [server] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!server) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
const [tool] = await db
|
||||
.select()
|
||||
.from(workflowMcpTool)
|
||||
.where(and(eq(workflowMcpTool.id, toolId), eq(workflowMcpTool.serverId, serverId)))
|
||||
.limit(1)
|
||||
|
||||
if (!tool) {
|
||||
return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404)
|
||||
}
|
||||
|
||||
return createMcpSuccessResponse({ tool })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error getting tool:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to get tool'),
|
||||
'Failed to get tool',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PATCH - Update a tool's configuration
|
||||
*/
|
||||
export const PATCH = withMcpAuth<RouteParams>('write')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId, toolId } = await params
|
||||
const body = getParsedBody(request) || (await request.json())
|
||||
|
||||
logger.info(`[${requestId}] Updating tool ${toolId} in server ${serverId}`)
|
||||
|
||||
// Verify server exists and belongs to workspace
|
||||
const [server] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!server) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
const [existingTool] = await db
|
||||
.select({ id: workflowMcpTool.id })
|
||||
.from(workflowMcpTool)
|
||||
.where(and(eq(workflowMcpTool.id, toolId), eq(workflowMcpTool.serverId, serverId)))
|
||||
.limit(1)
|
||||
|
||||
if (!existingTool) {
|
||||
return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404)
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
if (body.toolName !== undefined) {
|
||||
updateData.toolName = body.toolName.trim()
|
||||
}
|
||||
if (body.toolDescription !== undefined) {
|
||||
updateData.toolDescription = body.toolDescription?.trim() || null
|
||||
}
|
||||
if (body.parameterSchema !== undefined) {
|
||||
updateData.parameterSchema = body.parameterSchema
|
||||
}
|
||||
if (body.isEnabled !== undefined) {
|
||||
updateData.isEnabled = body.isEnabled
|
||||
}
|
||||
|
||||
const [updatedTool] = await db
|
||||
.update(workflowMcpTool)
|
||||
.set(updateData)
|
||||
.where(eq(workflowMcpTool.id, toolId))
|
||||
.returning()
|
||||
|
||||
logger.info(`[${requestId}] Successfully updated tool ${toolId}`)
|
||||
|
||||
return createMcpSuccessResponse({ tool: updatedTool })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error updating tool:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to update tool'),
|
||||
'Failed to update tool',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE - Remove a tool from an MCP server
|
||||
*/
|
||||
export const DELETE = withMcpAuth<RouteParams>('write')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId, toolId } = await params
|
||||
|
||||
logger.info(`[${requestId}] Deleting tool ${toolId} from server ${serverId}`)
|
||||
|
||||
// Verify server exists and belongs to workspace
|
||||
const [server] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!server) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
const [deletedTool] = await db
|
||||
.delete(workflowMcpTool)
|
||||
.where(and(eq(workflowMcpTool.id, toolId), eq(workflowMcpTool.serverId, serverId)))
|
||||
.returning()
|
||||
|
||||
if (!deletedTool) {
|
||||
return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Successfully deleted tool ${toolId}`)
|
||||
|
||||
return createMcpSuccessResponse({ message: `Tool ${toolId} deleted successfully` })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting tool:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to delete tool'),
|
||||
'Failed to delete tool',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
226
apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts
Normal file
226
apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
|
||||
|
||||
const logger = createLogger('WorkflowMcpToolsAPI')
|
||||
|
||||
/**
|
||||
* Check if a workflow has a valid start block by loading from database
|
||||
*/
|
||||
async function hasValidStartBlock(workflowId: string): Promise<boolean> {
|
||||
try {
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
return hasValidStartBlockInState(normalizedData)
|
||||
} catch (error) {
|
||||
logger.warn('Error checking for start block:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - List all tools for a workflow MCP server
|
||||
*/
|
||||
export const GET = withMcpAuth<RouteParams>('read')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
|
||||
logger.info(`[${requestId}] Listing tools for workflow MCP server: ${serverId}`)
|
||||
|
||||
// Verify server exists and belongs to workspace
|
||||
const [server] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!server) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
// Get tools with workflow details
|
||||
const tools = await db
|
||||
.select({
|
||||
id: workflowMcpTool.id,
|
||||
serverId: workflowMcpTool.serverId,
|
||||
workflowId: workflowMcpTool.workflowId,
|
||||
toolName: workflowMcpTool.toolName,
|
||||
toolDescription: workflowMcpTool.toolDescription,
|
||||
parameterSchema: workflowMcpTool.parameterSchema,
|
||||
isEnabled: workflowMcpTool.isEnabled,
|
||||
createdAt: workflowMcpTool.createdAt,
|
||||
updatedAt: workflowMcpTool.updatedAt,
|
||||
workflowName: workflow.name,
|
||||
workflowDescription: workflow.description,
|
||||
isDeployed: workflow.isDeployed,
|
||||
})
|
||||
.from(workflowMcpTool)
|
||||
.leftJoin(workflow, eq(workflowMcpTool.workflowId, workflow.id))
|
||||
.where(eq(workflowMcpTool.serverId, serverId))
|
||||
|
||||
logger.info(`[${requestId}] Found ${tools.length} tools for server ${serverId}`)
|
||||
|
||||
return createMcpSuccessResponse({ tools })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error listing tools:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to list tools'),
|
||||
'Failed to list tools',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST - Add a workflow as a tool to an MCP server
|
||||
*/
|
||||
export const POST = withMcpAuth<RouteParams>('write')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
const body = getParsedBody(request) || (await request.json())
|
||||
|
||||
logger.info(`[${requestId}] Adding tool to workflow MCP server: ${serverId}`, {
|
||||
workflowId: body.workflowId,
|
||||
})
|
||||
|
||||
if (!body.workflowId) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('Missing required field: workflowId'),
|
||||
'Missing required field',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// Verify server exists and belongs to workspace
|
||||
const [server] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!server) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
// Verify workflow exists and is deployed
|
||||
const [workflowRecord] = await db
|
||||
.select({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
isDeployed: workflow.isDeployed,
|
||||
workspaceId: workflow.workspaceId,
|
||||
})
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, body.workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowRecord) {
|
||||
return createMcpErrorResponse(new Error('Workflow not found'), 'Workflow not found', 404)
|
||||
}
|
||||
|
||||
// Verify workflow belongs to the same workspace
|
||||
if (workflowRecord.workspaceId !== workspaceId) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('Workflow does not belong to this workspace'),
|
||||
'Access denied',
|
||||
403
|
||||
)
|
||||
}
|
||||
|
||||
if (!workflowRecord.isDeployed) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('Workflow must be deployed before adding as a tool'),
|
||||
'Workflow not deployed',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// Verify workflow has a valid start block
|
||||
const hasStartBlock = await hasValidStartBlock(body.workflowId)
|
||||
if (!hasStartBlock) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('Workflow must have a Start block to be used as an MCP tool'),
|
||||
'No start block found',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// Check if tool already exists for this workflow
|
||||
const [existingTool] = await db
|
||||
.select({ id: workflowMcpTool.id })
|
||||
.from(workflowMcpTool)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowMcpTool.serverId, serverId),
|
||||
eq(workflowMcpTool.workflowId, body.workflowId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingTool) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('This workflow is already added as a tool to this server'),
|
||||
'Tool already exists',
|
||||
409
|
||||
)
|
||||
}
|
||||
|
||||
// Generate tool name and description
|
||||
const toolName = body.toolName?.trim() || sanitizeToolName(workflowRecord.name)
|
||||
const toolDescription =
|
||||
body.toolDescription?.trim() ||
|
||||
workflowRecord.description ||
|
||||
`Execute ${workflowRecord.name} workflow`
|
||||
|
||||
// Create the tool
|
||||
const toolId = crypto.randomUUID()
|
||||
const [tool] = await db
|
||||
.insert(workflowMcpTool)
|
||||
.values({
|
||||
id: toolId,
|
||||
serverId,
|
||||
workflowId: body.workflowId,
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterSchema: body.parameterSchema || {},
|
||||
isEnabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Successfully added tool ${toolName} (workflow: ${body.workflowId}) to server ${serverId}`
|
||||
)
|
||||
|
||||
return createMcpSuccessResponse({ tool }, 201)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error adding tool:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to add tool'),
|
||||
'Failed to add tool',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
107
apps/sim/app/api/mcp/workflow-servers/route.ts
Normal file
107
apps/sim/app/api/mcp/workflow-servers/route.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowMcpServer } from '@sim/db/schema'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServersAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* GET - List all workflow MCP servers for the workspace
|
||||
*/
|
||||
export const GET = withMcpAuth('read')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }) => {
|
||||
try {
|
||||
logger.info(`[${requestId}] Listing workflow MCP servers for workspace ${workspaceId}`)
|
||||
|
||||
const servers = await db
|
||||
.select({
|
||||
id: workflowMcpServer.id,
|
||||
workspaceId: workflowMcpServer.workspaceId,
|
||||
createdBy: workflowMcpServer.createdBy,
|
||||
name: workflowMcpServer.name,
|
||||
description: workflowMcpServer.description,
|
||||
isPublished: workflowMcpServer.isPublished,
|
||||
publishedAt: workflowMcpServer.publishedAt,
|
||||
createdAt: workflowMcpServer.createdAt,
|
||||
updatedAt: workflowMcpServer.updatedAt,
|
||||
toolCount: sql<number>`(
|
||||
SELECT COUNT(*)::int
|
||||
FROM "workflow_mcp_tool"
|
||||
WHERE "workflow_mcp_tool"."server_id" = "workflow_mcp_server"."id"
|
||||
)`.as('tool_count'),
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.where(eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Listed ${servers.length} workflow MCP servers for workspace ${workspaceId}`
|
||||
)
|
||||
return createMcpSuccessResponse({ servers })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error listing workflow MCP servers:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to list workflow MCP servers'),
|
||||
'Failed to list workflow MCP servers',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST - Create a new workflow MCP server
|
||||
*/
|
||||
export const POST = withMcpAuth('write')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }) => {
|
||||
try {
|
||||
const body = getParsedBody(request) || (await request.json())
|
||||
|
||||
logger.info(`[${requestId}] Creating workflow MCP server:`, {
|
||||
name: body.name,
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
if (!body.name) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('Missing required field: name'),
|
||||
'Missing required field',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
const serverId = crypto.randomUUID()
|
||||
|
||||
const [server] = await db
|
||||
.insert(workflowMcpServer)
|
||||
.values({
|
||||
id: serverId,
|
||||
workspaceId,
|
||||
createdBy: userId,
|
||||
name: body.name.trim(),
|
||||
description: body.description?.trim() || null,
|
||||
isPublished: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${serverId})`
|
||||
)
|
||||
|
||||
return createMcpSuccessResponse({ server }, 201)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error creating workflow MCP server:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to create workflow MCP server'),
|
||||
'Failed to create workflow MCP server',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1,17 +1,121 @@
|
||||
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
|
||||
import { db, workflow, workflowDeploymentVersion, workflowMcpTool } from '@sim/db'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { deployWorkflow } from '@/lib/workflows/persistence/utils'
|
||||
import {
|
||||
extractInputFormatFromBlocks,
|
||||
generateToolInputSchema,
|
||||
} from '@/lib/mcp/workflow-tool-schema'
|
||||
import { deployWorkflow, loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('WorkflowDeployAPI')
|
||||
|
||||
/**
|
||||
* Check if a workflow has a valid start block by loading from database
|
||||
*/
|
||||
async function hasValidStartBlock(workflowId: string): Promise<boolean> {
|
||||
try {
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
return hasValidStartBlockInState(normalizedData)
|
||||
} catch (error) {
|
||||
logger.warn('Error checking for start block:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/**
|
||||
* Extract input format from workflow blocks and generate MCP tool parameter schema
|
||||
*/
|
||||
async function generateMcpToolSchema(workflowId: string): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
if (!normalizedData?.blocks) {
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
|
||||
const inputFormat = extractInputFormatFromBlocks(normalizedData.blocks)
|
||||
if (!inputFormat || inputFormat.length === 0) {
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
|
||||
return generateToolInputSchema(inputFormat) as unknown as Record<string, unknown>
|
||||
} catch (error) {
|
||||
logger.warn('Error generating MCP tool schema:', error)
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all MCP tools that reference this workflow with the latest parameter schema.
|
||||
* If the workflow no longer has a start block, remove all MCP tools.
|
||||
*/
|
||||
async function syncMcpToolsOnDeploy(workflowId: string, requestId: string): Promise<void> {
|
||||
try {
|
||||
// Get all MCP tools that use this workflow
|
||||
const tools = await db
|
||||
.select({ id: workflowMcpTool.id })
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
if (tools.length === 0) {
|
||||
logger.debug(`[${requestId}] No MCP tools to sync for workflow: ${workflowId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if workflow still has a valid start block
|
||||
const hasStart = await hasValidStartBlock(workflowId)
|
||||
if (!hasStart) {
|
||||
// No start block - remove all MCP tools for this workflow
|
||||
await db.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Removed ${tools.length} MCP tool(s) - workflow no longer has a start block: ${workflowId}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate the latest parameter schema
|
||||
const parameterSchema = await generateMcpToolSchema(workflowId)
|
||||
|
||||
// Update all tools with the new schema
|
||||
await db
|
||||
.update(workflowMcpTool)
|
||||
.set({
|
||||
parameterSchema,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
logger.info(`[${requestId}] Synced ${tools.length} MCP tool(s) for workflow: ${workflowId}`)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error syncing MCP tools:`, error)
|
||||
// Don't throw - this is a non-critical operation
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all MCP tools that reference this workflow when undeploying
|
||||
*/
|
||||
async function removeMcpToolsOnUndeploy(workflowId: string, requestId: string): Promise<void> {
|
||||
try {
|
||||
const result = await db
|
||||
.delete(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
logger.info(`[${requestId}] Removed MCP tools for undeployed workflow: ${workflowId}`)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error removing MCP tools:`, error)
|
||||
// Don't throw - this is a non-critical operation
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await params
|
||||
@@ -119,6 +223,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
|
||||
|
||||
// Sync MCP tools with the latest parameter schema
|
||||
await syncMcpToolsOnDeploy(id, requestId)
|
||||
|
||||
const responseApiKeyInfo = workflowData!.workspaceId
|
||||
? 'Workspace API keys'
|
||||
: 'Personal API keys'
|
||||
@@ -167,6 +274,9 @@ export async function DELETE(
|
||||
.where(eq(workflow.id, id))
|
||||
})
|
||||
|
||||
// Remove all MCP tools that reference this workflow
|
||||
await removeMcpToolsOnUndeploy(id, requestId)
|
||||
|
||||
logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`)
|
||||
|
||||
// Track workflow undeployment
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
|
||||
import { db, workflow, workflowDeploymentVersion, workflowMcpTool } from '@sim/db'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
extractInputFormatFromBlocks,
|
||||
generateToolInputSchema,
|
||||
} from '@/lib/mcp/workflow-tool-schema'
|
||||
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
@@ -11,6 +16,80 @@ const logger = createLogger('WorkflowActivateDeploymentAPI')
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/**
|
||||
* Extract input format from a deployment version state and generate MCP tool parameter schema
|
||||
*/
|
||||
function generateMcpToolSchemaFromState(state: any): Record<string, unknown> {
|
||||
try {
|
||||
if (!state?.blocks) {
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
|
||||
const inputFormat = extractInputFormatFromBlocks(state.blocks)
|
||||
if (!inputFormat || inputFormat.length === 0) {
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
|
||||
return generateToolInputSchema(inputFormat) as unknown as Record<string, unknown>
|
||||
} catch (error) {
|
||||
logger.warn('Error generating MCP tool schema from state:', error)
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync MCP tools when activating a deployment version.
|
||||
* If the version has no start block, remove all MCP tools.
|
||||
*/
|
||||
async function syncMcpToolsOnVersionActivate(
|
||||
workflowId: string,
|
||||
versionState: any,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Get all MCP tools that use this workflow
|
||||
const tools = await db
|
||||
.select({ id: workflowMcpTool.id })
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
if (tools.length === 0) {
|
||||
logger.debug(`[${requestId}] No MCP tools to sync for workflow: ${workflowId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the activated version has a valid start block
|
||||
if (!hasValidStartBlockInState(versionState)) {
|
||||
// No start block - remove all MCP tools for this workflow
|
||||
await db.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Removed ${tools.length} MCP tool(s) - activated version has no start block: ${workflowId}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate the parameter schema from the activated version's state
|
||||
const parameterSchema = generateMcpToolSchemaFromState(versionState)
|
||||
|
||||
// Update all tools with the new schema
|
||||
await db
|
||||
.update(workflowMcpTool)
|
||||
.set({
|
||||
parameterSchema,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Synced ${tools.length} MCP tool(s) for workflow version activation: ${workflowId}`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error syncing MCP tools on version activate:`, error)
|
||||
// Don't throw - this is a non-critical operation
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; version: string }> }
|
||||
@@ -31,6 +110,18 @@ export async function POST(
|
||||
|
||||
const now = new Date()
|
||||
|
||||
// Get the state of the version being activated for MCP tool sync
|
||||
const [versionData] = await db
|
||||
.select({ state: workflowDeploymentVersion.state })
|
||||
.from(workflowDeploymentVersion)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, id),
|
||||
eq(workflowDeploymentVersion.version, versionNum)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(workflowDeploymentVersion)
|
||||
@@ -65,6 +156,11 @@ export async function POST(
|
||||
await tx.update(workflow).set(updateData).where(eq(workflow.id, id))
|
||||
})
|
||||
|
||||
// Sync MCP tools with the activated version's parameter schema
|
||||
if (versionData?.state) {
|
||||
await syncMcpToolsOnVersionActivate(id, versionData.state, requestId)
|
||||
}
|
||||
|
||||
return createSuccessResponse({ success: true, deployedAt: now })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error activating deployment for workflow: ${id}`, error)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
|
||||
import { db, workflow, workflowDeploymentVersion, workflowMcpTool } from '@sim/db'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
extractInputFormatFromBlocks,
|
||||
generateToolInputSchema,
|
||||
} from '@/lib/mcp/workflow-tool-schema'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
@@ -13,6 +18,80 @@ const logger = createLogger('RevertToDeploymentVersionAPI')
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/**
|
||||
* Extract input format from a deployment version state and generate MCP tool parameter schema
|
||||
*/
|
||||
function generateMcpToolSchemaFromState(state: any): Record<string, unknown> {
|
||||
try {
|
||||
if (!state?.blocks) {
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
|
||||
const inputFormat = extractInputFormatFromBlocks(state.blocks)
|
||||
if (!inputFormat || inputFormat.length === 0) {
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
|
||||
return generateToolInputSchema(inputFormat) as unknown as Record<string, unknown>
|
||||
} catch (error) {
|
||||
logger.warn('Error generating MCP tool schema from state:', error)
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync MCP tools when reverting to a deployment version.
|
||||
* If the version has no start block, remove all MCP tools.
|
||||
*/
|
||||
async function syncMcpToolsOnRevert(
|
||||
workflowId: string,
|
||||
versionState: any,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Get all MCP tools that use this workflow
|
||||
const tools = await db
|
||||
.select({ id: workflowMcpTool.id })
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
if (tools.length === 0) {
|
||||
logger.debug(`[${requestId}] No MCP tools to sync for workflow: ${workflowId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the reverted version has a valid start block
|
||||
if (!hasValidStartBlockInState(versionState)) {
|
||||
// No start block - remove all MCP tools for this workflow
|
||||
await db.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Removed ${tools.length} MCP tool(s) - reverted version has no start block: ${workflowId}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate the parameter schema from the reverted version's state
|
||||
const parameterSchema = generateMcpToolSchemaFromState(versionState)
|
||||
|
||||
// Update all tools with the new schema
|
||||
await db
|
||||
.update(workflowMcpTool)
|
||||
.set({
|
||||
parameterSchema,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Synced ${tools.length} MCP tool(s) for workflow revert: ${workflowId}`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error syncing MCP tools on revert:`, error)
|
||||
// Don't throw - this is a non-critical operation
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; version: string }> }
|
||||
@@ -87,6 +166,9 @@ export async function POST(
|
||||
.set({ lastSynced: new Date(), updatedAt: new Date() })
|
||||
.where(eq(workflow.id, id))
|
||||
|
||||
// Sync MCP tools with the reverted version's parameter schema
|
||||
await syncMcpToolsOnRevert(id, deployedState, requestId)
|
||||
|
||||
try {
|
||||
const socketServerUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002'
|
||||
await fetch(`${socketServerUrl}/api/workflow-reverted`, {
|
||||
|
||||
@@ -30,7 +30,7 @@ const logger = createLogger('WorkflowExecuteAPI')
|
||||
|
||||
const ExecuteWorkflowSchema = z.object({
|
||||
selectedOutputs: z.array(z.string()).optional().default([]),
|
||||
triggerType: z.enum(['api', 'webhook', 'schedule', 'manual', 'chat']).optional(),
|
||||
triggerType: z.enum(['api', 'webhook', 'schedule', 'manual', 'chat', 'mcp']).optional(),
|
||||
stream: z.boolean().optional(),
|
||||
useDraftState: z.boolean().optional(),
|
||||
input: z.any().optional(),
|
||||
@@ -227,7 +227,7 @@ type AsyncExecutionParams = {
|
||||
workflowId: string
|
||||
userId: string
|
||||
input: any
|
||||
triggerType: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
|
||||
triggerType: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -370,14 +370,15 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
})
|
||||
|
||||
const executionId = uuidv4()
|
||||
type LoggingTriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
|
||||
type LoggingTriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
|
||||
let loggingTriggerType: LoggingTriggerType = 'manual'
|
||||
if (
|
||||
triggerType === 'api' ||
|
||||
triggerType === 'chat' ||
|
||||
triggerType === 'webhook' ||
|
||||
triggerType === 'schedule' ||
|
||||
triggerType === 'manual'
|
||||
triggerType === 'manual' ||
|
||||
triggerType === 'mcp'
|
||||
) {
|
||||
loggingTriggerType = triggerType as LoggingTriggerType
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ const PRIMARY_BUTTON_STYLES =
|
||||
|
||||
type NotificationType = 'webhook' | 'email' | 'slack'
|
||||
type LogLevel = 'info' | 'error'
|
||||
type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
|
||||
type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
|
||||
type AlertRule =
|
||||
| 'none'
|
||||
| 'consecutive_failures'
|
||||
@@ -84,7 +84,7 @@ interface NotificationSettingsProps {
|
||||
}
|
||||
|
||||
const LOG_LEVELS: LogLevel[] = ['info', 'error']
|
||||
const TRIGGER_TYPES: TriggerType[] = ['api', 'webhook', 'schedule', 'manual', 'chat']
|
||||
const TRIGGER_TYPES: TriggerType[] = ['api', 'webhook', 'schedule', 'manual', 'chat', 'mcp']
|
||||
|
||||
function formatAlertConfigLabel(config: {
|
||||
rule: AlertRule
|
||||
@@ -137,7 +137,7 @@ export function NotificationSettings({
|
||||
workflowIds: [] as string[],
|
||||
allWorkflows: true,
|
||||
levelFilter: ['info', 'error'] as LogLevel[],
|
||||
triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat'] as TriggerType[],
|
||||
triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat', 'mcp'] as TriggerType[],
|
||||
includeFinalOutput: false,
|
||||
includeTraceSpans: false,
|
||||
includeRateLimits: false,
|
||||
@@ -207,7 +207,7 @@ export function NotificationSettings({
|
||||
workflowIds: [],
|
||||
allWorkflows: true,
|
||||
levelFilter: ['info', 'error'],
|
||||
triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat'],
|
||||
triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat', 'mcp'],
|
||||
includeFinalOutput: false,
|
||||
includeTraceSpans: false,
|
||||
includeRateLimits: false,
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
import { AutocompleteSearch } from './components/search'
|
||||
|
||||
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook'] as const
|
||||
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook', 'mcp'] as const
|
||||
|
||||
const TIME_RANGE_OPTIONS: ComboboxOption[] = [
|
||||
{ value: 'All time', label: 'All time' },
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Badge } from '@/components/emcn'
|
||||
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
|
||||
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook'] as const
|
||||
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook', 'mcp'] as const
|
||||
const RUNNING_COLOR = '#22c55e' as const
|
||||
const PENDING_COLOR = '#f59e0b' as const
|
||||
|
||||
|
||||
@@ -0,0 +1,861 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Input as EmcnInput,
|
||||
Label,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
|
||||
import {
|
||||
useAddWorkflowMcpTool,
|
||||
useDeleteWorkflowMcpTool,
|
||||
useUpdateWorkflowMcpTool,
|
||||
useWorkflowMcpServers,
|
||||
useWorkflowMcpTools,
|
||||
type WorkflowMcpServer,
|
||||
type WorkflowMcpTool,
|
||||
} from '@/hooks/queries/workflow-mcp-servers'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const logger = createLogger('McpToolDeploy')
|
||||
|
||||
interface McpToolDeployProps {
|
||||
workflowId: string
|
||||
workflowName: string
|
||||
workflowDescription?: string | null
|
||||
isDeployed: boolean
|
||||
onAddedToServer?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract input format from workflow blocks using SubBlockStore
|
||||
* The actual input format values are stored in useSubBlockStore, not directly in the block structure
|
||||
*/
|
||||
function extractInputFormat(
|
||||
blocks: Record<string, unknown>
|
||||
): Array<{ name: string; type: string }> {
|
||||
// Find the starter block
|
||||
for (const [blockId, block] of Object.entries(blocks)) {
|
||||
if (!block || typeof block !== 'object') continue
|
||||
|
||||
const blockObj = block as Record<string, unknown>
|
||||
const blockType = blockObj.type
|
||||
|
||||
// Check for all possible start/trigger block types
|
||||
if (
|
||||
blockType === 'starter' ||
|
||||
blockType === 'start' ||
|
||||
blockType === 'start_trigger' || // This is the unified start block type
|
||||
blockType === 'api' ||
|
||||
blockType === 'api_trigger' ||
|
||||
blockType === 'input_trigger'
|
||||
) {
|
||||
// Get the inputFormat value from the SubBlockStore (where the actual values are stored)
|
||||
const inputFormatValue = useSubBlockStore.getState().getValue(blockId, 'inputFormat')
|
||||
|
||||
if (Array.isArray(inputFormatValue) && inputFormatValue.length > 0) {
|
||||
return inputFormatValue
|
||||
.filter(
|
||||
(field: unknown): field is { name: string; type: string } =>
|
||||
field !== null &&
|
||||
typeof field === 'object' &&
|
||||
'name' in field &&
|
||||
typeof (field as { name: unknown }).name === 'string' &&
|
||||
(field as { name: string }).name.trim() !== ''
|
||||
)
|
||||
.map((field) => ({
|
||||
name: field.name.trim(),
|
||||
type: field.type || 'string',
|
||||
}))
|
||||
}
|
||||
|
||||
// Fallback: try to get from block's subBlocks structure (for backwards compatibility)
|
||||
const subBlocks = blockObj.subBlocks as Record<string, unknown> | undefined
|
||||
if (subBlocks?.inputFormat) {
|
||||
const inputFormatSubBlock = subBlocks.inputFormat as Record<string, unknown>
|
||||
const value = inputFormatSubBlock.value
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
return value
|
||||
.filter(
|
||||
(field: unknown): field is { name: string; type: string } =>
|
||||
field !== null &&
|
||||
typeof field === 'object' &&
|
||||
'name' in field &&
|
||||
typeof (field as { name: unknown }).name === 'string' &&
|
||||
(field as { name: string }).name.trim() !== ''
|
||||
)
|
||||
.map((field) => ({
|
||||
name: field.name.trim(),
|
||||
type: field.type || 'string',
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JSON Schema from input format using the shared utility
|
||||
* Optionally applies custom descriptions from the UI
|
||||
*/
|
||||
function generateParameterSchema(
|
||||
inputFormat: Array<{ name: string; type: string }>,
|
||||
customDescriptions?: Record<string, string>
|
||||
): Record<string, unknown> {
|
||||
// Convert to InputFormatField with descriptions
|
||||
const fieldsWithDescriptions = inputFormat.map((field) => ({
|
||||
...field,
|
||||
description: customDescriptions?.[field.name]?.trim() || undefined,
|
||||
}))
|
||||
return generateToolInputSchema(fieldsWithDescriptions) as unknown as Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameter names from a tool's parameter schema
|
||||
*/
|
||||
function getToolParameterNames(schema: Record<string, unknown>): string[] {
|
||||
const properties = schema.properties as Record<string, unknown> | undefined
|
||||
if (!properties) return []
|
||||
return Object.keys(properties)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the tool's parameters differ from the current workflow's input format
|
||||
*/
|
||||
function hasParameterMismatch(
|
||||
tool: WorkflowMcpTool,
|
||||
currentInputFormat: Array<{ name: string; type: string }>
|
||||
): boolean {
|
||||
const toolParams = getToolParameterNames(tool.parameterSchema as Record<string, unknown>)
|
||||
const currentParams = currentInputFormat.map((f) => f.name)
|
||||
|
||||
if (toolParams.length !== currentParams.length) return true
|
||||
|
||||
const toolParamSet = new Set(toolParams)
|
||||
for (const param of currentParams) {
|
||||
if (!toolParamSet.has(param)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to query tools for a single server and report back via callback.
|
||||
* This pattern avoids calling hooks in a loop.
|
||||
*/
|
||||
function ServerToolsQuery({
|
||||
workspaceId,
|
||||
server,
|
||||
workflowId,
|
||||
onData,
|
||||
}: {
|
||||
workspaceId: string
|
||||
server: WorkflowMcpServer
|
||||
workflowId: string
|
||||
onData: (serverId: string, tool: WorkflowMcpTool | null, isLoading: boolean) => void
|
||||
}) {
|
||||
const { data: tools, isLoading } = useWorkflowMcpTools(workspaceId, server.id)
|
||||
|
||||
useEffect(() => {
|
||||
const tool = tools?.find((t) => t.workflowId === workflowId) || null
|
||||
onData(server.id, tool, isLoading)
|
||||
}, [tools, isLoading, workflowId, server.id, onData])
|
||||
|
||||
return null // This component doesn't render anything
|
||||
}
|
||||
|
||||
interface ToolOnServerProps {
|
||||
server: WorkflowMcpServer
|
||||
tool: WorkflowMcpTool
|
||||
workspaceId: string
|
||||
currentInputFormat: Array<{ name: string; type: string }>
|
||||
currentParameterSchema: Record<string, unknown>
|
||||
workflowDescription: string | null | undefined
|
||||
onRemoved: (serverId: string) => void
|
||||
onUpdated: () => void
|
||||
}
|
||||
|
||||
function ToolOnServer({
|
||||
server,
|
||||
tool,
|
||||
workspaceId,
|
||||
currentInputFormat,
|
||||
currentParameterSchema,
|
||||
workflowDescription,
|
||||
onRemoved,
|
||||
onUpdated,
|
||||
}: ToolOnServerProps) {
|
||||
const deleteToolMutation = useDeleteWorkflowMcpTool()
|
||||
const updateToolMutation = useUpdateWorkflowMcpTool()
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
|
||||
const needsUpdate = hasParameterMismatch(tool, currentInputFormat)
|
||||
const toolParams = getToolParameterNames(tool.parameterSchema as Record<string, unknown>)
|
||||
|
||||
const handleRemove = async () => {
|
||||
try {
|
||||
await deleteToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId: server.id,
|
||||
toolId: tool.id,
|
||||
})
|
||||
onRemoved(server.id)
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove tool:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async () => {
|
||||
try {
|
||||
await updateToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId: server.id,
|
||||
toolId: tool.id,
|
||||
toolDescription: workflowDescription || `Execute workflow`,
|
||||
parameterSchema: currentParameterSchema,
|
||||
})
|
||||
onUpdated()
|
||||
logger.info(`Updated tool ${tool.id} with new parameters`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to update tool:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (showConfirm) {
|
||||
return (
|
||||
<div className='flex items-center justify-between rounded-[6px] border border-[var(--text-error)]/30 bg-[var(--surface-3)] px-[10px] py-[8px]'>
|
||||
<span className='text-[12px] text-[var(--text-secondary)]'>Remove from {server.name}?</span>
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => setShowConfirm(false)}
|
||||
className='h-[24px] px-[8px] text-[11px]'
|
||||
disabled={deleteToolMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleRemove}
|
||||
className='h-[24px] px-[8px] text-[11px] text-[var(--text-error)] hover:text-[var(--text-error)]'
|
||||
disabled={deleteToolMutation.isPending}
|
||||
>
|
||||
{deleteToolMutation.isPending ? 'Removing...' : 'Remove'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='rounded-[6px] border bg-[var(--surface-3)]'>
|
||||
<div
|
||||
className='flex cursor-pointer items-center justify-between px-[10px] py-[8px]'
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{showDetails ? (
|
||||
<ChevronDown className='h-[12px] w-[12px] text-[var(--text-tertiary)]' />
|
||||
) : (
|
||||
<ChevronRight className='h-[12px] w-[12px] text-[var(--text-tertiary)]' />
|
||||
)}
|
||||
<span className='text-[13px] text-[var(--text-primary)]'>{server.name}</span>
|
||||
{server.isPublished && (
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
Published
|
||||
</Badge>
|
||||
)}
|
||||
{needsUpdate && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-amber-500/50 bg-amber-500/10 text-[10px] text-amber-500'
|
||||
>
|
||||
<AlertTriangle className='mr-[4px] h-[10px] w-[10px]' />
|
||||
Needs Update
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-[4px]' onClick={(e) => e.stopPropagation()}>
|
||||
{needsUpdate && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleUpdate}
|
||||
disabled={updateToolMutation.isPending}
|
||||
className='h-[24px] px-[8px] text-[11px] text-amber-500 hover:text-amber-600'
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
'mr-[4px] h-[10px] w-[10px]',
|
||||
updateToolMutation.isPending && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
{updateToolMutation.isPending ? 'Updating...' : 'Update'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => setShowConfirm(true)}
|
||||
className='h-[24px] w-[24px] p-0 text-[var(--text-tertiary)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<Trash2 className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDetails && (
|
||||
<div className='border-[var(--border)] border-t px-[10px] py-[8px]'>
|
||||
<div className='flex flex-col gap-[6px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-[11px] text-[var(--text-muted)]'>Tool Name</span>
|
||||
<span className='font-mono text-[11px] text-[var(--text-secondary)]'>
|
||||
{tool.toolName}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-start justify-between gap-[8px]'>
|
||||
<span className='flex-shrink-0 text-[11px] text-[var(--text-muted)]'>
|
||||
Description
|
||||
</span>
|
||||
<span className='text-right text-[11px] text-[var(--text-secondary)]'>
|
||||
{tool.toolDescription || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-start justify-between gap-[8px]'>
|
||||
<span className='flex-shrink-0 text-[11px] text-[var(--text-muted)]'>
|
||||
Parameters ({toolParams.length})
|
||||
</span>
|
||||
<div className='flex flex-wrap justify-end gap-[4px]'>
|
||||
{toolParams.length === 0 ? (
|
||||
<span className='text-[11px] text-[var(--text-muted)]'>None</span>
|
||||
) : (
|
||||
toolParams.map((param) => (
|
||||
<Badge key={param} variant='outline' className='text-[9px]'>
|
||||
{param}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function McpToolDeploy({
|
||||
workflowId,
|
||||
workflowName,
|
||||
workflowDescription,
|
||||
isDeployed,
|
||||
onAddedToServer,
|
||||
}: McpToolDeployProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const {
|
||||
data: servers = [],
|
||||
isLoading: isLoadingServers,
|
||||
refetch: refetchServers,
|
||||
} = useWorkflowMcpServers(workspaceId)
|
||||
const addToolMutation = useAddWorkflowMcpTool()
|
||||
|
||||
// Get workflow blocks
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
|
||||
// Find the starter block ID to subscribe to its inputFormat changes
|
||||
const starterBlockId = useMemo(() => {
|
||||
for (const [blockId, block] of Object.entries(blocks)) {
|
||||
if (!block || typeof block !== 'object') continue
|
||||
const blockType = (block as { type?: string }).type
|
||||
// Check for all possible start/trigger block types
|
||||
if (
|
||||
blockType === 'starter' ||
|
||||
blockType === 'start' ||
|
||||
blockType === 'start_trigger' || // This is the unified start block type
|
||||
blockType === 'api' ||
|
||||
blockType === 'api_trigger' ||
|
||||
blockType === 'input_trigger'
|
||||
) {
|
||||
return blockId
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [blocks])
|
||||
|
||||
// Subscribe to the inputFormat value in SubBlockStore for reactivity
|
||||
// Use workflowId prop directly (not activeWorkflowId from registry) to ensure we get the correct workflow's data
|
||||
const subBlockValues = useSubBlockStore((state) =>
|
||||
workflowId ? (state.workflowValues[workflowId] ?? {}) : {}
|
||||
)
|
||||
|
||||
// Extract and normalize input format - now reactive to SubBlockStore changes
|
||||
const inputFormat = useMemo(() => {
|
||||
// First try to get from SubBlockStore (where runtime values are stored)
|
||||
if (starterBlockId && subBlockValues[starterBlockId]) {
|
||||
const inputFormatValue = subBlockValues[starterBlockId].inputFormat
|
||||
|
||||
if (Array.isArray(inputFormatValue) && inputFormatValue.length > 0) {
|
||||
const filtered = inputFormatValue
|
||||
.filter(
|
||||
(field: unknown): field is { name: string; type: string } =>
|
||||
field !== null &&
|
||||
typeof field === 'object' &&
|
||||
'name' in field &&
|
||||
typeof (field as { name: unknown }).name === 'string' &&
|
||||
(field as { name: string }).name.trim() !== ''
|
||||
)
|
||||
.map((field) => ({
|
||||
name: field.name.trim(),
|
||||
type: field.type || 'string',
|
||||
}))
|
||||
if (filtered.length > 0) {
|
||||
return filtered
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try to get from block structure (for initial load or backwards compatibility)
|
||||
if (starterBlockId && blocks[starterBlockId]) {
|
||||
const startBlock = blocks[starterBlockId]
|
||||
const subBlocksValue = startBlock?.subBlocks?.inputFormat?.value as unknown
|
||||
|
||||
if (Array.isArray(subBlocksValue) && subBlocksValue.length > 0) {
|
||||
const validFields: Array<{ name: string; type: string }> = []
|
||||
for (const field of subBlocksValue) {
|
||||
if (
|
||||
field !== null &&
|
||||
typeof field === 'object' &&
|
||||
'name' in field &&
|
||||
typeof field.name === 'string' &&
|
||||
field.name.trim() !== ''
|
||||
) {
|
||||
validFields.push({
|
||||
name: field.name.trim(),
|
||||
type: typeof field.type === 'string' ? field.type : 'string',
|
||||
})
|
||||
}
|
||||
}
|
||||
if (validFields.length > 0) {
|
||||
return validFields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last fallback: use extractInputFormat helper
|
||||
return extractInputFormat(blocks)
|
||||
}, [starterBlockId, subBlockValues, blocks])
|
||||
|
||||
const [selectedServer, setSelectedServer] = useState<WorkflowMcpServer | null>(null)
|
||||
const [toolName, setToolName] = useState('')
|
||||
const [toolDescription, setToolDescription] = useState('')
|
||||
const [showServerSelector, setShowServerSelector] = useState(false)
|
||||
const [showParameterSchema, setShowParameterSchema] = useState(false)
|
||||
// Track custom descriptions for each parameter
|
||||
const [parameterDescriptions, setParameterDescriptions] = useState<Record<string, string>>({})
|
||||
|
||||
const parameterSchema = useMemo(
|
||||
() => generateParameterSchema(inputFormat, parameterDescriptions),
|
||||
[inputFormat, parameterDescriptions]
|
||||
)
|
||||
|
||||
// Track tools data from each server using state instead of hooks in a loop
|
||||
const [serverToolsMap, setServerToolsMap] = useState<
|
||||
Record<string, { tool: WorkflowMcpTool | null; isLoading: boolean }>
|
||||
>({})
|
||||
|
||||
// Stable callback to handle tool data from ServerToolsQuery components
|
||||
const handleServerToolData = useCallback(
|
||||
(serverId: string, tool: WorkflowMcpTool | null, isLoading: boolean) => {
|
||||
setServerToolsMap((prev) => {
|
||||
// Only update if data has changed to prevent infinite loops
|
||||
const existing = prev[serverId]
|
||||
if (existing?.tool?.id === tool?.id && existing?.isLoading === isLoading) {
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[serverId]: { tool, isLoading },
|
||||
}
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Find which servers already have this workflow as a tool and get the tool info
|
||||
const serversWithThisWorkflow = useMemo(() => {
|
||||
const result: Array<{ server: WorkflowMcpServer; tool: WorkflowMcpTool }> = []
|
||||
for (const server of servers) {
|
||||
const toolInfo = serverToolsMap[server.id]
|
||||
if (toolInfo?.tool) {
|
||||
result.push({ server, tool: toolInfo.tool })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}, [servers, serverToolsMap])
|
||||
|
||||
// Check if any tools need updating
|
||||
const toolsNeedingUpdate = useMemo(() => {
|
||||
return serversWithThisWorkflow.filter(({ tool }) => hasParameterMismatch(tool, inputFormat))
|
||||
}, [serversWithThisWorkflow, inputFormat])
|
||||
|
||||
// Load existing parameter descriptions from the first deployed tool
|
||||
useEffect(() => {
|
||||
if (serversWithThisWorkflow.length > 0) {
|
||||
const existingTool = serversWithThisWorkflow[0].tool
|
||||
const schema = existingTool.parameterSchema as Record<string, unknown> | undefined
|
||||
const properties = schema?.properties as Record<string, { description?: string }> | undefined
|
||||
|
||||
if (properties) {
|
||||
const descriptions: Record<string, string> = {}
|
||||
for (const [name, prop] of Object.entries(properties)) {
|
||||
// Only use description if it differs from the field name (i.e., it's custom)
|
||||
if (
|
||||
prop.description &&
|
||||
prop.description !== name &&
|
||||
prop.description !== 'Array of file objects'
|
||||
) {
|
||||
descriptions[name] = prop.description
|
||||
}
|
||||
}
|
||||
if (Object.keys(descriptions).length > 0) {
|
||||
setParameterDescriptions(descriptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [serversWithThisWorkflow])
|
||||
|
||||
// Reset form when selected server changes
|
||||
useEffect(() => {
|
||||
if (selectedServer) {
|
||||
setToolName(sanitizeToolName(workflowName))
|
||||
setToolDescription(workflowDescription || `Execute ${workflowName} workflow`)
|
||||
}
|
||||
}, [selectedServer, workflowName, workflowDescription])
|
||||
|
||||
const handleAddTool = useCallback(async () => {
|
||||
if (!selectedServer || !toolName.trim()) return
|
||||
|
||||
try {
|
||||
await addToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId: selectedServer.id,
|
||||
workflowId,
|
||||
toolName: toolName.trim(),
|
||||
toolDescription: toolDescription.trim() || undefined,
|
||||
parameterSchema,
|
||||
})
|
||||
|
||||
setSelectedServer(null)
|
||||
setToolName('')
|
||||
setToolDescription('')
|
||||
|
||||
// Refetch servers to update tool count
|
||||
refetchServers()
|
||||
onAddedToServer?.()
|
||||
|
||||
logger.info(`Added workflow ${workflowId} as tool to server ${selectedServer.id}`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to add tool:', error)
|
||||
}
|
||||
}, [
|
||||
selectedServer,
|
||||
toolName,
|
||||
toolDescription,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
parameterSchema,
|
||||
addToolMutation,
|
||||
refetchServers,
|
||||
onAddedToServer,
|
||||
])
|
||||
|
||||
const handleToolChanged = useCallback(
|
||||
(removedServerId?: string) => {
|
||||
// If a tool was removed from a specific server, clear just that entry
|
||||
// The ServerToolsQuery component will re-query and update the map
|
||||
if (removedServerId) {
|
||||
setServerToolsMap((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[removedServerId]
|
||||
return next
|
||||
})
|
||||
}
|
||||
refetchServers()
|
||||
},
|
||||
[refetchServers]
|
||||
)
|
||||
|
||||
const availableServers = useMemo(() => {
|
||||
const addedServerIds = new Set(serversWithThisWorkflow.map((s) => s.server.id))
|
||||
return servers.filter((server) => !addedServerIds.has(server.id))
|
||||
}, [servers, serversWithThisWorkflow])
|
||||
|
||||
if (!isDeployed) {
|
||||
return (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-[12px] text-center'>
|
||||
<Server className='h-[32px] w-[32px] text-[var(--text-muted)]' />
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<p className='text-[14px] text-[var(--text-primary)]'>Deploy workflow first</p>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
You need to deploy your workflow before adding it as an MCP tool.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoadingServers) {
|
||||
return (
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
<Skeleton className='h-[60px] w-full' />
|
||||
<Skeleton className='h-[40px] w-full' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (servers.length === 0) {
|
||||
return (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-[12px] text-center'>
|
||||
<Server className='h-[32px] w-[32px] text-[var(--text-muted)]' />
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<p className='text-[14px] text-[var(--text-primary)]'>No MCP servers yet</p>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
Create a Workflow MCP Server in Settings → Workflow MCP Servers first.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
{/* Query tools for each server using separate components to follow Rules of Hooks */}
|
||||
{servers.map((server) => (
|
||||
<ServerToolsQuery
|
||||
key={server.id}
|
||||
workspaceId={workspaceId}
|
||||
server={server}
|
||||
workflowId={workflowId}
|
||||
onData={handleServerToolData}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<p className='text-[13px] text-[var(--text-secondary)]'>
|
||||
Add this workflow as an MCP tool to make it callable by external MCP clients like Cursor
|
||||
or Claude Desktop.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Update Warning */}
|
||||
{toolsNeedingUpdate.length > 0 && (
|
||||
<div className='flex items-center gap-[8px] rounded-[6px] border border-amber-500/30 bg-amber-500/10 px-[10px] py-[8px]'>
|
||||
<AlertTriangle className='h-[14px] w-[14px] flex-shrink-0 text-amber-500' />
|
||||
<p className='text-[12px] text-amber-600 dark:text-amber-400'>
|
||||
{toolsNeedingUpdate.length} server{toolsNeedingUpdate.length > 1 ? 's have' : ' has'}{' '}
|
||||
outdated tool definitions. Click "Update" on each to sync with current parameters.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parameter Schema Preview */}
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowParameterSchema(!showParameterSchema)}
|
||||
className='flex items-center gap-[6px] text-left'
|
||||
>
|
||||
{showParameterSchema ? (
|
||||
<ChevronDown className='h-[12px] w-[12px] text-[var(--text-tertiary)]' />
|
||||
) : (
|
||||
<ChevronRight className='h-[12px] w-[12px] text-[var(--text-tertiary)]' />
|
||||
)}
|
||||
<Label className='cursor-pointer text-[13px] text-[var(--text-primary)]'>
|
||||
Current Tool Parameters ({inputFormat.length})
|
||||
</Label>
|
||||
</button>
|
||||
|
||||
{showParameterSchema && (
|
||||
<div className='rounded-[6px] border bg-[var(--surface-4)] p-[12px]'>
|
||||
{inputFormat.length === 0 ? (
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||
No parameters defined. Add input fields in the Starter block to define tool
|
||||
parameters.
|
||||
</p>
|
||||
) : (
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
{inputFormat.map((field, index) => (
|
||||
<div key={index} className='flex flex-col gap-[6px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-mono text-[12px] text-[var(--text-primary)]'>
|
||||
{field.name}
|
||||
</span>
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
{field.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<EmcnInput
|
||||
value={parameterDescriptions[field.name] || ''}
|
||||
onChange={(e) =>
|
||||
setParameterDescriptions((prev) => ({
|
||||
...prev,
|
||||
[field.name]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={`Describe what "${field.name}" is for...`}
|
||||
className='h-[32px] text-[12px]'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<p className='text-[11px] text-[var(--text-muted)]'>
|
||||
Descriptions help MCP clients understand what each parameter is for.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Servers with this workflow */}
|
||||
{serversWithThisWorkflow.length > 0 && (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label className='text-[13px] text-[var(--text-primary)]'>
|
||||
Added to ({serversWithThisWorkflow.length})
|
||||
</Label>
|
||||
<div className='flex flex-col gap-[6px]'>
|
||||
{serversWithThisWorkflow.map(({ server, tool }) => (
|
||||
<ToolOnServer
|
||||
key={server.id}
|
||||
server={server}
|
||||
tool={tool}
|
||||
workspaceId={workspaceId}
|
||||
currentInputFormat={inputFormat}
|
||||
currentParameterSchema={parameterSchema}
|
||||
workflowDescription={workflowDescription}
|
||||
onRemoved={(serverId) => handleToolChanged(serverId)}
|
||||
onUpdated={() => handleToolChanged()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add to new server */}
|
||||
{availableServers.length > 0 ? (
|
||||
<>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label className='text-[13px] text-[var(--text-primary)]'>Add to Server</Label>
|
||||
<Popover open={showServerSelector} onOpenChange={setShowServerSelector}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='default'
|
||||
className='h-[36px] w-full justify-between border bg-[var(--surface-3)]'
|
||||
>
|
||||
<span className={cn(!selectedServer && 'text-[var(--text-muted)]')}>
|
||||
{selectedServer?.name || 'Choose a server...'}
|
||||
</span>
|
||||
<ChevronDown className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side='bottom'
|
||||
align='start'
|
||||
sideOffset={4}
|
||||
className='w-[var(--radix-popover-trigger-width)]'
|
||||
border
|
||||
>
|
||||
{availableServers.map((server) => (
|
||||
<PopoverItem
|
||||
key={server.id}
|
||||
onClick={() => {
|
||||
setSelectedServer(server)
|
||||
setShowServerSelector(false)
|
||||
}}
|
||||
>
|
||||
<Server className='mr-[8px] h-[14px] w-[14px] text-[var(--text-tertiary)]' />
|
||||
<span>{server.name}</span>
|
||||
{server.isPublished && (
|
||||
<Badge variant='outline' className='ml-auto text-[10px]'>
|
||||
Published
|
||||
</Badge>
|
||||
)}
|
||||
</PopoverItem>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{selectedServer && (
|
||||
<>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label className='text-[13px] text-[var(--text-primary)]'>Tool Name</Label>
|
||||
<EmcnInput
|
||||
value={toolName}
|
||||
onChange={(e) => setToolName(e.target.value)}
|
||||
placeholder='e.g., book_flight'
|
||||
className='h-[36px]'
|
||||
/>
|
||||
<p className='text-[11px] text-[var(--text-muted)]'>
|
||||
Use lowercase letters, numbers, and underscores only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label className='text-[13px] text-[var(--text-primary)]'>Description</Label>
|
||||
<EmcnInput
|
||||
value={toolDescription}
|
||||
onChange={(e) => setToolDescription(e.target.value)}
|
||||
placeholder='Describe what this tool does...'
|
||||
className='h-[36px]'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleAddTool}
|
||||
disabled={addToolMutation.isPending || !toolName.trim()}
|
||||
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
|
||||
>
|
||||
<Plus className='mr-[6px] h-[14px] w-[14px]' />
|
||||
{addToolMutation.isPending ? 'Adding...' : 'Add to Server'}
|
||||
</Button>
|
||||
|
||||
{addToolMutation.isError && (
|
||||
<p className='text-[12px] text-[var(--text-error)]'>
|
||||
{addToolMutation.error?.message || 'Failed to add tool'}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : serversWithThisWorkflow.length > 0 ? (
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
This workflow has been added to all available servers.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { ApiDeploy } from './components/api/api'
|
||||
import { ChatDeploy, type ExistingChat } from './components/chat/chat'
|
||||
import { GeneralDeploy } from './components/general/general'
|
||||
import { McpToolDeploy } from './components/mcp-tool/mcp-tool'
|
||||
import { TemplateDeploy } from './components/template/template'
|
||||
|
||||
const logger = createLogger('DeployModal')
|
||||
@@ -49,7 +50,7 @@ interface WorkflowDeploymentInfo {
|
||||
needsRedeployment: boolean
|
||||
}
|
||||
|
||||
type TabView = 'general' | 'api' | 'chat' | 'template'
|
||||
type TabView = 'general' | 'api' | 'chat' | 'template' | 'mcp-tool'
|
||||
|
||||
export function DeployModal({
|
||||
open,
|
||||
@@ -552,6 +553,7 @@ export function DeployModal({
|
||||
<ModalTabsTrigger value='api'>API</ModalTabsTrigger>
|
||||
<ModalTabsTrigger value='chat'>Chat</ModalTabsTrigger>
|
||||
<ModalTabsTrigger value='template'>Template</ModalTabsTrigger>
|
||||
<ModalTabsTrigger value='mcp-tool'>MCP Tool</ModalTabsTrigger>
|
||||
</ModalTabsList>
|
||||
|
||||
<ModalBody className='min-h-0 flex-1'>
|
||||
@@ -610,6 +612,17 @@ export function DeployModal({
|
||||
/>
|
||||
)}
|
||||
</ModalTabsContent>
|
||||
|
||||
<ModalTabsContent value='mcp-tool'>
|
||||
{workflowId && (
|
||||
<McpToolDeploy
|
||||
workflowId={workflowId}
|
||||
workflowName={workflowMetadata?.name || 'Workflow'}
|
||||
workflowDescription={workflowMetadata?.description}
|
||||
isDeployed={isDeployed}
|
||||
/>
|
||||
)}
|
||||
</ModalTabsContent>
|
||||
</ModalBody>
|
||||
</ModalTabs>
|
||||
|
||||
|
||||
@@ -85,11 +85,11 @@ export function ShortInput({
|
||||
const persistSubBlockValueRef = useRef<(value: string) => void>(() => {})
|
||||
|
||||
const justPastedRef = useRef(false)
|
||||
|
||||
const webhookManagement = useWebhookManagement({
|
||||
blockId,
|
||||
triggerId: undefined,
|
||||
isPreview,
|
||||
useWebhookUrl,
|
||||
})
|
||||
|
||||
const wandHook = useWand({
|
||||
|
||||
@@ -9,3 +9,4 @@ export { MCP } from './mcp/mcp'
|
||||
export { SSO } from './sso/sso'
|
||||
export { Subscription } from './subscription/subscription'
|
||||
export { TeamManagement } from './team-management/team-management'
|
||||
export { WorkflowMcpServers } from './workflow-mcp-servers/workflow-mcp-servers'
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Check, ChevronLeft, Clipboard, Globe, Plus, Search, Server, Trash2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Input as EmcnInput,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@/components/emcn'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
useCreateWorkflowMcpServer,
|
||||
useDeleteWorkflowMcpServer,
|
||||
useDeleteWorkflowMcpTool,
|
||||
usePublishWorkflowMcpServer,
|
||||
useUnpublishWorkflowMcpServer,
|
||||
useWorkflowMcpServer,
|
||||
useWorkflowMcpServers,
|
||||
type WorkflowMcpServer,
|
||||
type WorkflowMcpTool,
|
||||
} from '@/hooks/queries/workflow-mcp-servers'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServers')
|
||||
|
||||
function ServerSkeleton() {
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-[12px] rounded-[8px] border bg-[var(--surface-3)] p-[12px]'>
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[4px]'>
|
||||
<Skeleton className='h-[14px] w-[120px]' />
|
||||
<Skeleton className='h-[12px] w-[80px]' />
|
||||
</div>
|
||||
<Skeleton className='h-[28px] w-[60px] rounded-[4px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ServerListItemProps {
|
||||
server: WorkflowMcpServer
|
||||
onViewDetails: () => void
|
||||
onDelete: () => void
|
||||
isDeleting: boolean
|
||||
}
|
||||
|
||||
function ServerListItem({ server, onViewDetails, onDelete, isDeleting }: ServerListItemProps) {
|
||||
return (
|
||||
<div
|
||||
className='flex items-center justify-between gap-[12px] rounded-[8px] border bg-[var(--surface-3)] p-[12px] transition-colors hover:bg-[var(--surface-4)]'
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={onViewDetails}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onViewDetails()}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||
<Server className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-tertiary)]' />
|
||||
<div className='flex min-w-0 flex-col gap-[2px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<span className='truncate font-medium text-[14px] text-[var(--text-primary)]'>
|
||||
{server.name}
|
||||
</span>
|
||||
{server.isPublished && (
|
||||
<Badge variant='outline' className='flex-shrink-0 text-[10px]'>
|
||||
<Globe className='mr-[4px] h-[10px] w-[10px]' />
|
||||
Published
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
{server.toolCount || 0} tool{(server.toolCount || 0) !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
className='h-[28px] px-[8px]'
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ServerDetailViewProps {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewProps) {
|
||||
const { data, isLoading, error } = useWorkflowMcpServer(workspaceId, serverId)
|
||||
const publishMutation = usePublishWorkflowMcpServer()
|
||||
const unpublishMutation = useUnpublishWorkflowMcpServer()
|
||||
const deleteToolMutation = useDeleteWorkflowMcpTool()
|
||||
const [copiedUrl, setCopiedUrl] = useState(false)
|
||||
const [toolToDelete, setToolToDelete] = useState<WorkflowMcpTool | null>(null)
|
||||
|
||||
const mcpServerUrl = useMemo(() => {
|
||||
if (!data?.server?.isPublished) return null
|
||||
return `${getBaseUrl()}/api/mcp/serve/${serverId}/sse`
|
||||
}, [data?.server?.isPublished, serverId])
|
||||
|
||||
const handlePublish = async () => {
|
||||
try {
|
||||
await publishMutation.mutateAsync({ workspaceId, serverId })
|
||||
} catch (error) {
|
||||
logger.error('Failed to publish server:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnpublish = async () => {
|
||||
try {
|
||||
await unpublishMutation.mutateAsync({ workspaceId, serverId })
|
||||
} catch (error) {
|
||||
logger.error('Failed to unpublish server:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
if (mcpServerUrl) {
|
||||
navigator.clipboard.writeText(mcpServerUrl)
|
||||
setCopiedUrl(true)
|
||||
setTimeout(() => setCopiedUrl(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteTool = async () => {
|
||||
if (!toolToDelete) return
|
||||
try {
|
||||
await deleteToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId,
|
||||
toolId: toolToDelete.id,
|
||||
})
|
||||
setToolToDelete(null)
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete tool:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex h-full flex-col gap-[16px]'>
|
||||
<Skeleton className='h-[24px] w-[200px]' />
|
||||
<Skeleton className='h-[100px] w-full' />
|
||||
<Skeleton className='h-[150px] w-full' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-[8px]'>
|
||||
<p className='text-[13px] text-[var(--text-error)]'>Failed to load server details</p>
|
||||
<Button variant='default' onClick={onBack}>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { server, tools } = data
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex h-full flex-col gap-[16px]'>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Server Name
|
||||
</span>
|
||||
<p className='text-[14px] text-[var(--text-secondary)]'>{server.name}</p>
|
||||
</div>
|
||||
|
||||
{server.description && (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Description
|
||||
</span>
|
||||
<p className='text-[14px] text-[var(--text-secondary)]'>{server.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>Status</span>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{server.isPublished ? (
|
||||
<>
|
||||
<Badge variant='outline' className='text-[12px]'>
|
||||
<Globe className='mr-[4px] h-[12px] w-[12px]' />
|
||||
Published
|
||||
</Badge>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleUnpublish}
|
||||
disabled={unpublishMutation.isPending}
|
||||
className='h-[28px] text-[12px]'
|
||||
>
|
||||
{unpublishMutation.isPending ? 'Unpublishing...' : 'Unpublish'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className='text-[14px] text-[var(--text-tertiary)]'>Not Published</span>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={handlePublish}
|
||||
disabled={publishMutation.isPending || tools.length === 0}
|
||||
className='h-[28px] text-[12px]'
|
||||
>
|
||||
{publishMutation.isPending ? 'Publishing...' : 'Publish'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{publishMutation.isError && (
|
||||
<p className='text-[12px] text-[var(--text-error)]'>
|
||||
{publishMutation.error?.message || 'Failed to publish'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mcpServerUrl && (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
MCP Server URL
|
||||
</span>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<code className='flex-1 truncate rounded-[4px] bg-[var(--surface-5)] px-[8px] py-[6px] font-mono text-[12px] text-[var(--text-secondary)]'>
|
||||
{mcpServerUrl}
|
||||
</code>
|
||||
<Button variant='ghost' onClick={handleCopyUrl} className='h-[32px] w-[32px] p-0'>
|
||||
{copiedUrl ? (
|
||||
<Check className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<Clipboard className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className='text-[11px] text-[var(--text-tertiary)]'>
|
||||
Use this URL to connect external MCP clients like Cursor or Claude Desktop.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Tools ({tools.length})
|
||||
</span>
|
||||
{tools.length === 0 ? (
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
No tools added yet. Deploy a workflow and add it as a tool from the deploy modal.
|
||||
</p>
|
||||
) : (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
{tools.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className='flex items-center justify-between rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
|
||||
>
|
||||
<div className='flex min-w-0 flex-col gap-[2px]'>
|
||||
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
{tool.toolName}
|
||||
</p>
|
||||
{tool.toolDescription && (
|
||||
<p className='truncate text-[12px] text-[var(--text-tertiary)]'>
|
||||
{tool.toolDescription}
|
||||
</p>
|
||||
)}
|
||||
{tool.workflowName && (
|
||||
<p className='text-[11px] text-[var(--text-muted)]'>
|
||||
Workflow: {tool.workflowName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => setToolToDelete(tool)}
|
||||
className='h-[24px] w-[24px] p-0 text-[var(--text-tertiary)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<Trash2 className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-auto flex items-center justify-end'>
|
||||
<Button
|
||||
onClick={onBack}
|
||||
variant='primary'
|
||||
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
|
||||
>
|
||||
<ChevronLeft className='mr-[4px] h-[14px] w-[14px]' />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={!!toolToDelete} onOpenChange={(open) => !open && setToolToDelete(null)}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalHeader>Remove Tool</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
Are you sure you want to remove{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{toolToDelete?.toolName}
|
||||
</span>{' '}
|
||||
from this server?
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => setToolToDelete(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleDeleteTool}
|
||||
disabled={deleteToolMutation.isPending}
|
||||
className='!bg-[var(--text-error)] !text-white hover:!bg-[var(--text-error)]/90'
|
||||
>
|
||||
{deleteToolMutation.isPending ? 'Removing...' : 'Remove'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow MCP Servers settings component.
|
||||
* Allows users to create and manage MCP servers that expose workflows as tools.
|
||||
*/
|
||||
export function WorkflowMcpServers() {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const { data: servers = [], isLoading, error } = useWorkflowMcpServers(workspaceId)
|
||||
const createServerMutation = useCreateWorkflowMcpServer()
|
||||
const deleteServerMutation = useDeleteWorkflowMcpServer()
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [formData, setFormData] = useState({ name: '', description: '' })
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(null)
|
||||
const [serverToDelete, setServerToDelete] = useState<WorkflowMcpServer | null>(null)
|
||||
const [deletingServers, setDeletingServers] = useState<Set<string>>(new Set())
|
||||
|
||||
const filteredServers = useMemo(() => {
|
||||
if (!searchTerm.trim()) return servers
|
||||
const search = searchTerm.toLowerCase()
|
||||
return servers.filter(
|
||||
(server) =>
|
||||
server.name.toLowerCase().includes(search) ||
|
||||
server.description?.toLowerCase().includes(search)
|
||||
)
|
||||
}, [servers, searchTerm])
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormData({ name: '', description: '' })
|
||||
setShowAddForm(false)
|
||||
}, [])
|
||||
|
||||
const handleCreateServer = async () => {
|
||||
if (!formData.name.trim()) return
|
||||
|
||||
try {
|
||||
await createServerMutation.mutateAsync({
|
||||
workspaceId,
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
})
|
||||
resetForm()
|
||||
} catch (error) {
|
||||
logger.error('Failed to create server:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteServer = async () => {
|
||||
if (!serverToDelete) return
|
||||
|
||||
setDeletingServers((prev) => new Set(prev).add(serverToDelete.id))
|
||||
setServerToDelete(null)
|
||||
|
||||
try {
|
||||
await deleteServerMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId: serverToDelete.id,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete server:', error)
|
||||
} finally {
|
||||
setDeletingServers((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(serverToDelete.id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const hasServers = servers.length > 0
|
||||
const showEmptyState = !hasServers && !showAddForm
|
||||
const showNoResults = searchTerm.trim() && filteredServers.length === 0 && hasServers
|
||||
const isFormValid = formData.name.trim().length > 0
|
||||
|
||||
// Show detail view if a server is selected
|
||||
if (selectedServerId) {
|
||||
return (
|
||||
<ServerDetailView
|
||||
workspaceId={workspaceId}
|
||||
serverId={selectedServerId}
|
||||
onBack={() => setSelectedServerId(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex h-full flex-col gap-[16px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 items-center gap-[8px] rounded-[8px] border bg-[var(--surface-6)] px-[8px] py-[5px]',
|
||||
isLoading && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<Search
|
||||
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Input
|
||||
placeholder='Search servers...'
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-100'
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
disabled={isLoading}
|
||||
variant='primary'
|
||||
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
|
||||
>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div className='rounded-[8px] border bg-[var(--surface-3)] p-[12px]'>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
<div className='flex flex-col gap-[6px]'>
|
||||
<label
|
||||
htmlFor='mcp-server-name'
|
||||
className='font-medium text-[13px] text-[var(--text-secondary)]'
|
||||
>
|
||||
Server Name
|
||||
</label>
|
||||
<EmcnInput
|
||||
id='mcp-server-name'
|
||||
placeholder='e.g., My Workflow Tools'
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
||||
className='h-9'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[6px]'>
|
||||
<label
|
||||
htmlFor='mcp-server-description'
|
||||
className='font-medium text-[13px] text-[var(--text-secondary)]'
|
||||
>
|
||||
Description (optional)
|
||||
</label>
|
||||
<EmcnInput
|
||||
id='mcp-server-description'
|
||||
placeholder='Describe what this server provides...'
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, description: e.target.value }))
|
||||
}
|
||||
className='h-9'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-end gap-[8px] pt-[4px]'>
|
||||
<Button variant='ghost' onClick={resetForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateServer}
|
||||
disabled={!isFormValid || createServerMutation.isPending}
|
||||
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
|
||||
>
|
||||
{createServerMutation.isPending ? 'Creating...' : 'Create Server'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
{error ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-[8px]'>
|
||||
<p className='text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{error instanceof Error ? error.message : 'Failed to load servers'}
|
||||
</p>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<ServerSkeleton />
|
||||
<ServerSkeleton />
|
||||
</div>
|
||||
) : showEmptyState ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-[8px] text-center'>
|
||||
<Server className='h-[32px] w-[32px] text-[var(--text-muted)]' />
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
No workflow MCP servers yet.
|
||||
<br />
|
||||
Create one to expose your workflows as MCP tools.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
{filteredServers.map((server) => (
|
||||
<ServerListItem
|
||||
key={server.id}
|
||||
server={server}
|
||||
onViewDetails={() => setSelectedServerId(server.id)}
|
||||
onDelete={() => setServerToDelete(server)}
|
||||
isDeleting={deletingServers.has(server.id)}
|
||||
/>
|
||||
))}
|
||||
{showNoResults && (
|
||||
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
|
||||
No servers found matching "{searchTerm}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={!!serverToDelete} onOpenChange={(open) => !open && setServerToDelete(null)}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalHeader>Delete MCP Server</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{serverToDelete?.name}</span>
|
||||
?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will remove all tools and cannot be undone.
|
||||
</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => setServerToDelete(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleDeleteServer}
|
||||
className='!bg-[var(--text-error)] !text-white hover:!bg-[var(--text-error)]/90'
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Files, LogIn, Settings, User, Users, Wrench } from 'lucide-react'
|
||||
import { Files, LogIn, Server, Settings, User, Users, Wrench } from 'lucide-react'
|
||||
import {
|
||||
Card,
|
||||
Connections,
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
SSO,
|
||||
Subscription,
|
||||
TeamManagement,
|
||||
WorkflowMcpServers,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components'
|
||||
import { TemplateProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile'
|
||||
import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general-settings'
|
||||
@@ -69,6 +70,7 @@ type SettingsSection =
|
||||
| 'copilot'
|
||||
| 'mcp'
|
||||
| 'custom-tools'
|
||||
| 'workflow-mcp-servers'
|
||||
|
||||
type NavigationSection = 'account' | 'subscription' | 'tools' | 'system'
|
||||
|
||||
@@ -112,6 +114,7 @@ const allNavigationItems: NavigationItem[] = [
|
||||
{ id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' },
|
||||
{ id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' },
|
||||
{ id: 'mcp', label: 'MCPs', icon: McpIcon, section: 'tools' },
|
||||
{ id: 'workflow-mcp-servers', label: 'Workflow MCP Servers', icon: Server, section: 'tools' },
|
||||
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
|
||||
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
|
||||
{
|
||||
@@ -459,6 +462,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
{activeSection === 'copilot' && <Copilot />}
|
||||
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
|
||||
{activeSection === 'custom-tools' && <CustomTools />}
|
||||
{activeSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
|
||||
</SModalMainBody>
|
||||
</SModalMain>
|
||||
</SModalContent>
|
||||
|
||||
@@ -14,7 +14,7 @@ export type WorkflowExecutionPayload = {
|
||||
workflowId: string
|
||||
userId: string
|
||||
input?: any
|
||||
triggerType?: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
|
||||
triggerType?: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export const notificationKeys = {
|
||||
|
||||
type NotificationType = 'webhook' | 'email' | 'slack'
|
||||
type LogLevel = 'info' | 'error'
|
||||
type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
|
||||
type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
|
||||
|
||||
type AlertRuleType =
|
||||
| 'consecutive_failures'
|
||||
|
||||
508
apps/sim/hooks/queries/workflow-mcp-servers.ts
Normal file
508
apps/sim/hooks/queries/workflow-mcp-servers.ts
Normal file
@@ -0,0 +1,508 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServerQueries')
|
||||
|
||||
/**
|
||||
* Query key factories for Workflow MCP Server queries
|
||||
*/
|
||||
export const workflowMcpServerKeys = {
|
||||
all: ['workflow-mcp-servers'] as const,
|
||||
servers: (workspaceId: string) => [...workflowMcpServerKeys.all, 'servers', workspaceId] as const,
|
||||
server: (workspaceId: string, serverId: string) =>
|
||||
[...workflowMcpServerKeys.servers(workspaceId), serverId] as const,
|
||||
tools: (workspaceId: string, serverId: string) =>
|
||||
[...workflowMcpServerKeys.server(workspaceId, serverId), 'tools'] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow MCP Server Types
|
||||
*/
|
||||
export interface WorkflowMcpServer {
|
||||
id: string
|
||||
workspaceId: string
|
||||
createdBy: string
|
||||
name: string
|
||||
description: string | null
|
||||
isPublished: boolean
|
||||
publishedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
toolCount?: number
|
||||
}
|
||||
|
||||
export interface WorkflowMcpTool {
|
||||
id: string
|
||||
serverId: string
|
||||
workflowId: string
|
||||
toolName: string
|
||||
toolDescription: string | null
|
||||
parameterSchema: Record<string, unknown>
|
||||
isEnabled: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
workflowName?: string
|
||||
workflowDescription?: string | null
|
||||
isDeployed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch workflow MCP servers for a workspace
|
||||
*/
|
||||
async function fetchWorkflowMcpServers(workspaceId: string): Promise<WorkflowMcpServer[]> {
|
||||
const response = await fetch(`/api/mcp/workflow-servers?workspaceId=${workspaceId}`)
|
||||
|
||||
if (response.status === 404) {
|
||||
return []
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch workflow MCP servers')
|
||||
}
|
||||
|
||||
return data.data?.servers || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch workflow MCP servers
|
||||
*/
|
||||
export function useWorkflowMcpServers(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: workflowMcpServerKeys.servers(workspaceId),
|
||||
queryFn: () => fetchWorkflowMcpServers(workspaceId),
|
||||
enabled: !!workspaceId,
|
||||
retry: false,
|
||||
staleTime: 60 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single workflow MCP server with its tools
|
||||
*/
|
||||
async function fetchWorkflowMcpServer(
|
||||
workspaceId: string,
|
||||
serverId: string
|
||||
): Promise<{ server: WorkflowMcpServer; tools: WorkflowMcpTool[] }> {
|
||||
const response = await fetch(`/api/mcp/workflow-servers/${serverId}?workspaceId=${workspaceId}`)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch workflow MCP server')
|
||||
}
|
||||
|
||||
return {
|
||||
server: data.data?.server,
|
||||
tools: data.data?.tools || [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a single workflow MCP server
|
||||
*/
|
||||
export function useWorkflowMcpServer(workspaceId: string, serverId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: workflowMcpServerKeys.server(workspaceId, serverId || ''),
|
||||
queryFn: () => fetchWorkflowMcpServer(workspaceId, serverId!),
|
||||
enabled: !!workspaceId && !!serverId,
|
||||
retry: false,
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch tools for a workflow MCP server
|
||||
*/
|
||||
async function fetchWorkflowMcpTools(
|
||||
workspaceId: string,
|
||||
serverId: string
|
||||
): Promise<WorkflowMcpTool[]> {
|
||||
const response = await fetch(
|
||||
`/api/mcp/workflow-servers/${serverId}/tools?workspaceId=${workspaceId}`
|
||||
)
|
||||
|
||||
if (response.status === 404) {
|
||||
return []
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch workflow MCP tools')
|
||||
}
|
||||
|
||||
return data.data?.tools || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch tools for a workflow MCP server
|
||||
*/
|
||||
export function useWorkflowMcpTools(workspaceId: string, serverId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: workflowMcpServerKeys.tools(workspaceId, serverId || ''),
|
||||
queryFn: () => fetchWorkflowMcpTools(workspaceId, serverId!),
|
||||
enabled: !!workspaceId && !!serverId,
|
||||
retry: false,
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create workflow MCP server mutation
|
||||
*/
|
||||
interface CreateWorkflowMcpServerParams {
|
||||
workspaceId: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function useCreateWorkflowMcpServer() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, name, description }: CreateWorkflowMcpServerParams) => {
|
||||
const response = await fetch('/api/mcp/workflow-servers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId, name, description }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to create workflow MCP server')
|
||||
}
|
||||
|
||||
logger.info(`Created workflow MCP server: ${name}`)
|
||||
return data.data?.server as WorkflowMcpServer
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update workflow MCP server mutation
|
||||
*/
|
||||
interface UpdateWorkflowMcpServerParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
name?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function useUpdateWorkflowMcpServer() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
serverId,
|
||||
name,
|
||||
description,
|
||||
}: UpdateWorkflowMcpServerParams) => {
|
||||
const response = await fetch(
|
||||
`/api/mcp/workflow-servers/${serverId}?workspaceId=${workspaceId}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description }),
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to update workflow MCP server')
|
||||
}
|
||||
|
||||
logger.info(`Updated workflow MCP server: ${serverId}`)
|
||||
return data.data?.server as WorkflowMcpServer
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.server(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete workflow MCP server mutation
|
||||
*/
|
||||
interface DeleteWorkflowMcpServerParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
}
|
||||
|
||||
export function useDeleteWorkflowMcpServer() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, serverId }: DeleteWorkflowMcpServerParams) => {
|
||||
const response = await fetch(
|
||||
`/api/mcp/workflow-servers/${serverId}?workspaceId=${workspaceId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to delete workflow MCP server')
|
||||
}
|
||||
|
||||
logger.info(`Deleted workflow MCP server: ${serverId}`)
|
||||
return data
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish workflow MCP server mutation
|
||||
*/
|
||||
interface PublishWorkflowMcpServerParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
}
|
||||
|
||||
export interface PublishWorkflowMcpServerResult {
|
||||
server: WorkflowMcpServer
|
||||
mcpServerUrl: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export function usePublishWorkflowMcpServer() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
serverId,
|
||||
}: PublishWorkflowMcpServerParams): Promise<PublishWorkflowMcpServerResult> => {
|
||||
const response = await fetch(
|
||||
`/api/mcp/workflow-servers/${serverId}/publish?workspaceId=${workspaceId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to publish workflow MCP server')
|
||||
}
|
||||
|
||||
logger.info(`Published workflow MCP server: ${serverId}`)
|
||||
return data.data
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.server(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpublish workflow MCP server mutation
|
||||
*/
|
||||
export function useUnpublishWorkflowMcpServer() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, serverId }: PublishWorkflowMcpServerParams) => {
|
||||
const response = await fetch(
|
||||
`/api/mcp/workflow-servers/${serverId}/publish?workspaceId=${workspaceId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to unpublish workflow MCP server')
|
||||
}
|
||||
|
||||
logger.info(`Unpublished workflow MCP server: ${serverId}`)
|
||||
return data.data
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.server(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add tool to workflow MCP server mutation
|
||||
*/
|
||||
interface AddWorkflowMcpToolParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
workflowId: string
|
||||
toolName?: string
|
||||
toolDescription?: string
|
||||
parameterSchema?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function useAddWorkflowMcpTool() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
serverId,
|
||||
workflowId,
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterSchema,
|
||||
}: AddWorkflowMcpToolParams) => {
|
||||
const response = await fetch(
|
||||
`/api/mcp/workflow-servers/${serverId}/tools?workspaceId=${workspaceId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workflowId, toolName, toolDescription, parameterSchema }),
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to add tool to workflow MCP server')
|
||||
}
|
||||
|
||||
logger.info(`Added tool to workflow MCP server: ${serverId}`)
|
||||
return data.data?.tool as WorkflowMcpTool
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.server(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.tools(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tool mutation
|
||||
*/
|
||||
interface UpdateWorkflowMcpToolParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
toolId: string
|
||||
toolName?: string
|
||||
toolDescription?: string
|
||||
parameterSchema?: Record<string, unknown>
|
||||
isEnabled?: boolean
|
||||
}
|
||||
|
||||
export function useUpdateWorkflowMcpTool() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
serverId,
|
||||
toolId,
|
||||
...updates
|
||||
}: UpdateWorkflowMcpToolParams) => {
|
||||
const response = await fetch(
|
||||
`/api/mcp/workflow-servers/${serverId}/tools/${toolId}?workspaceId=${workspaceId}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to update tool')
|
||||
}
|
||||
|
||||
logger.info(`Updated tool ${toolId} in workflow MCP server: ${serverId}`)
|
||||
return data.data?.tool as WorkflowMcpTool
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.tools(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete tool mutation
|
||||
*/
|
||||
interface DeleteWorkflowMcpToolParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
toolId: string
|
||||
}
|
||||
|
||||
export function useDeleteWorkflowMcpTool() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, serverId, toolId }: DeleteWorkflowMcpToolParams) => {
|
||||
const response = await fetch(
|
||||
`/api/mcp/workflow-servers/${serverId}/tools/${toolId}?workspaceId=${workspaceId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to delete tool')
|
||||
}
|
||||
|
||||
logger.info(`Deleted tool ${toolId} from workflow MCP server: ${serverId}`)
|
||||
return data
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.server(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.tools(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -14,6 +14,7 @@ interface UseWebhookManagementProps {
|
||||
blockId: string
|
||||
triggerId?: string
|
||||
isPreview?: boolean
|
||||
useWebhookUrl?: boolean
|
||||
}
|
||||
|
||||
interface WebhookManagementState {
|
||||
@@ -90,6 +91,7 @@ export function useWebhookManagement({
|
||||
blockId,
|
||||
triggerId,
|
||||
isPreview = false,
|
||||
useWebhookUrl = false,
|
||||
}: UseWebhookManagementProps): WebhookManagementState {
|
||||
const params = useParams()
|
||||
const workflowId = params.workflowId as string
|
||||
@@ -134,7 +136,6 @@ export function useWebhookManagement({
|
||||
const currentlyLoading = store.loadingWebhooks.has(blockId)
|
||||
const alreadyChecked = store.checkedWebhooks.has(blockId)
|
||||
const currentWebhookId = store.getValue(blockId, 'webhookId')
|
||||
|
||||
if (currentlyLoading || (alreadyChecked && currentWebhookId)) {
|
||||
return
|
||||
}
|
||||
@@ -205,7 +206,9 @@ export function useWebhookManagement({
|
||||
}
|
||||
}
|
||||
|
||||
loadWebhookOrGenerateUrl()
|
||||
if (useWebhookUrl) {
|
||||
loadWebhookOrGenerateUrl()
|
||||
}
|
||||
}, [isPreview, triggerId, workflowId, blockId])
|
||||
|
||||
const createWebhook = async (
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import type { TokenBucketConfig } from './storage'
|
||||
|
||||
export type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'api-endpoint'
|
||||
export type TriggerType =
|
||||
| 'api'
|
||||
| 'webhook'
|
||||
| 'schedule'
|
||||
| 'manual'
|
||||
| 'chat'
|
||||
| 'mcp'
|
||||
| 'api-endpoint'
|
||||
|
||||
export type RateLimitCounterType = 'sync' | 'async' | 'api-endpoint'
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ export interface PreprocessExecutionOptions {
|
||||
// Required fields
|
||||
workflowId: string
|
||||
userId: string // The authenticated user ID
|
||||
triggerType: 'manual' | 'api' | 'webhook' | 'schedule' | 'chat'
|
||||
triggerType: 'manual' | 'api' | 'webhook' | 'schedule' | 'chat' | 'mcp'
|
||||
executionId: string
|
||||
requestId: string
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export function getTriggerOptions(): TriggerOption[] {
|
||||
{ value: 'schedule', label: 'Schedule', color: '#059669' },
|
||||
{ value: 'chat', label: 'Chat', color: '#7c3aed' },
|
||||
{ value: 'webhook', label: 'Webhook', color: '#ea580c' },
|
||||
{ value: 'mcp', label: 'MCP', color: '#dc2626' },
|
||||
]
|
||||
|
||||
for (const trigger of triggers) {
|
||||
|
||||
115
apps/sim/lib/mcp/serve-auth.ts
Normal file
115
apps/sim/lib/mcp/serve-auth.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowMcpServer } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('McpServeAuth')
|
||||
|
||||
export interface McpServeAuthResult {
|
||||
success: boolean
|
||||
userId?: string
|
||||
workspaceId?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates authentication for accessing a workflow MCP server.
|
||||
*
|
||||
* Authentication can be done via:
|
||||
* 1. API Key (X-API-Key header) - for programmatic access
|
||||
* 2. Session cookie - for logged-in users
|
||||
*
|
||||
* The user must have at least read access to the workspace that owns the server.
|
||||
*/
|
||||
export async function validateMcpServeAuth(
|
||||
request: NextRequest,
|
||||
serverId: string
|
||||
): Promise<McpServeAuthResult> {
|
||||
try {
|
||||
// First, get the server to find its workspace
|
||||
const [server] = await db
|
||||
.select({
|
||||
id: workflowMcpServer.id,
|
||||
workspaceId: workflowMcpServer.workspaceId,
|
||||
isPublished: workflowMcpServer.isPublished,
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.where(eq(workflowMcpServer.id, serverId))
|
||||
.limit(1)
|
||||
|
||||
if (!server) {
|
||||
return { success: false, error: 'Server not found' }
|
||||
}
|
||||
|
||||
if (!server.isPublished) {
|
||||
return { success: false, error: 'Server is not published' }
|
||||
}
|
||||
|
||||
// Check authentication using hybrid auth (supports both session and API key)
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!auth.success || !auth.userId) {
|
||||
return { success: false, error: auth.error || 'Authentication required' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: auth.userId,
|
||||
workspaceId: server.workspaceId,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error validating MCP serve auth:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: 'Authentication validation failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection instructions for an MCP server.
|
||||
* This provides the information users need to connect their MCP clients.
|
||||
*/
|
||||
export function getMcpServerConnectionInfo(
|
||||
serverId: string,
|
||||
serverName: string,
|
||||
baseUrl: string
|
||||
): {
|
||||
sseUrl: string
|
||||
httpUrl: string
|
||||
authHeader: string
|
||||
instructions: string
|
||||
} {
|
||||
const sseUrl = `${baseUrl}/api/mcp/serve/${serverId}/sse`
|
||||
const httpUrl = `${baseUrl}/api/mcp/serve/${serverId}`
|
||||
|
||||
return {
|
||||
sseUrl,
|
||||
httpUrl,
|
||||
authHeader: 'X-API-Key: YOUR_SIM_API_KEY',
|
||||
instructions: `
|
||||
To connect to this MCP server from Cursor or Claude Desktop:
|
||||
|
||||
1. Get your Sim API key from Settings -> API Keys
|
||||
2. Configure your MCP client with:
|
||||
- Server URL: ${sseUrl}
|
||||
- Authentication: Add header "X-API-Key" with your API key
|
||||
|
||||
For Cursor, add to your MCP configuration:
|
||||
{
|
||||
"mcpServers": {
|
||||
"${serverName.toLowerCase().replace(/\s+/g, '-')}": {
|
||||
"url": "${sseUrl}",
|
||||
"headers": {
|
||||
"X-API-Key": "YOUR_SIM_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
For Claude Desktop, configure similarly in your settings.
|
||||
`.trim(),
|
||||
}
|
||||
}
|
||||
399
apps/sim/lib/mcp/workflow-mcp-server.ts
Normal file
399
apps/sim/lib/mcp/workflow-mcp-server.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* Workflow MCP Server
|
||||
*
|
||||
* Creates an MCP server using the official @modelcontextprotocol/sdk
|
||||
* that exposes workflows as tools via a Next.js-compatible transport.
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
||||
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowMcpTool } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { fileItemZodSchema } from '@/lib/mcp/workflow-tool-schema'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServer')
|
||||
|
||||
/**
|
||||
* Convert stored JSON schema to Zod schema.
|
||||
* Uses fileItemZodSchema from workflow-tool-schema for file arrays.
|
||||
*/
|
||||
function jsonSchemaToZodShape(schema: Record<string, unknown> | null): z.ZodRawShape | undefined {
|
||||
if (!schema || schema.type !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const properties = schema.properties as
|
||||
| Record<string, { type: string; description?: string; items?: unknown }>
|
||||
| undefined
|
||||
if (!properties || Object.keys(properties).length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const shape: z.ZodRawShape = {}
|
||||
const required = (schema.required as string[] | undefined) || []
|
||||
|
||||
for (const [key, prop] of Object.entries(properties)) {
|
||||
let zodType: z.ZodTypeAny
|
||||
|
||||
// Check if this array has items (file arrays have items.type === 'object')
|
||||
const hasObjectItems =
|
||||
prop.type === 'array' &&
|
||||
prop.items &&
|
||||
typeof prop.items === 'object' &&
|
||||
(prop.items as Record<string, unknown>).type === 'object'
|
||||
|
||||
switch (prop.type) {
|
||||
case 'string':
|
||||
zodType = z.string()
|
||||
break
|
||||
case 'number':
|
||||
zodType = z.number()
|
||||
break
|
||||
case 'boolean':
|
||||
zodType = z.boolean()
|
||||
break
|
||||
case 'array':
|
||||
if (hasObjectItems) {
|
||||
// File arrays - use the shared file item schema
|
||||
zodType = z.array(fileItemZodSchema)
|
||||
} else {
|
||||
zodType = z.array(z.any())
|
||||
}
|
||||
break
|
||||
case 'object':
|
||||
zodType = z.record(z.any())
|
||||
break
|
||||
default:
|
||||
zodType = z.any()
|
||||
}
|
||||
|
||||
if (prop.description) {
|
||||
zodType = zodType.describe(prop.description)
|
||||
}
|
||||
|
||||
if (!required.includes(key)) {
|
||||
zodType = zodType.optional()
|
||||
}
|
||||
|
||||
shape[key] = zodType
|
||||
}
|
||||
|
||||
return Object.keys(shape).length > 0 ? shape : undefined
|
||||
}
|
||||
|
||||
interface WorkflowTool {
|
||||
id: string
|
||||
toolName: string
|
||||
toolDescription: string | null
|
||||
parameterSchema: Record<string, unknown> | null
|
||||
workflowId: string
|
||||
isEnabled: boolean
|
||||
}
|
||||
|
||||
interface ServerContext {
|
||||
serverId: string
|
||||
serverName: string
|
||||
userId: string
|
||||
workspaceId: string
|
||||
apiKey?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple transport for handling single request/response cycles in Next.js
|
||||
* This transport is designed for stateless request handling where each
|
||||
* request creates a new server instance.
|
||||
*/
|
||||
class NextJsTransport implements Transport {
|
||||
private responseMessage: JSONRPCMessage | null = null
|
||||
private resolveResponse: ((message: JSONRPCMessage) => void) | null = null
|
||||
|
||||
onclose?: () => void
|
||||
onerror?: (error: Error) => void
|
||||
onmessage?: (message: JSONRPCMessage) => void
|
||||
|
||||
async start(): Promise<void> {
|
||||
// No-op for stateless transport
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.onclose?.()
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
this.responseMessage = message
|
||||
this.resolveResponse?.(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects a message into the transport as if it was received from the client
|
||||
*/
|
||||
receiveMessage(message: JSONRPCMessage): void {
|
||||
this.onmessage?.(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the server to send a response
|
||||
*/
|
||||
waitForResponse(): Promise<JSONRPCMessage> {
|
||||
if (this.responseMessage) {
|
||||
return Promise.resolve(this.responseMessage)
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.resolveResponse = resolve
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and configures an MCP server with workflow tools
|
||||
*/
|
||||
async function createConfiguredMcpServer(context: ServerContext): Promise<McpServer> {
|
||||
const { serverId, serverName, apiKey } = context
|
||||
|
||||
// Create the MCP server using the SDK
|
||||
const server = new McpServer({
|
||||
name: serverName,
|
||||
version: '1.0.0',
|
||||
})
|
||||
|
||||
// Load tools from the database
|
||||
const tools = await db
|
||||
.select({
|
||||
id: workflowMcpTool.id,
|
||||
toolName: workflowMcpTool.toolName,
|
||||
toolDescription: workflowMcpTool.toolDescription,
|
||||
parameterSchema: workflowMcpTool.parameterSchema,
|
||||
workflowId: workflowMcpTool.workflowId,
|
||||
isEnabled: workflowMcpTool.isEnabled,
|
||||
})
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.serverId, serverId))
|
||||
|
||||
// Register each enabled tool
|
||||
for (const tool of tools.filter((t) => t.isEnabled)) {
|
||||
const zodSchema = jsonSchemaToZodShape(tool.parameterSchema as Record<string, unknown> | null)
|
||||
|
||||
if (zodSchema) {
|
||||
// Tool with parameters - callback receives (args, extra)
|
||||
server.tool(
|
||||
tool.toolName,
|
||||
tool.toolDescription || `Execute workflow: ${tool.toolName}`,
|
||||
zodSchema,
|
||||
async (args) => {
|
||||
return executeWorkflowTool(tool as WorkflowTool, args, apiKey)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Tool without parameters - callback only receives (extra)
|
||||
server.tool(
|
||||
tool.toolName,
|
||||
tool.toolDescription || `Execute workflow: ${tool.toolName}`,
|
||||
async () => {
|
||||
return executeWorkflowTool(tool as WorkflowTool, {}, apiKey)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Created MCP server "${serverName}" with ${tools.filter((t) => t.isEnabled).length} tools`
|
||||
)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a workflow tool and returns the result
|
||||
*/
|
||||
async function executeWorkflowTool(
|
||||
tool: WorkflowTool,
|
||||
args: Record<string, unknown>,
|
||||
apiKey?: string | null
|
||||
): Promise<{
|
||||
content: Array<{ type: 'text'; text: string }>
|
||||
isError?: boolean
|
||||
}> {
|
||||
logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${tool.toolName}`)
|
||||
|
||||
try {
|
||||
// Verify workflow is deployed
|
||||
const [workflowRecord] = await db
|
||||
.select({ id: workflow.id, isDeployed: workflow.isDeployed })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, tool.workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowRecord || !workflowRecord.isDeployed) {
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ error: 'Workflow is not deployed' }) }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the workflow
|
||||
const baseUrl = getBaseUrl()
|
||||
const executeUrl = `${baseUrl}/api/workflows/${tool.workflowId}/execute`
|
||||
|
||||
const executeHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
if (apiKey) {
|
||||
executeHeaders['X-API-Key'] = apiKey
|
||||
}
|
||||
|
||||
const executeResponse = await fetch(executeUrl, {
|
||||
method: 'POST',
|
||||
headers: executeHeaders,
|
||||
body: JSON.stringify({
|
||||
input: args,
|
||||
triggerType: 'mcp',
|
||||
}),
|
||||
})
|
||||
|
||||
const executeResult = await executeResponse.json()
|
||||
|
||||
if (!executeResponse.ok) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ error: executeResult.error || 'Workflow execution failed' }),
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(executeResult.output || executeResult, null, 2),
|
||||
},
|
||||
],
|
||||
isError: !executeResult.success,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error executing workflow ${tool.workflowId}:`, error)
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ error: 'Tool execution failed' }) }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an MCP JSON-RPC request using the SDK
|
||||
*/
|
||||
export async function handleMcpRequest(
|
||||
context: ServerContext,
|
||||
request: Request
|
||||
): Promise<Response> {
|
||||
try {
|
||||
// Parse the incoming JSON-RPC message
|
||||
const body = await request.json()
|
||||
const message = body as JSONRPCMessage
|
||||
|
||||
// Create transport and server
|
||||
const transport = new NextJsTransport()
|
||||
const server = await createConfiguredMcpServer(context)
|
||||
|
||||
// Connect server to transport
|
||||
await server.connect(transport)
|
||||
|
||||
// Inject the received message
|
||||
transport.receiveMessage(message)
|
||||
|
||||
// Wait for the response
|
||||
const response = await transport.waitForResponse()
|
||||
|
||||
// Clean up
|
||||
await server.close()
|
||||
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-MCP-Server-Name': context.serverName,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error handling MCP request:', error)
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: 'Internal error',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an SSE stream for MCP notifications (used for GET requests)
|
||||
*/
|
||||
export function createMcpSseStream(context: ServerContext): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder()
|
||||
let isStreamClosed = false
|
||||
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
const sendEvent = (event: string, data: unknown) => {
|
||||
if (isStreamClosed) return
|
||||
try {
|
||||
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
||||
controller.enqueue(encoder.encode(message))
|
||||
} catch {
|
||||
isStreamClosed = true
|
||||
}
|
||||
}
|
||||
|
||||
// Send initial connection event
|
||||
sendEvent('open', { type: 'connection', status: 'connected' })
|
||||
|
||||
// Send server capabilities
|
||||
sendEvent('message', {
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/initialized',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
serverInfo: {
|
||||
name: context.serverName,
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Keep connection alive with periodic pings
|
||||
const pingInterval = setInterval(() => {
|
||||
if (isStreamClosed) {
|
||||
clearInterval(pingInterval)
|
||||
return
|
||||
}
|
||||
sendEvent('ping', { timestamp: Date.now() })
|
||||
}, 30000)
|
||||
},
|
||||
|
||||
cancel() {
|
||||
isStreamClosed = true
|
||||
logger.info(`SSE connection closed for server ${context.serverId}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
247
apps/sim/lib/mcp/workflow-tool-schema.ts
Normal file
247
apps/sim/lib/mcp/workflow-tool-schema.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { z } from 'zod'
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
|
||||
/**
|
||||
* MCP Tool Schema following the JSON Schema specification
|
||||
*/
|
||||
export interface McpToolInputSchema {
|
||||
type: 'object'
|
||||
properties: Record<string, McpToolProperty>
|
||||
required?: string[]
|
||||
}
|
||||
|
||||
export interface McpToolProperty {
|
||||
type: string
|
||||
description?: string
|
||||
items?: McpToolProperty
|
||||
properties?: Record<string, McpToolProperty>
|
||||
}
|
||||
|
||||
export interface McpToolDefinition {
|
||||
name: string
|
||||
description: string
|
||||
inputSchema: McpToolInputSchema
|
||||
}
|
||||
|
||||
/**
|
||||
* File item Zod schema for MCP file inputs.
|
||||
* This is the single source of truth for file structure.
|
||||
*/
|
||||
export const fileItemZodSchema = z.object({
|
||||
name: z.string().describe('File name'),
|
||||
data: z.string().describe('Base64 encoded file content'),
|
||||
mimeType: z.string().describe('MIME type of the file'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Convert InputFormatField type to Zod schema
|
||||
*/
|
||||
function fieldTypeToZod(fieldType: string | undefined, isRequired: boolean): z.ZodTypeAny {
|
||||
let zodType: z.ZodTypeAny
|
||||
|
||||
switch (fieldType) {
|
||||
case 'string':
|
||||
zodType = z.string()
|
||||
break
|
||||
case 'number':
|
||||
zodType = z.number()
|
||||
break
|
||||
case 'boolean':
|
||||
zodType = z.boolean()
|
||||
break
|
||||
case 'object':
|
||||
zodType = z.record(z.any())
|
||||
break
|
||||
case 'array':
|
||||
zodType = z.array(z.any())
|
||||
break
|
||||
case 'files':
|
||||
zodType = z.array(fileItemZodSchema)
|
||||
break
|
||||
default:
|
||||
zodType = z.string()
|
||||
}
|
||||
|
||||
return isRequired ? zodType : zodType.optional()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Zod schema shape from InputFormatField array.
|
||||
* This is used directly by the MCP server for tool registration.
|
||||
*/
|
||||
export function generateToolZodSchema(inputFormat: InputFormatField[]): z.ZodRawShape | undefined {
|
||||
if (!inputFormat || inputFormat.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const shape: z.ZodRawShape = {}
|
||||
|
||||
for (const field of inputFormat) {
|
||||
if (!field.name) continue
|
||||
|
||||
const zodType = fieldTypeToZod(field.type, true)
|
||||
shape[field.name] = field.name ? zodType.describe(field.name) : zodType
|
||||
}
|
||||
|
||||
return Object.keys(shape).length > 0 ? shape : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Map InputFormatField type to JSON Schema type (for database storage)
|
||||
*/
|
||||
function mapFieldTypeToJsonSchemaType(fieldType: string | undefined): string {
|
||||
switch (fieldType) {
|
||||
case 'string':
|
||||
return 'string'
|
||||
case 'number':
|
||||
return 'number'
|
||||
case 'boolean':
|
||||
return 'boolean'
|
||||
case 'object':
|
||||
return 'object'
|
||||
case 'array':
|
||||
return 'array'
|
||||
case 'files':
|
||||
return 'array'
|
||||
default:
|
||||
return 'string'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a workflow name to be a valid MCP tool name.
|
||||
* Tool names should be lowercase, alphanumeric with underscores.
|
||||
*/
|
||||
export function sanitizeToolName(name: string): string {
|
||||
return (
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s_-]/g, '')
|
||||
.replace(/[\s-]+/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^_|_$/g, '')
|
||||
.substring(0, 64) || 'workflow_tool'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate MCP tool input schema from InputFormatField array.
|
||||
* This converts the workflow's input format definition to JSON Schema format
|
||||
* that MCP clients can use to understand tool parameters.
|
||||
*/
|
||||
export function generateToolInputSchema(inputFormat: InputFormatField[]): McpToolInputSchema {
|
||||
const properties: Record<string, McpToolProperty> = {}
|
||||
const required: string[] = []
|
||||
|
||||
for (const field of inputFormat) {
|
||||
if (!field.name) continue
|
||||
|
||||
const fieldName = field.name
|
||||
const fieldType = mapFieldTypeToJsonSchemaType(field.type)
|
||||
|
||||
const property: McpToolProperty = {
|
||||
type: fieldType,
|
||||
// Use custom description if provided, otherwise use field name
|
||||
description: field.description?.trim() || fieldName,
|
||||
}
|
||||
|
||||
// Handle array types
|
||||
if (fieldType === 'array') {
|
||||
if (field.type === 'files') {
|
||||
property.items = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'File name' },
|
||||
url: { type: 'string', description: 'File URL' },
|
||||
type: { type: 'string', description: 'MIME type' },
|
||||
size: { type: 'number', description: 'File size in bytes' },
|
||||
},
|
||||
}
|
||||
// Use custom description if provided, otherwise use default
|
||||
if (!field.description?.trim()) {
|
||||
property.description = 'Array of file objects'
|
||||
}
|
||||
} else {
|
||||
property.items = { type: 'string' }
|
||||
}
|
||||
}
|
||||
|
||||
properties[fieldName] = property
|
||||
|
||||
// All fields are considered required by default
|
||||
// (in the future, we could add an optional flag to InputFormatField)
|
||||
required.push(fieldName)
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
properties,
|
||||
required: required.length > 0 ? required : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete MCP tool definition from workflow metadata and input format.
|
||||
*/
|
||||
export function generateToolDefinition(
|
||||
workflowName: string,
|
||||
workflowDescription: string | undefined | null,
|
||||
inputFormat: InputFormatField[],
|
||||
customToolName?: string,
|
||||
customDescription?: string
|
||||
): McpToolDefinition {
|
||||
return {
|
||||
name: customToolName || sanitizeToolName(workflowName),
|
||||
description: customDescription || workflowDescription || `Execute ${workflowName} workflow`,
|
||||
inputSchema: generateToolInputSchema(inputFormat),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid start block types that can have input format
|
||||
*/
|
||||
const VALID_START_BLOCK_TYPES = [
|
||||
'starter',
|
||||
'start',
|
||||
'start_trigger',
|
||||
'api',
|
||||
'api_trigger',
|
||||
'input_trigger',
|
||||
]
|
||||
|
||||
/**
|
||||
* Extract input format from a workflow's blocks.
|
||||
* Looks for any valid start block and extracts its inputFormat configuration.
|
||||
*/
|
||||
export function extractInputFormatFromBlocks(
|
||||
blocks: Record<string, unknown>
|
||||
): InputFormatField[] | null {
|
||||
// Look for any valid start block
|
||||
for (const [, block] of Object.entries(blocks)) {
|
||||
if (!block || typeof block !== 'object') continue
|
||||
|
||||
const blockObj = block as Record<string, unknown>
|
||||
const blockType = blockObj.type as string
|
||||
|
||||
if (VALID_START_BLOCK_TYPES.includes(blockType)) {
|
||||
// Try to get inputFormat from subBlocks
|
||||
const subBlocks = blockObj.subBlocks as Record<string, unknown> | undefined
|
||||
if (subBlocks?.inputFormat) {
|
||||
const inputFormatSubBlock = subBlocks.inputFormat as Record<string, unknown>
|
||||
const value = inputFormatSubBlock.value
|
||||
if (Array.isArray(value)) {
|
||||
return value as InputFormatField[]
|
||||
}
|
||||
}
|
||||
|
||||
// Try legacy config.params.inputFormat
|
||||
const config = blockObj.config as Record<string, unknown> | undefined
|
||||
const params = config?.params as Record<string, unknown> | undefined
|
||||
if (params?.inputFormat && Array.isArray(params.inputFormat)) {
|
||||
return params.inputFormat as InputFormatField[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -10,6 +10,43 @@ import { getTrigger } from '@/triggers'
|
||||
|
||||
const logger = createLogger('TriggerUtils')
|
||||
|
||||
/**
|
||||
* Valid start block types that can trigger a workflow
|
||||
*/
|
||||
export const VALID_START_BLOCK_TYPES = [
|
||||
'starter',
|
||||
'start',
|
||||
'start_trigger',
|
||||
'api',
|
||||
'api_trigger',
|
||||
'input_trigger',
|
||||
] as const
|
||||
|
||||
export type ValidStartBlockType = (typeof VALID_START_BLOCK_TYPES)[number]
|
||||
|
||||
/**
|
||||
* Check if a block type is a valid start block type
|
||||
*/
|
||||
export function isValidStartBlockType(blockType: string): blockType is ValidStartBlockType {
|
||||
return VALID_START_BLOCK_TYPES.includes(blockType as ValidStartBlockType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a workflow state has a valid start block
|
||||
*/
|
||||
export function hasValidStartBlockInState(state: any): boolean {
|
||||
if (!state?.blocks) {
|
||||
return false
|
||||
}
|
||||
|
||||
const startBlock = Object.values(state.blocks).find((block: any) => {
|
||||
const blockType = block?.type
|
||||
return isValidStartBlockType(blockType)
|
||||
})
|
||||
|
||||
return !!startBlock
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates mock data based on the output type definition
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface InputFormatField {
|
||||
name?: string
|
||||
type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files' | string
|
||||
description?: string
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev --port 7321",
|
||||
"dev": "next dev --port 3000",
|
||||
"dev:webpack": "next dev --webpack",
|
||||
"dev:sockets": "bun run socket-server/index.ts",
|
||||
"dev:full": "concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"bun run dev\" \"bun run dev:sockets\"",
|
||||
|
||||
@@ -60,7 +60,9 @@ const parseTriggerArrayFromURL = (value: string | null): TriggerType[] => {
|
||||
if (!value) return []
|
||||
return value
|
||||
.split(',')
|
||||
.filter((t): t is TriggerType => ['chat', 'api', 'webhook', 'manual', 'schedule'].includes(t))
|
||||
.filter((t): t is TriggerType =>
|
||||
['chat', 'api', 'webhook', 'manual', 'schedule', 'mcp'].includes(t)
|
||||
)
|
||||
}
|
||||
|
||||
const parseStringArrayFromURL = (value: string | null): string[] => {
|
||||
|
||||
@@ -166,7 +166,15 @@ export type TimeRange =
|
||||
| 'Past 30 days'
|
||||
| 'All time'
|
||||
export type LogLevel = 'error' | 'info' | 'running' | 'pending' | 'all'
|
||||
export type TriggerType = 'chat' | 'api' | 'webhook' | 'manual' | 'schedule' | 'all' | string
|
||||
export type TriggerType =
|
||||
| 'chat'
|
||||
| 'api'
|
||||
| 'webhook'
|
||||
| 'manual'
|
||||
| 'schedule'
|
||||
| 'mcp'
|
||||
| 'all'
|
||||
| string
|
||||
|
||||
export interface FilterState {
|
||||
// Workspace context
|
||||
|
||||
34
packages/db/migrations/0124_amused_lyja.sql
Normal file
34
packages/db/migrations/0124_amused_lyja.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
CREATE TABLE "workflow_mcp_server" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"workspace_id" text NOT NULL,
|
||||
"created_by" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"is_published" boolean DEFAULT false NOT NULL,
|
||||
"published_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "workflow_mcp_tool" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"server_id" text NOT NULL,
|
||||
"workflow_id" text NOT NULL,
|
||||
"tool_name" text NOT NULL,
|
||||
"tool_description" text,
|
||||
"parameter_schema" json DEFAULT '{}' NOT NULL,
|
||||
"is_enabled" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "workflow_mcp_server" ADD CONSTRAINT "workflow_mcp_server_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "workflow_mcp_server" ADD CONSTRAINT "workflow_mcp_server_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "workflow_mcp_tool" ADD CONSTRAINT "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."workflow_mcp_server"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "workflow_mcp_tool" ADD CONSTRAINT "workflow_mcp_tool_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "workflow_mcp_server_workspace_id_idx" ON "workflow_mcp_server" USING btree ("workspace_id");--> statement-breakpoint
|
||||
CREATE INDEX "workflow_mcp_server_created_by_idx" ON "workflow_mcp_server" USING btree ("created_by");--> statement-breakpoint
|
||||
CREATE INDEX "workflow_mcp_server_is_published_idx" ON "workflow_mcp_server" USING btree ("is_published");--> statement-breakpoint
|
||||
CREATE INDEX "workflow_mcp_tool_server_id_idx" ON "workflow_mcp_tool" USING btree ("server_id");--> statement-breakpoint
|
||||
CREATE INDEX "workflow_mcp_tool_workflow_id_idx" ON "workflow_mcp_tool" USING btree ("workflow_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "workflow_mcp_tool_server_workflow_unique" ON "workflow_mcp_tool" USING btree ("server_id","workflow_id");
|
||||
7999
packages/db/migrations/meta/0124_snapshot.json
Normal file
7999
packages/db/migrations/meta/0124_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -862,6 +862,13 @@
|
||||
"when": 1765932898404,
|
||||
"tag": "0123_windy_lockheed",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 124,
|
||||
"version": "7",
|
||||
"when": 1766018207289,
|
||||
"tag": "0124_amused_lyja",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1598,3 +1598,62 @@ export const ssoProvider = pgTable(
|
||||
organizationIdIdx: index('sso_provider_organization_id_idx').on(table.organizationId),
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Workflow MCP Servers - User-created MCP servers that expose workflows as tools.
|
||||
* These servers can be published and accessed by external MCP clients via OAuth.
|
||||
*/
|
||||
export const workflowMcpServer = pgTable(
|
||||
'workflow_mcp_server',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
workspaceId: text('workspace_id')
|
||||
.notNull()
|
||||
.references(() => workspace.id, { onDelete: 'cascade' }),
|
||||
createdBy: text('created_by')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
isPublished: boolean('is_published').notNull().default(false),
|
||||
publishedAt: timestamp('published_at'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
workspaceIdIdx: index('workflow_mcp_server_workspace_id_idx').on(table.workspaceId),
|
||||
createdByIdx: index('workflow_mcp_server_created_by_idx').on(table.createdBy),
|
||||
isPublishedIdx: index('workflow_mcp_server_is_published_idx').on(table.isPublished),
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Workflow MCP Tools - Workflows registered as tools within a Workflow MCP Server.
|
||||
* Each tool maps to a deployed workflow's execute endpoint.
|
||||
*/
|
||||
export const workflowMcpTool = pgTable(
|
||||
'workflow_mcp_tool',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
serverId: text('server_id')
|
||||
.notNull()
|
||||
.references(() => workflowMcpServer.id, { onDelete: 'cascade' }),
|
||||
workflowId: text('workflow_id')
|
||||
.notNull()
|
||||
.references(() => workflow.id, { onDelete: 'cascade' }),
|
||||
toolName: text('tool_name').notNull(),
|
||||
toolDescription: text('tool_description'),
|
||||
parameterSchema: json('parameter_schema').notNull().default('{}'),
|
||||
isEnabled: boolean('is_enabled').notNull().default(true),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
serverIdIdx: index('workflow_mcp_tool_server_id_idx').on(table.serverId),
|
||||
workflowIdIdx: index('workflow_mcp_tool_workflow_id_idx').on(table.workflowId),
|
||||
serverWorkflowUnique: uniqueIndex('workflow_mcp_tool_server_workflow_unique').on(
|
||||
table.serverId,
|
||||
table.workflowId
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user