mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-21 04:48:00 -05:00
Compare commits
11 Commits
feat/tools
...
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 { and, desc, eq } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
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 { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||||
|
|
||||||
const logger = createLogger('WorkflowDeployAPI')
|
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 dynamic = 'force-dynamic'
|
||||||
export const runtime = 'nodejs'
|
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 }> }) {
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
@@ -119,6 +223,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
|
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
|
||||||
|
|
||||||
|
// Sync MCP tools with the latest parameter schema
|
||||||
|
await syncMcpToolsOnDeploy(id, requestId)
|
||||||
|
|
||||||
const responseApiKeyInfo = workflowData!.workspaceId
|
const responseApiKeyInfo = workflowData!.workspaceId
|
||||||
? 'Workspace API keys'
|
? 'Workspace API keys'
|
||||||
: 'Personal API keys'
|
: 'Personal API keys'
|
||||||
@@ -167,6 +274,9 @@ export async function DELETE(
|
|||||||
.where(eq(workflow.id, id))
|
.where(eq(workflow.id, id))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Remove all MCP tools that reference this workflow
|
||||||
|
await removeMcpToolsOnUndeploy(id, requestId)
|
||||||
|
|
||||||
logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`)
|
logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`)
|
||||||
|
|
||||||
// Track workflow undeployment
|
// 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 { and, eq } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
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 { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/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 dynamic = 'force-dynamic'
|
||||||
export const runtime = 'nodejs'
|
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(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string; version: string }> }
|
{ params }: { params: Promise<{ id: string; version: string }> }
|
||||||
@@ -31,6 +110,18 @@ export async function POST(
|
|||||||
|
|
||||||
const now = new Date()
|
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 db.transaction(async (tx) => {
|
||||||
await tx
|
await tx
|
||||||
.update(workflowDeploymentVersion)
|
.update(workflowDeploymentVersion)
|
||||||
@@ -65,6 +156,11 @@ export async function POST(
|
|||||||
await tx.update(workflow).set(updateData).where(eq(workflow.id, id))
|
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 })
|
return createSuccessResponse({ success: true, deployedAt: now })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`[${requestId}] Error activating deployment for workflow: ${id}`, error)
|
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 { and, eq } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { env } from '@/lib/core/config/env'
|
import { env } from '@/lib/core/config/env'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
|
import {
|
||||||
|
extractInputFormatFromBlocks,
|
||||||
|
generateToolInputSchema,
|
||||||
|
} from '@/lib/mcp/workflow-tool-schema'
|
||||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||||
|
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
|
||||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/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 dynamic = 'force-dynamic'
|
||||||
export const runtime = 'nodejs'
|
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(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string; version: string }> }
|
{ params }: { params: Promise<{ id: string; version: string }> }
|
||||||
@@ -87,6 +166,9 @@ export async function POST(
|
|||||||
.set({ lastSynced: new Date(), updatedAt: new Date() })
|
.set({ lastSynced: new Date(), updatedAt: new Date() })
|
||||||
.where(eq(workflow.id, id))
|
.where(eq(workflow.id, id))
|
||||||
|
|
||||||
|
// Sync MCP tools with the reverted version's parameter schema
|
||||||
|
await syncMcpToolsOnRevert(id, deployedState, requestId)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const socketServerUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002'
|
const socketServerUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002'
|
||||||
await fetch(`${socketServerUrl}/api/workflow-reverted`, {
|
await fetch(`${socketServerUrl}/api/workflow-reverted`, {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const logger = createLogger('WorkflowExecuteAPI')
|
|||||||
|
|
||||||
const ExecuteWorkflowSchema = z.object({
|
const ExecuteWorkflowSchema = z.object({
|
||||||
selectedOutputs: z.array(z.string()).optional().default([]),
|
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(),
|
stream: z.boolean().optional(),
|
||||||
useDraftState: z.boolean().optional(),
|
useDraftState: z.boolean().optional(),
|
||||||
input: z.any().optional(),
|
input: z.any().optional(),
|
||||||
@@ -227,7 +227,7 @@ type AsyncExecutionParams = {
|
|||||||
workflowId: string
|
workflowId: string
|
||||||
userId: string
|
userId: string
|
||||||
input: any
|
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()
|
const executionId = uuidv4()
|
||||||
type LoggingTriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
|
type LoggingTriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
|
||||||
let loggingTriggerType: LoggingTriggerType = 'manual'
|
let loggingTriggerType: LoggingTriggerType = 'manual'
|
||||||
if (
|
if (
|
||||||
triggerType === 'api' ||
|
triggerType === 'api' ||
|
||||||
triggerType === 'chat' ||
|
triggerType === 'chat' ||
|
||||||
triggerType === 'webhook' ||
|
triggerType === 'webhook' ||
|
||||||
triggerType === 'schedule' ||
|
triggerType === 'schedule' ||
|
||||||
triggerType === 'manual'
|
triggerType === 'manual' ||
|
||||||
|
triggerType === 'mcp'
|
||||||
) {
|
) {
|
||||||
loggingTriggerType = triggerType as LoggingTriggerType
|
loggingTriggerType = triggerType as LoggingTriggerType
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const PRIMARY_BUTTON_STYLES =
|
|||||||
|
|
||||||
type NotificationType = 'webhook' | 'email' | 'slack'
|
type NotificationType = 'webhook' | 'email' | 'slack'
|
||||||
type LogLevel = 'info' | 'error'
|
type LogLevel = 'info' | 'error'
|
||||||
type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
|
type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
|
||||||
type AlertRule =
|
type AlertRule =
|
||||||
| 'none'
|
| 'none'
|
||||||
| 'consecutive_failures'
|
| 'consecutive_failures'
|
||||||
@@ -84,7 +84,7 @@ interface NotificationSettingsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LOG_LEVELS: LogLevel[] = ['info', 'error']
|
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: {
|
function formatAlertConfigLabel(config: {
|
||||||
rule: AlertRule
|
rule: AlertRule
|
||||||
@@ -137,7 +137,7 @@ export function NotificationSettings({
|
|||||||
workflowIds: [] as string[],
|
workflowIds: [] as string[],
|
||||||
allWorkflows: true,
|
allWorkflows: true,
|
||||||
levelFilter: ['info', 'error'] as LogLevel[],
|
levelFilter: ['info', 'error'] as LogLevel[],
|
||||||
triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat'] as TriggerType[],
|
triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat', 'mcp'] as TriggerType[],
|
||||||
includeFinalOutput: false,
|
includeFinalOutput: false,
|
||||||
includeTraceSpans: false,
|
includeTraceSpans: false,
|
||||||
includeRateLimits: false,
|
includeRateLimits: false,
|
||||||
@@ -207,7 +207,7 @@ export function NotificationSettings({
|
|||||||
workflowIds: [],
|
workflowIds: [],
|
||||||
allWorkflows: true,
|
allWorkflows: true,
|
||||||
levelFilter: ['info', 'error'],
|
levelFilter: ['info', 'error'],
|
||||||
triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat'],
|
triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat', 'mcp'],
|
||||||
includeFinalOutput: false,
|
includeFinalOutput: false,
|
||||||
includeTraceSpans: false,
|
includeTraceSpans: false,
|
||||||
includeRateLimits: false,
|
includeRateLimits: false,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { useFolderStore } from '@/stores/folders/store'
|
|||||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||||
import { AutocompleteSearch } from './components/search'
|
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[] = [
|
const TIME_RANGE_OPTIONS: ComboboxOption[] = [
|
||||||
{ value: 'All time', label: 'All time' },
|
{ value: 'All time', label: 'All time' },
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Badge } from '@/components/emcn'
|
|||||||
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
|
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
|
||||||
import { getBlock } from '@/blocks/registry'
|
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 RUNNING_COLOR = '#22c55e' as const
|
||||||
const PENDING_COLOR = '#f59e0b' 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 { ApiDeploy } from './components/api/api'
|
||||||
import { ChatDeploy, type ExistingChat } from './components/chat/chat'
|
import { ChatDeploy, type ExistingChat } from './components/chat/chat'
|
||||||
import { GeneralDeploy } from './components/general/general'
|
import { GeneralDeploy } from './components/general/general'
|
||||||
|
import { McpToolDeploy } from './components/mcp-tool/mcp-tool'
|
||||||
import { TemplateDeploy } from './components/template/template'
|
import { TemplateDeploy } from './components/template/template'
|
||||||
|
|
||||||
const logger = createLogger('DeployModal')
|
const logger = createLogger('DeployModal')
|
||||||
@@ -49,7 +50,7 @@ interface WorkflowDeploymentInfo {
|
|||||||
needsRedeployment: boolean
|
needsRedeployment: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabView = 'general' | 'api' | 'chat' | 'template'
|
type TabView = 'general' | 'api' | 'chat' | 'template' | 'mcp-tool'
|
||||||
|
|
||||||
export function DeployModal({
|
export function DeployModal({
|
||||||
open,
|
open,
|
||||||
@@ -552,6 +553,7 @@ export function DeployModal({
|
|||||||
<ModalTabsTrigger value='api'>API</ModalTabsTrigger>
|
<ModalTabsTrigger value='api'>API</ModalTabsTrigger>
|
||||||
<ModalTabsTrigger value='chat'>Chat</ModalTabsTrigger>
|
<ModalTabsTrigger value='chat'>Chat</ModalTabsTrigger>
|
||||||
<ModalTabsTrigger value='template'>Template</ModalTabsTrigger>
|
<ModalTabsTrigger value='template'>Template</ModalTabsTrigger>
|
||||||
|
<ModalTabsTrigger value='mcp-tool'>MCP Tool</ModalTabsTrigger>
|
||||||
</ModalTabsList>
|
</ModalTabsList>
|
||||||
|
|
||||||
<ModalBody className='min-h-0 flex-1'>
|
<ModalBody className='min-h-0 flex-1'>
|
||||||
@@ -610,6 +612,17 @@ export function DeployModal({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ModalTabsContent>
|
</ModalTabsContent>
|
||||||
|
|
||||||
|
<ModalTabsContent value='mcp-tool'>
|
||||||
|
{workflowId && (
|
||||||
|
<McpToolDeploy
|
||||||
|
workflowId={workflowId}
|
||||||
|
workflowName={workflowMetadata?.name || 'Workflow'}
|
||||||
|
workflowDescription={workflowMetadata?.description}
|
||||||
|
isDeployed={isDeployed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ModalTabsContent>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
</ModalTabs>
|
</ModalTabs>
|
||||||
|
|
||||||
|
|||||||
@@ -85,11 +85,11 @@ export function ShortInput({
|
|||||||
const persistSubBlockValueRef = useRef<(value: string) => void>(() => {})
|
const persistSubBlockValueRef = useRef<(value: string) => void>(() => {})
|
||||||
|
|
||||||
const justPastedRef = useRef(false)
|
const justPastedRef = useRef(false)
|
||||||
|
|
||||||
const webhookManagement = useWebhookManagement({
|
const webhookManagement = useWebhookManagement({
|
||||||
blockId,
|
blockId,
|
||||||
triggerId: undefined,
|
triggerId: undefined,
|
||||||
isPreview,
|
isPreview,
|
||||||
|
useWebhookUrl,
|
||||||
})
|
})
|
||||||
|
|
||||||
const wandHook = useWand({
|
const wandHook = useWand({
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export { MCP } from './mcp/mcp'
|
|||||||
export { SSO } from './sso/sso'
|
export { SSO } from './sso/sso'
|
||||||
export { Subscription } from './subscription/subscription'
|
export { Subscription } from './subscription/subscription'
|
||||||
export { TeamManagement } from './team-management/team-management'
|
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 DialogPrimitive from '@radix-ui/react-dialog'
|
||||||
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
|
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
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 {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Connections,
|
Connections,
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
SSO,
|
SSO,
|
||||||
Subscription,
|
Subscription,
|
||||||
TeamManagement,
|
TeamManagement,
|
||||||
|
WorkflowMcpServers,
|
||||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components'
|
} 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 { TemplateProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile'
|
||||||
import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general-settings'
|
import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general-settings'
|
||||||
@@ -69,6 +70,7 @@ type SettingsSection =
|
|||||||
| 'copilot'
|
| 'copilot'
|
||||||
| 'mcp'
|
| 'mcp'
|
||||||
| 'custom-tools'
|
| 'custom-tools'
|
||||||
|
| 'workflow-mcp-servers'
|
||||||
|
|
||||||
type NavigationSection = 'account' | 'subscription' | 'tools' | 'system'
|
type NavigationSection = 'account' | 'subscription' | 'tools' | 'system'
|
||||||
|
|
||||||
@@ -112,6 +114,7 @@ const allNavigationItems: NavigationItem[] = [
|
|||||||
{ id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' },
|
{ id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' },
|
||||||
{ id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' },
|
{ id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' },
|
||||||
{ id: 'mcp', label: 'MCPs', icon: McpIcon, 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: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
|
||||||
{ id: 'apikeys', label: 'API Keys', icon: Key, 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 === 'copilot' && <Copilot />}
|
||||||
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
|
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
|
||||||
{activeSection === 'custom-tools' && <CustomTools />}
|
{activeSection === 'custom-tools' && <CustomTools />}
|
||||||
|
{activeSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
|
||||||
</SModalMainBody>
|
</SModalMainBody>
|
||||||
</SModalMain>
|
</SModalMain>
|
||||||
</SModalContent>
|
</SModalContent>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export type WorkflowExecutionPayload = {
|
|||||||
workflowId: string
|
workflowId: string
|
||||||
userId: string
|
userId: string
|
||||||
input?: any
|
input?: any
|
||||||
triggerType?: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
|
triggerType?: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
|
||||||
metadata?: Record<string, any>
|
metadata?: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const notificationKeys = {
|
|||||||
|
|
||||||
type NotificationType = 'webhook' | 'email' | 'slack'
|
type NotificationType = 'webhook' | 'email' | 'slack'
|
||||||
type LogLevel = 'info' | 'error'
|
type LogLevel = 'info' | 'error'
|
||||||
type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
|
type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
|
||||||
|
|
||||||
type AlertRuleType =
|
type AlertRuleType =
|
||||||
| 'consecutive_failures'
|
| '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
|
blockId: string
|
||||||
triggerId?: string
|
triggerId?: string
|
||||||
isPreview?: boolean
|
isPreview?: boolean
|
||||||
|
useWebhookUrl?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WebhookManagementState {
|
interface WebhookManagementState {
|
||||||
@@ -90,6 +91,7 @@ export function useWebhookManagement({
|
|||||||
blockId,
|
blockId,
|
||||||
triggerId,
|
triggerId,
|
||||||
isPreview = false,
|
isPreview = false,
|
||||||
|
useWebhookUrl = false,
|
||||||
}: UseWebhookManagementProps): WebhookManagementState {
|
}: UseWebhookManagementProps): WebhookManagementState {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const workflowId = params.workflowId as string
|
const workflowId = params.workflowId as string
|
||||||
@@ -134,7 +136,6 @@ export function useWebhookManagement({
|
|||||||
const currentlyLoading = store.loadingWebhooks.has(blockId)
|
const currentlyLoading = store.loadingWebhooks.has(blockId)
|
||||||
const alreadyChecked = store.checkedWebhooks.has(blockId)
|
const alreadyChecked = store.checkedWebhooks.has(blockId)
|
||||||
const currentWebhookId = store.getValue(blockId, 'webhookId')
|
const currentWebhookId = store.getValue(blockId, 'webhookId')
|
||||||
|
|
||||||
if (currentlyLoading || (alreadyChecked && currentWebhookId)) {
|
if (currentlyLoading || (alreadyChecked && currentWebhookId)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -205,7 +206,9 @@ export function useWebhookManagement({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadWebhookOrGenerateUrl()
|
if (useWebhookUrl) {
|
||||||
|
loadWebhookOrGenerateUrl()
|
||||||
|
}
|
||||||
}, [isPreview, triggerId, workflowId, blockId])
|
}, [isPreview, triggerId, workflowId, blockId])
|
||||||
|
|
||||||
const createWebhook = async (
|
const createWebhook = async (
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { env } from '@/lib/core/config/env'
|
import { env } from '@/lib/core/config/env'
|
||||||
import type { TokenBucketConfig } from './storage'
|
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'
|
export type RateLimitCounterType = 'sync' | 'async' | 'api-endpoint'
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export interface PreprocessExecutionOptions {
|
|||||||
// Required fields
|
// Required fields
|
||||||
workflowId: string
|
workflowId: string
|
||||||
userId: string // The authenticated user ID
|
userId: string // The authenticated user ID
|
||||||
triggerType: 'manual' | 'api' | 'webhook' | 'schedule' | 'chat'
|
triggerType: 'manual' | 'api' | 'webhook' | 'schedule' | 'chat' | 'mcp'
|
||||||
executionId: string
|
executionId: string
|
||||||
requestId: string
|
requestId: string
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export function getTriggerOptions(): TriggerOption[] {
|
|||||||
{ value: 'schedule', label: 'Schedule', color: '#059669' },
|
{ value: 'schedule', label: 'Schedule', color: '#059669' },
|
||||||
{ value: 'chat', label: 'Chat', color: '#7c3aed' },
|
{ value: 'chat', label: 'Chat', color: '#7c3aed' },
|
||||||
{ value: 'webhook', label: 'Webhook', color: '#ea580c' },
|
{ value: 'webhook', label: 'Webhook', color: '#ea580c' },
|
||||||
|
{ value: 'mcp', label: 'MCP', color: '#dc2626' },
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const trigger of triggers) {
|
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')
|
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
|
* Generates mock data based on the output type definition
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export interface InputFormatField {
|
export interface InputFormatField {
|
||||||
name?: string
|
name?: string
|
||||||
type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files' | string
|
type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files' | string
|
||||||
|
description?: string
|
||||||
value?: unknown
|
value?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --port 7321",
|
"dev": "next dev --port 3000",
|
||||||
"dev:webpack": "next dev --webpack",
|
"dev:webpack": "next dev --webpack",
|
||||||
"dev:sockets": "bun run socket-server/index.ts",
|
"dev:sockets": "bun run socket-server/index.ts",
|
||||||
"dev:full": "concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"bun run dev\" \"bun run dev:sockets\"",
|
"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 []
|
if (!value) return []
|
||||||
return value
|
return value
|
||||||
.split(',')
|
.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[] => {
|
const parseStringArrayFromURL = (value: string | null): string[] => {
|
||||||
|
|||||||
@@ -166,7 +166,15 @@ export type TimeRange =
|
|||||||
| 'Past 30 days'
|
| 'Past 30 days'
|
||||||
| 'All time'
|
| 'All time'
|
||||||
export type LogLevel = 'error' | 'info' | 'running' | 'pending' | 'all'
|
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 {
|
export interface FilterState {
|
||||||
// Workspace context
|
// 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,
|
"when": 1765932898404,
|
||||||
"tag": "0123_windy_lockheed",
|
"tag": "0123_windy_lockheed",
|
||||||
"breakpoints": true
|
"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),
|
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