Compare commits

..

32 Commits

Author SHA1 Message Date
Waleed
1d6975db49 v0.5.33: loops, chat fixes, subflow resizing refactor, terminal updates 2025-12-17 15:45:39 -08:00
Waleed
c4a6d11cc0 fix(condition): used isolated vms for condition block RCE (#2432)
* fix(condition): used isolated vms for condition block RCE

* ack PR comment

* one more

* remove inputForm from sched, update loop condition to also use isolated vm

* hide servicenow
2025-12-17 15:29:25 -08:00
Waleed
837aabca5e v0.5.32: google sheets fix, schedule input format 2025-12-16 15:41:04 -08:00
Vikhyath Mondreti
f9cfca92bf v0.5.31: add zod as direct dep 2025-12-15 20:40:02 -08:00
Waleed
25afacb25e v0.5.30: vllm fixes, permissions fixes, isolated vms for code execution, tool fixes 2025-12-15 19:38:01 -08:00
Gaurav Chadha
fcf52ac4d5 fix(landing): prevent url encoding for spaces for footer links (#2376) 2025-12-15 10:59:12 -08:00
Shivam
842200bcf2 fix(docs): clarify working directory for drizzle migration (#2375) 2025-12-15 10:58:27 -08:00
Waleed
a0fb889644 v0.5.29: chat voice mode, opengraph for docs, option to disable auth 2025-12-13 19:50:06 -08:00
Waleed
f526c36fc0 v0.5.28: tool fixes, sqs, spotify, nextjs update, component playground 2025-12-12 21:05:57 -08:00
Waleed
e24f31cbce v0.5.27: sidebar updates, ssrf patches, gpt-5.2, stagehand fixes 2025-12-11 14:45:25 -08:00
Waleed
3fbd57caf1 v0.5.26: tool fixes, templates and knowledgebase fixes, deployment versions in logs 2025-12-11 00:52:13 -08:00
Vikhyath Mondreti
b5da61377c v0.5.25: minor ui improvements, copilot billing fix 2025-12-10 18:32:27 -08:00
Waleed
18b7032494 v0.5.24: agent tool and UX improvements, redis service overhaul (#2291)
* feat(folders): add the ability to create a folder within a folder in popover (#2287)

* fix(agent): filter out empty params to ensure LLM can set tool params at runtime (#2288)

* fix(mcp): added backfill effect to add missing descriptions for mcp tools (#2290)

* fix(redis): cleanup access pattern across callsites (#2289)

* fix(redis): cleanup access pattern across callsites

* swap redis command to be non blocking

* improvement(log-details): polling, trace spans (#2292)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2025-12-10 13:09:21 -08:00
Waleed
b7bbef8620 v0.5.23: kb, logs, general ui improvements, token bucket rate limits, docs, mcp, autolayout improvements (#2286)
* fix(mcp): prevent redundant MCP server discovery calls at runtime, use cached tool schema instead (#2273)

* fix(mcp): prevent redundant MCP server discovery calls at runtime, use cached tool schema instead

* added backfill, added loading state for tools in settings > mcp

* fix tool inp

* feat(rate-limiter): token bucket algorithm  (#2270)

* fix(ratelimit): make deployed chat rate limited

* improvement(rate-limiter): use token bucket algo

* update docs

* fix

* fix type

* fix db rate limiter

* address greptile comments

* feat(i18n): update translations (#2275)

Co-authored-by: icecrasher321 <icecrasher321@users.noreply.github.com>

* fix(tools): updated kalshi and polymarket tools to accurately reflect outputs (#2274)

* feat(i18n): update translations (#2276)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(autolayout): align by handle (#2277)

* fix(autolayout): align by handle

* use shared constants everywhere

* cleanup

* fix(copilot): fix custom tools (#2278)

* Fix title custom tool

* Checkpoitn (broken)

* Fix custom tool flash

* Edit workflow returns null fix

* Works

* Fix lint

* fix(ime): prevent form submission during IME composition steps (#2279)

* fix(ui): prevent form submission during IME composition steps

* chore(gitignore): add IntelliJ IDE files to .gitignore

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* feat(ui): logs, kb, emcn (#2207)

* feat(kb): emcn alignment; sidebar: popover primary; settings-modal: expand

* feat: EMCN breadcrumb; improvement(KB): UI

* fix: hydration error

* improvement(KB): UI

* feat: emcn modal sizing, KB tags; refactor: deleted old sidebar

* feat(logs): UI

* fix: add documents modal name

* feat: logs, emcn, cursorrules; refactor: logs

* feat: dashboard

* feat: notifications; improvement: logs details

* fixed random rectangle on canvas

* fixed the name of the file to align

* fix build

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(creds): glitch allowing multiple credentials in an integration (#2282)

* improvement: custom tools modal, logs-details (#2283)

* fix(docs): fix copy page button and header hook (#2284)

* improvement(chat): add the ability to download files from the deployed chat (#2280)

* added teams download and chat download file

* Removed comments

* removed comments

* component structure and download all

* removed comments

* cleanup code

* fix empty files case

* small fix

* fix(container): resize heuristic improvement (#2285)

* estimate block height for resize based on subblocks

* fix hydration error

* make more conservative

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: icecrasher321 <icecrasher321@users.noreply.github.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: mosa <mosaxiv@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
2025-12-10 00:57:58 -08:00
Waleed
52edbea659 v0.5.22: rss feed trigger, sftp tool, billing fixes, 413 surfacing, copilot improvements 2025-12-09 10:27:36 -08:00
Vikhyath Mondreti
d480057fd3 fix(migration): migration got removed by force push (#2253) 2025-12-08 14:08:12 -08:00
Waleed
c27c233da0 v0.5.21: google groups, virtualized code viewer, ui, autolayout, docs improvements 2025-12-08 13:10:50 -08:00
Waleed
ebef5f3a27 v0.5.20: google slides, ui fixes, subflow resizing improvements 2025-12-06 15:36:09 -08:00
Vikhyath Mondreti
12c4c2d44f v0.5.19: copilot fix 2025-12-05 15:27:31 -08:00
Vikhyath Mondreti
929a352edb fix(build): added trigger.dev sdk mock to tests (#2216) 2025-12-05 14:26:50 -08:00
Vikhyath Mondreti
6cd078b0fe v0.5.18: ui fixes, nextjs16, workspace notifications, admin APIs, loading improvements, new slack tools 2025-12-05 14:03:09 -08:00
Waleed
31874939ee v0.5.17: modals, billing fixes, bun update, zoom, dropbox, kalshi, polymarket, datadog, ahrefs, gitlab, shopify, ssh, wordpress integrations 2025-12-04 13:29:46 -08:00
Waleed
e157ce5fbc v0.5.16: MCP fixes, code refactors, jira fixes, new mistral models 2025-12-02 22:02:11 -08:00
Vikhyath Mondreti
774e5d585c v0.5.15: add tools, revert subblock prop change 2025-12-01 13:52:12 -08:00
Vikhyath Mondreti
54cc93743f v0.5.14: fix issue with teams, google selectors + cleanup code 2025-12-01 12:39:39 -08:00
Waleed
8c32ad4c0d v0.5.13: polling fixes, generic agent search tool, status page, smtp, sendgrid, linkedin, more tools (#2148)
* feat(tools): added smtp, sendgrid, mailgun, linkedin, fixed permissions in context menu (#2133)

* feat(tools): added twilio sendgrid integration

* feat(tools): added smtp, sendgrid, mailgun, fixed permissions in context menu

* added top level mocks for sporadically failing tests

* incr type safety

* fix(team-plans): track departed member usage so value not lost (#2118)

* fix(team-plans): track departed member usage so value not lost

* reset usage to 0 when they leave team

* prep merge with stagig

* regen migrations

* fix org invite + ws selection'

---------

Co-authored-by: Waleed <walif6@gmail.com>

* feat(i18n): update translations (#2134)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* feat(creators): add verification for creators (#2135)

* feat(tools): added apify block/tools  (#2136)

* feat(tools): added apify

* cleanup

* feat(i18n): update translations (#2137)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* feat(env): added more optional env var examples (#2138)

* feat(statuspage): added statuspage, updated list of tools in footer, renamed routes (#2139)

* feat(statuspage): added statuspage, updated list of tools in footer, renamed routes

* ack PR comments

* feat(tools): add generic search tool (#2140)

* feat(i18n): update translations (#2141)

* fix(sdks): bump sdk versions (#2142)

* fix(webhooks): count test webhooks towards usage limit (#2143)

* fix(bill): add requestId to webhook processing (#2144)

* improvement(subflow): remove all associated edges when moving a block into a subflow (#2145)

* improvement(subflow): remove all associated edges when moving a block into a subflow

* ack PR comments

* fix(polling): mark webhook failed on webhook trigger errors (#2146)

* fix(deps): declare core transient deps explicitly (#2147)

* fix(deps): declare core transient deps explicitly

* ack PR comments

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
2025-12-01 10:15:36 -08:00
Waleed
1d08796853 v0.5.12: memory optimizations, sentry, incidentio, posthog, zendesk, pylon, intercom, mailchimp, loading optimizations (#2132)
* fix(memory-util): fixed unbounded array of gmail/outlook pollers causing high memory util, added missing db indexes/removed unused ones, auto-disable schedules/webhooks after 10 consecutive failures (#2115)

* fix(memory-util): fixed unbounded array of gmail/outlook pollers causing high memory util, added missing db indexes/removed unused ones, auto-disable schedules/webhooks after 10 consecutive failures

* ack PR comments

* ack

* improvement(teams-plan): seats increase simplification + not triggering checkout session (#2117)

* improvement(teams-plan): seats increase simplification + not triggering checkout session

* cleanup via helper

* feat(tools): added sentry, incidentio, and posthog tools (#2116)

* feat(tools): added sentry, incidentio, and posthog tools

* update docs

* fixed docs to use native fumadocs for llms.txt and copy markdown, fixed tool issues

* cleanup

* enhance error extractor, fixed posthog tools

* docs enhancements, cleanup

* added more incident io ops, remove zustand/shallow in favor of zustand/react/shallow

* fix type errors

* remove unnecessary comments

* added vllm to docs

* feat(i18n): update translations (#2120)

* feat(i18n): update translations

* fix build

---------

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* improvement(workflow-execution): perf improvements to passing workflow state + decrypted env vars (#2119)

* improvement(execution): load workflow state once instead of 2-3 times

* decrypt only in get helper

* remove comments

* remove comments

* feat(models): host google gemini models (#2122)

* feat(models): host google gemini models

* remove unused primary key

* feat(i18n): update translations (#2123)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* feat(tools): added zendesk, pylon, intercom, & mailchimp (#2126)

* feat(tools): added zendesk, pylon, intercom, & mailchimp

* finish zendesk and pylon

* updated docs

* feat(i18n): update translations (#2129)

* feat(i18n): update translations

* fixed build

---------

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(permissions): add client-side permissions validation to prevent unauthorized actions, upgraded custom tool modal (#2130)

* fix(permissions): add client-side permissions validation to prevent unauthorized actions, upgraded custom tool modal

* fix failing test

* fix test

* cleanup

* fix(custom-tools): add composite index on custom tool names & workspace id (#2131)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
2025-11-28 16:08:06 -08:00
Waleed
ebcd243942 v0.5.11: stt, videogen, vllm, billing fixes, new models 2025-11-25 01:14:12 -08:00
Waleed
b7e814b721 v0.5.10: copilot upgrade, preprocessor, logs search, UI, code hygiene 2025-11-21 12:04:34 -08:00
Waleed
842ef27ed9 v0.5.9: add backwards compatibility for agent messages array 2025-11-20 11:19:42 -08:00
Vikhyath Mondreti
31c34b2ea3 v0.5.8: notifications, billing, ui changes, store loading state machine 2025-11-20 01:32:32 -08:00
Vikhyath Mondreti
8f0ef58056 v0.5.7: combobox selectors, usage indicator, workflow loading race condition, other improvements 2025-11-17 21:25:51 -08:00
49 changed files with 182 additions and 12781 deletions

View File

@@ -188,6 +188,7 @@ DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio"
Then run the migrations:
```bash
cd apps/sim # Required so drizzle picks correct .env file
bunx drizzle-kit migrate --config=./drizzle.config.ts
```

View File

@@ -109,7 +109,7 @@ export default function Footer({ fullWidth = false }: FooterProps) {
{FOOTER_BLOCKS.map((block) => (
<Link
key={block}
href={`https://docs.sim.ai/blocks/${block.toLowerCase().replace(' ', '-')}`}
href={`https://docs.sim.ai/blocks/${block.toLowerCase().replaceAll(' ', '-')}`}
target='_blank'
rel='noopener noreferrer'
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'

View File

@@ -1,129 +0,0 @@
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 }
)
}
}

View File

@@ -1,360 +0,0 @@
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,
})
}
}

View File

@@ -1,197 +0,0 @@
/**
* 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 })
}
}

View File

@@ -1,150 +0,0 @@
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
)
}
}
)

View File

@@ -1,157 +0,0 @@
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
)
}
}
)

View File

@@ -1,178 +0,0 @@
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
)
}
}
)

View File

@@ -1,226 +0,0 @@
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
)
}
}
)

View File

@@ -1,107 +0,0 @@
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
)
}
}
)

View File

@@ -1,121 +1,17 @@
import { db, workflow, workflowDeploymentVersion, workflowMcpTool } from '@sim/db'
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { and, desc, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import {
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 { deployWorkflow } from '@/lib/workflows/persistence/utils'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('WorkflowDeployAPI')
/**
* Check if a workflow has a valid start block by loading from database
*/
async function hasValidStartBlock(workflowId: string): Promise<boolean> {
try {
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
return hasValidStartBlockInState(normalizedData)
} catch (error) {
logger.warn('Error checking for start block:', error)
return false
}
}
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
/**
* Extract input format from workflow blocks and generate MCP tool parameter schema
*/
async function generateMcpToolSchema(workflowId: string): Promise<Record<string, unknown>> {
try {
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalizedData?.blocks) {
return { type: 'object', properties: {} }
}
const inputFormat = extractInputFormatFromBlocks(normalizedData.blocks)
if (!inputFormat || inputFormat.length === 0) {
return { type: 'object', properties: {} }
}
return generateToolInputSchema(inputFormat) as unknown as Record<string, unknown>
} catch (error) {
logger.warn('Error generating MCP tool schema:', error)
return { type: 'object', properties: {} }
}
}
/**
* Update all MCP tools that reference this workflow with the latest parameter schema.
* If the workflow no longer has a start block, remove all MCP tools.
*/
async function syncMcpToolsOnDeploy(workflowId: string, requestId: string): Promise<void> {
try {
// Get all MCP tools that use this workflow
const tools = await db
.select({ id: workflowMcpTool.id })
.from(workflowMcpTool)
.where(eq(workflowMcpTool.workflowId, workflowId))
if (tools.length === 0) {
logger.debug(`[${requestId}] No MCP tools to sync for workflow: ${workflowId}`)
return
}
// Check if workflow still has a valid start block
const hasStart = await hasValidStartBlock(workflowId)
if (!hasStart) {
// No start block - remove all MCP tools for this workflow
await db.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId))
logger.info(
`[${requestId}] Removed ${tools.length} MCP tool(s) - workflow no longer has a start block: ${workflowId}`
)
return
}
// Generate the latest parameter schema
const parameterSchema = await generateMcpToolSchema(workflowId)
// Update all tools with the new schema
await db
.update(workflowMcpTool)
.set({
parameterSchema,
updatedAt: new Date(),
})
.where(eq(workflowMcpTool.workflowId, workflowId))
logger.info(`[${requestId}] Synced ${tools.length} MCP tool(s) for workflow: ${workflowId}`)
} catch (error) {
logger.error(`[${requestId}] Error syncing MCP tools:`, error)
// Don't throw - this is a non-critical operation
}
}
/**
* Remove all MCP tools that reference this workflow when undeploying
*/
async function removeMcpToolsOnUndeploy(workflowId: string, requestId: string): Promise<void> {
try {
const result = await db
.delete(workflowMcpTool)
.where(eq(workflowMcpTool.workflowId, workflowId))
logger.info(`[${requestId}] Removed MCP tools for undeployed workflow: ${workflowId}`)
} catch (error) {
logger.error(`[${requestId}] Error removing MCP tools:`, error)
// Don't throw - this is a non-critical operation
}
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params
@@ -223,9 +119,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
// Sync MCP tools with the latest parameter schema
await syncMcpToolsOnDeploy(id, requestId)
const responseApiKeyInfo = workflowData!.workspaceId
? 'Workspace API keys'
: 'Personal API keys'
@@ -274,9 +167,6 @@ export async function DELETE(
.where(eq(workflow.id, id))
})
// Remove all MCP tools that reference this workflow
await removeMcpToolsOnUndeploy(id, requestId)
logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`)
// Track workflow undeployment

View File

@@ -1,13 +1,8 @@
import { db, workflow, workflowDeploymentVersion, workflowMcpTool } from '@sim/db'
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import {
extractInputFormatFromBlocks,
generateToolInputSchema,
} from '@/lib/mcp/workflow-tool-schema'
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -16,80 +11,6 @@ const logger = createLogger('WorkflowActivateDeploymentAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
/**
* Extract input format from a deployment version state and generate MCP tool parameter schema
*/
function generateMcpToolSchemaFromState(state: any): Record<string, unknown> {
try {
if (!state?.blocks) {
return { type: 'object', properties: {} }
}
const inputFormat = extractInputFormatFromBlocks(state.blocks)
if (!inputFormat || inputFormat.length === 0) {
return { type: 'object', properties: {} }
}
return generateToolInputSchema(inputFormat) as unknown as Record<string, unknown>
} catch (error) {
logger.warn('Error generating MCP tool schema from state:', error)
return { type: 'object', properties: {} }
}
}
/**
* Sync MCP tools when activating a deployment version.
* If the version has no start block, remove all MCP tools.
*/
async function syncMcpToolsOnVersionActivate(
workflowId: string,
versionState: any,
requestId: string
): Promise<void> {
try {
// Get all MCP tools that use this workflow
const tools = await db
.select({ id: workflowMcpTool.id })
.from(workflowMcpTool)
.where(eq(workflowMcpTool.workflowId, workflowId))
if (tools.length === 0) {
logger.debug(`[${requestId}] No MCP tools to sync for workflow: ${workflowId}`)
return
}
// Check if the activated version has a valid start block
if (!hasValidStartBlockInState(versionState)) {
// No start block - remove all MCP tools for this workflow
await db.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId))
logger.info(
`[${requestId}] Removed ${tools.length} MCP tool(s) - activated version has no start block: ${workflowId}`
)
return
}
// Generate the parameter schema from the activated version's state
const parameterSchema = generateMcpToolSchemaFromState(versionState)
// Update all tools with the new schema
await db
.update(workflowMcpTool)
.set({
parameterSchema,
updatedAt: new Date(),
})
.where(eq(workflowMcpTool.workflowId, workflowId))
logger.info(
`[${requestId}] Synced ${tools.length} MCP tool(s) for workflow version activation: ${workflowId}`
)
} catch (error) {
logger.error(`[${requestId}] Error syncing MCP tools on version activate:`, error)
// Don't throw - this is a non-critical operation
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string; version: string }> }
@@ -110,18 +31,6 @@ export async function POST(
const now = new Date()
// Get the state of the version being activated for MCP tool sync
const [versionData] = await db
.select({ state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.version, versionNum)
)
)
.limit(1)
await db.transaction(async (tx) => {
await tx
.update(workflowDeploymentVersion)
@@ -156,11 +65,6 @@ export async function POST(
await tx.update(workflow).set(updateData).where(eq(workflow.id, id))
})
// Sync MCP tools with the activated version's parameter schema
if (versionData?.state) {
await syncMcpToolsOnVersionActivate(id, versionData.state, requestId)
}
return createSuccessResponse({ success: true, deployedAt: now })
} catch (error: any) {
logger.error(`[${requestId}] Error activating deployment for workflow: ${id}`, error)

View File

@@ -1,15 +1,10 @@
import { db, workflow, workflowDeploymentVersion, workflowMcpTool } from '@sim/db'
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { env } from '@/lib/core/config/env'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import {
extractInputFormatFromBlocks,
generateToolInputSchema,
} from '@/lib/mcp/workflow-tool-schema'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -18,80 +13,6 @@ const logger = createLogger('RevertToDeploymentVersionAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
/**
* Extract input format from a deployment version state and generate MCP tool parameter schema
*/
function generateMcpToolSchemaFromState(state: any): Record<string, unknown> {
try {
if (!state?.blocks) {
return { type: 'object', properties: {} }
}
const inputFormat = extractInputFormatFromBlocks(state.blocks)
if (!inputFormat || inputFormat.length === 0) {
return { type: 'object', properties: {} }
}
return generateToolInputSchema(inputFormat) as unknown as Record<string, unknown>
} catch (error) {
logger.warn('Error generating MCP tool schema from state:', error)
return { type: 'object', properties: {} }
}
}
/**
* Sync MCP tools when reverting to a deployment version.
* If the version has no start block, remove all MCP tools.
*/
async function syncMcpToolsOnRevert(
workflowId: string,
versionState: any,
requestId: string
): Promise<void> {
try {
// Get all MCP tools that use this workflow
const tools = await db
.select({ id: workflowMcpTool.id })
.from(workflowMcpTool)
.where(eq(workflowMcpTool.workflowId, workflowId))
if (tools.length === 0) {
logger.debug(`[${requestId}] No MCP tools to sync for workflow: ${workflowId}`)
return
}
// Check if the reverted version has a valid start block
if (!hasValidStartBlockInState(versionState)) {
// No start block - remove all MCP tools for this workflow
await db.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId))
logger.info(
`[${requestId}] Removed ${tools.length} MCP tool(s) - reverted version has no start block: ${workflowId}`
)
return
}
// Generate the parameter schema from the reverted version's state
const parameterSchema = generateMcpToolSchemaFromState(versionState)
// Update all tools with the new schema
await db
.update(workflowMcpTool)
.set({
parameterSchema,
updatedAt: new Date(),
})
.where(eq(workflowMcpTool.workflowId, workflowId))
logger.info(
`[${requestId}] Synced ${tools.length} MCP tool(s) for workflow revert: ${workflowId}`
)
} catch (error) {
logger.error(`[${requestId}] Error syncing MCP tools on revert:`, error)
// Don't throw - this is a non-critical operation
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string; version: string }> }
@@ -166,9 +87,6 @@ export async function POST(
.set({ lastSynced: new Date(), updatedAt: new Date() })
.where(eq(workflow.id, id))
// Sync MCP tools with the reverted version's parameter schema
await syncMcpToolsOnRevert(id, deployedState, requestId)
try {
const socketServerUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002'
await fetch(`${socketServerUrl}/api/workflow-reverted`, {

View File

@@ -30,7 +30,7 @@ const logger = createLogger('WorkflowExecuteAPI')
const ExecuteWorkflowSchema = z.object({
selectedOutputs: z.array(z.string()).optional().default([]),
triggerType: z.enum(['api', 'webhook', 'schedule', 'manual', 'chat', 'mcp']).optional(),
triggerType: z.enum(['api', 'webhook', 'schedule', 'manual', 'chat']).optional(),
stream: z.boolean().optional(),
useDraftState: z.boolean().optional(),
input: z.any().optional(),
@@ -227,7 +227,7 @@ type AsyncExecutionParams = {
workflowId: string
userId: string
input: any
triggerType: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
triggerType: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
}
/**
@@ -370,15 +370,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
})
const executionId = uuidv4()
type LoggingTriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
type LoggingTriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
let loggingTriggerType: LoggingTriggerType = 'manual'
if (
triggerType === 'api' ||
triggerType === 'chat' ||
triggerType === 'webhook' ||
triggerType === 'schedule' ||
triggerType === 'manual' ||
triggerType === 'mcp'
triggerType === 'manual'
) {
loggingTriggerType = triggerType as LoggingTriggerType
}

View File

@@ -43,7 +43,7 @@ const PRIMARY_BUTTON_STYLES =
type NotificationType = 'webhook' | 'email' | 'slack'
type LogLevel = 'info' | 'error'
type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
type AlertRule =
| 'none'
| 'consecutive_failures'
@@ -84,7 +84,7 @@ interface NotificationSettingsProps {
}
const LOG_LEVELS: LogLevel[] = ['info', 'error']
const TRIGGER_TYPES: TriggerType[] = ['api', 'webhook', 'schedule', 'manual', 'chat', 'mcp']
const TRIGGER_TYPES: TriggerType[] = ['api', 'webhook', 'schedule', 'manual', 'chat']
function formatAlertConfigLabel(config: {
rule: AlertRule
@@ -137,7 +137,7 @@ export function NotificationSettings({
workflowIds: [] as string[],
allWorkflows: true,
levelFilter: ['info', 'error'] as LogLevel[],
triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat', 'mcp'] as TriggerType[],
triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat'] as TriggerType[],
includeFinalOutput: false,
includeTraceSpans: false,
includeRateLimits: false,
@@ -207,7 +207,7 @@ export function NotificationSettings({
workflowIds: [],
allWorkflows: true,
levelFilter: ['info', 'error'],
triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat', 'mcp'],
triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat'],
includeFinalOutput: false,
includeTraceSpans: false,
includeRateLimits: false,

View File

@@ -21,7 +21,7 @@ import { useFolderStore } from '@/stores/folders/store'
import { useFilterStore } from '@/stores/logs/filters/store'
import { AutocompleteSearch } from './components/search'
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook', 'mcp'] as const
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook'] as const
const TIME_RANGE_OPTIONS: ComboboxOption[] = [
{ value: 'All time', label: 'All time' },

View File

@@ -4,7 +4,7 @@ import { Badge } from '@/components/emcn'
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
import { getBlock } from '@/blocks/registry'
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook', 'mcp'] as const
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook'] as const
const RUNNING_COLOR = '#22c55e' as const
const PENDING_COLOR = '#f59e0b' as const

View File

@@ -1,861 +0,0 @@
'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>
)
}

View File

@@ -24,7 +24,6 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { ApiDeploy } from './components/api/api'
import { ChatDeploy, type ExistingChat } from './components/chat/chat'
import { GeneralDeploy } from './components/general/general'
import { McpToolDeploy } from './components/mcp-tool/mcp-tool'
import { TemplateDeploy } from './components/template/template'
const logger = createLogger('DeployModal')
@@ -50,7 +49,7 @@ interface WorkflowDeploymentInfo {
needsRedeployment: boolean
}
type TabView = 'general' | 'api' | 'chat' | 'template' | 'mcp-tool'
type TabView = 'general' | 'api' | 'chat' | 'template'
export function DeployModal({
open,
@@ -553,7 +552,6 @@ export function DeployModal({
<ModalTabsTrigger value='api'>API</ModalTabsTrigger>
<ModalTabsTrigger value='chat'>Chat</ModalTabsTrigger>
<ModalTabsTrigger value='template'>Template</ModalTabsTrigger>
<ModalTabsTrigger value='mcp-tool'>MCP Tool</ModalTabsTrigger>
</ModalTabsList>
<ModalBody className='min-h-0 flex-1'>
@@ -612,17 +610,6 @@ export function DeployModal({
/>
)}
</ModalTabsContent>
<ModalTabsContent value='mcp-tool'>
{workflowId && (
<McpToolDeploy
workflowId={workflowId}
workflowName={workflowMetadata?.name || 'Workflow'}
workflowDescription={workflowMetadata?.description}
isDeployed={isDeployed}
/>
)}
</ModalTabsContent>
</ModalBody>
</ModalTabs>

View File

@@ -85,11 +85,11 @@ export function ShortInput({
const persistSubBlockValueRef = useRef<(value: string) => void>(() => {})
const justPastedRef = useRef(false)
const webhookManagement = useWebhookManagement({
blockId,
triggerId: undefined,
isPreview,
useWebhookUrl,
})
const wandHook = useWand({

View File

@@ -9,4 +9,3 @@ export { MCP } from './mcp/mcp'
export { SSO } from './sso/sso'
export { Subscription } from './subscription/subscription'
export { TeamManagement } from './team-management/team-management'
export { WorkflowMcpServers } from './workflow-mcp-servers/workflow-mcp-servers'

View File

@@ -1,591 +0,0 @@
'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>
</>
)
}

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
import { useQueryClient } from '@tanstack/react-query'
import { Files, LogIn, Server, Settings, User, Users, Wrench } from 'lucide-react'
import { Files, LogIn, Settings, User, Users, Wrench } from 'lucide-react'
import {
Card,
Connections,
@@ -40,7 +40,6 @@ import {
SSO,
Subscription,
TeamManagement,
WorkflowMcpServers,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components'
import { TemplateProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile'
import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general-settings'
@@ -70,7 +69,6 @@ type SettingsSection =
| 'copilot'
| 'mcp'
| 'custom-tools'
| 'workflow-mcp-servers'
type NavigationSection = 'account' | 'subscription' | 'tools' | 'system'
@@ -114,7 +112,6 @@ const allNavigationItems: NavigationItem[] = [
{ id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' },
{ id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' },
{ id: 'mcp', label: 'MCPs', icon: McpIcon, section: 'tools' },
{ id: 'workflow-mcp-servers', label: 'Workflow MCP Servers', icon: Server, section: 'tools' },
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
{
@@ -462,7 +459,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
{activeSection === 'copilot' && <Copilot />}
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
{activeSection === 'custom-tools' && <CustomTools />}
{activeSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
</SModalMainBody>
</SModalMain>
</SModalContent>

View File

@@ -14,7 +14,7 @@ export type WorkflowExecutionPayload = {
workflowId: string
userId: string
input?: any
triggerType?: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
triggerType?: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
metadata?: Record<string, any>
}

View File

@@ -155,15 +155,6 @@ export const ScheduleBlock: BlockConfig = {
condition: { field: 'scheduleType', value: ['minutes', 'hourly'], not: true },
},
{
id: 'inputFormat',
title: 'Input Format',
type: 'input-format',
description:
'Define input parameters that will be available when the schedule triggers. Use Value to set default values for scheduled executions.',
mode: 'trigger',
},
{
id: 'scheduleSave',
type: 'schedule-save',

View File

@@ -8,6 +8,7 @@ export const ServiceNowBlock: BlockConfig<ServiceNowResponse> = {
name: 'ServiceNow',
description: 'Create, read, update, delete, and bulk import ServiceNow records',
authMode: AuthMode.OAuth,
hideFromToolbar: true,
longDescription:
'Integrate ServiceNow into your workflow. Can create, read, update, and delete records in any ServiceNow table (incidents, tasks, users, etc.). Supports bulk import operations for data migration and ETL.',
docsLink: 'https://docs.sim.ai/tools/servicenow',

View File

@@ -1,11 +1,47 @@
import '@/executor/__test-utils__/mock-dependencies'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockType } from '@/executor/constants'
import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler'
import type { BlockState, ExecutionContext } from '@/executor/types'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
vi.mock('@/lib/logs/console/logger', () => ({
createLogger: vi.fn(() => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
})),
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn(() => 'test-request-id'),
}))
vi.mock('@/lib/execution/isolated-vm', () => ({
executeInIsolatedVM: vi.fn(),
}))
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
const mockExecuteInIsolatedVM = executeInIsolatedVM as ReturnType<typeof vi.fn>
function simulateIsolatedVMExecution(
code: string,
contextVariables: Record<string, unknown>
): { result: unknown; stdout: string; error?: { message: string; name: string } } {
try {
const fn = new Function(...Object.keys(contextVariables), code)
const result = fn(...Object.values(contextVariables))
return { result, stdout: '' }
} catch (error: any) {
return {
result: null,
stdout: '',
error: { message: error.message, name: error.name || 'Error' },
}
}
}
describe('ConditionBlockHandler', () => {
let handler: ConditionBlockHandler
let mockBlock: SerializedBlock
@@ -18,7 +54,6 @@ describe('ConditionBlockHandler', () => {
let mockPathTracker: any
beforeEach(() => {
// Define blocks first
mockSourceBlock = {
id: 'source-block-1',
metadata: { id: 'source', name: 'Source Block' },
@@ -33,7 +68,7 @@ describe('ConditionBlockHandler', () => {
metadata: { id: BlockType.CONDITION, name: 'Test Condition' },
position: { x: 50, y: 50 },
config: { tool: BlockType.CONDITION, params: {} },
inputs: { conditions: 'json' }, // Corrected based on previous step
inputs: { conditions: 'json' },
outputs: {},
enabled: true,
}
@@ -56,7 +91,6 @@ describe('ConditionBlockHandler', () => {
enabled: true,
}
// Then define workflow using the block objects
mockWorkflow = {
blocks: [mockSourceBlock, mockBlock, mockTargetBlock1, mockTargetBlock2],
connections: [
@@ -84,7 +118,6 @@ describe('ConditionBlockHandler', () => {
handler = new ConditionBlockHandler(mockPathTracker, mockResolver)
// Define mock context *after* workflow and blocks are set up
mockContext = {
workflowId: 'test-workflow-id',
blockStates: new Map<string, BlockState>([
@@ -99,7 +132,7 @@ describe('ConditionBlockHandler', () => {
]),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {}, // Now set the context's env vars
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set([mockSourceBlock.id]),
@@ -108,11 +141,11 @@ describe('ConditionBlockHandler', () => {
completedLoops: new Set(),
}
// Reset mocks using vi
vi.clearAllMocks()
// Default mock implementations - Removed as it's in the shared mock now
// mockResolver.resolveBlockReferences.mockImplementation((value) => value)
mockExecuteInIsolatedVM.mockImplementation(async ({ code, contextVariables }) => {
return simulateIsolatedVMExecution(code, contextVariables)
})
})
it('should handle condition blocks', () => {
@@ -141,7 +174,6 @@ describe('ConditionBlockHandler', () => {
selectedOption: 'cond1',
}
// Mock the full resolution pipeline
mockResolver.resolveVariableReferences.mockReturnValue('context.value > 5')
mockResolver.resolveBlockReferences.mockReturnValue('context.value > 5')
mockResolver.resolveEnvVariables.mockReturnValue('context.value > 5')
@@ -182,7 +214,6 @@ describe('ConditionBlockHandler', () => {
selectedOption: 'else1',
}
// Mock the full resolution pipeline
mockResolver.resolveVariableReferences.mockReturnValue('context.value < 0')
mockResolver.resolveBlockReferences.mockReturnValue('context.value < 0')
mockResolver.resolveEnvVariables.mockReturnValue('context.value < 0')
@@ -207,7 +238,7 @@ describe('ConditionBlockHandler', () => {
const inputs = { conditions: '{ "invalid json ' }
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
/^Invalid conditions format: Unterminated string.*/
/^Invalid conditions format:/
)
})
@@ -218,7 +249,6 @@ describe('ConditionBlockHandler', () => {
]
const inputs = { conditions: JSON.stringify(conditions) }
// Mock the full resolution pipeline
mockResolver.resolveVariableReferences.mockReturnValue('{{source-block-1.value}} > 5')
mockResolver.resolveBlockReferences.mockReturnValue('10 > 5')
mockResolver.resolveEnvVariables.mockReturnValue('10 > 5')
@@ -245,7 +275,6 @@ describe('ConditionBlockHandler', () => {
]
const inputs = { conditions: JSON.stringify(conditions) }
// Mock the full resolution pipeline for variable resolution
mockResolver.resolveVariableReferences.mockReturnValue('"john" !== null')
mockResolver.resolveBlockReferences.mockReturnValue('"john" !== null')
mockResolver.resolveEnvVariables.mockReturnValue('"john" !== null')
@@ -272,7 +301,6 @@ describe('ConditionBlockHandler', () => {
]
const inputs = { conditions: JSON.stringify(conditions) }
// Mock the full resolution pipeline for env variable resolution
mockResolver.resolveVariableReferences.mockReturnValue('{{POOP}} === "hi"')
mockResolver.resolveBlockReferences.mockReturnValue('{{POOP}} === "hi"')
mockResolver.resolveEnvVariables.mockReturnValue('"hi" === "hi"')
@@ -300,7 +328,6 @@ describe('ConditionBlockHandler', () => {
const inputs = { conditions: JSON.stringify(conditions) }
const resolutionError = new Error('Could not resolve reference: invalid-ref')
// Mock the pipeline to throw at the variable resolution stage
mockResolver.resolveVariableReferences.mockImplementation(() => {
throw resolutionError
})
@@ -317,7 +344,6 @@ describe('ConditionBlockHandler', () => {
]
const inputs = { conditions: JSON.stringify(conditions) }
// Mock the full resolution pipeline
mockResolver.resolveVariableReferences.mockReturnValue(
'context.nonExistentProperty.doSomething()'
)
@@ -325,7 +351,7 @@ describe('ConditionBlockHandler', () => {
mockResolver.resolveEnvVariables.mockReturnValue('context.nonExistentProperty.doSomething()')
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
/^Evaluation error in condition "if": Evaluation error in condition: Cannot read properties of undefined \(reading 'doSomething'\)\. \(Resolved: context\.nonExistentProperty\.doSomething\(\)\)$/
/Evaluation error in condition "if".*doSomething/
)
})
@@ -333,7 +359,6 @@ describe('ConditionBlockHandler', () => {
const conditions = [{ id: 'cond1', title: 'if', value: 'true' }]
const inputs = { conditions: JSON.stringify(conditions) }
// Create a new context with empty blockStates instead of trying to delete from readonly map
const contextWithoutSource = {
...mockContext,
blockStates: new Map<string, BlockState>(),
@@ -355,7 +380,6 @@ describe('ConditionBlockHandler', () => {
mockContext.workflow!.blocks = [mockSourceBlock, mockBlock, mockTargetBlock2]
// Mock the full resolution pipeline
mockResolver.resolveVariableReferences.mockReturnValue('true')
mockResolver.resolveBlockReferences.mockReturnValue('true')
mockResolver.resolveEnvVariables.mockReturnValue('true')
@@ -381,7 +405,6 @@ describe('ConditionBlockHandler', () => {
},
]
// Mock the full resolution pipeline
mockResolver.resolveVariableReferences
.mockReturnValueOnce('false')
.mockReturnValueOnce('context.value === 99')
@@ -394,12 +417,10 @@ describe('ConditionBlockHandler', () => {
const result = await handler.execute(mockContext, mockBlock, inputs)
// Should return success with no path selected (branch ends gracefully)
expect((result as any).conditionResult).toBe(false)
expect((result as any).selectedPath).toBeNull()
expect((result as any).selectedConditionId).toBeNull()
expect((result as any).selectedOption).toBeNull()
// Decision should not be set when no condition matches
expect(mockContext.decisions.condition.has(mockBlock.id)).toBe(false)
})
@@ -410,7 +431,6 @@ describe('ConditionBlockHandler', () => {
]
const inputs = { conditions: JSON.stringify(conditions) }
// Mock the full resolution pipeline
mockResolver.resolveVariableReferences.mockReturnValue('context.item === "apple"')
mockResolver.resolveBlockReferences.mockReturnValue('context.item === "apple"')
mockResolver.resolveEnvVariables.mockReturnValue('context.item === "apple"')

View File

@@ -1,3 +1,5 @@
import { generateRequestId } from '@/lib/core/utils/request'
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
import { createLogger } from '@/lib/logs/console/logger'
import type { BlockOutput } from '@/blocks/types'
import { BlockType, CONDITION, DEFAULTS, EDGE } from '@/executor/constants'
@@ -6,6 +8,8 @@ import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('ConditionBlockHandler')
const CONDITION_TIMEOUT_MS = 5000
/**
* Evaluates a single condition expression with variable/block reference resolution
* Returns true if condition is met, false otherwise
@@ -35,11 +39,32 @@ export async function evaluateConditionExpression(
}
try {
const conditionMet = new Function(
'context',
`with(context) { return ${resolvedConditionValue} }`
)(evalContext)
return Boolean(conditionMet)
const requestId = generateRequestId()
const code = `return Boolean(${resolvedConditionValue})`
const result = await executeInIsolatedVM({
code,
params: {},
envVars: {},
contextVariables: { context: evalContext },
timeoutMs: CONDITION_TIMEOUT_MS,
requestId,
})
if (result.error) {
logger.error(`Failed to evaluate condition: ${result.error.message}`, {
originalCondition: conditionExpression,
resolvedCondition: resolvedConditionValue,
evalContext,
error: result.error,
})
throw new Error(
`Evaluation error in condition: ${result.error.message}. (Resolved: ${resolvedConditionValue})`
)
}
return Boolean(result.result)
} catch (evalError: any) {
logger.error(`Failed to evaluate condition: ${evalError.message}`, {
originalCondition: conditionExpression,
@@ -87,7 +112,6 @@ export class ConditionBlockHandler implements BlockHandler {
block
)
// Handle case where no condition matched and no else exists - branch ends gracefully
if (!selectedConnection || !selectedCondition) {
return {
...((sourceOutput as any) || {}),
@@ -206,14 +230,12 @@ export class ConditionBlockHandler implements BlockHandler {
if (elseConnection) {
return { selectedConnection: elseConnection, selectedCondition: elseCondition }
}
// Else exists but has no connection - treat as no match, branch ends
logger.info(`No condition matched and else has no connection - branch ending`, {
blockId: block.id,
})
return { selectedConnection: null, selectedCondition: null }
}
// No condition matched and no else exists - branch ends gracefully
logger.info(`No condition matched and no else block - branch ending`, { blockId: block.id })
return { selectedConnection: null, selectedCondition: null }
}

View File

@@ -1,3 +1,5 @@
import { generateRequestId } from '@/lib/core/utils/request'
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
import { createLogger } from '@/lib/logs/console/logger'
import { buildLoopIndexCondition, DEFAULTS, EDGE } from '@/executor/constants'
import type { DAG } from '@/executor/dag/builder'
@@ -17,6 +19,8 @@ import type { SerializedLoop } from '@/serializer/types'
const logger = createLogger('LoopOrchestrator')
const LOOP_CONDITION_TIMEOUT_MS = 5000
export type LoopRoute = typeof EDGE.LOOP_CONTINUE | typeof EDGE.LOOP_EXIT
export interface LoopContinuationResult {
@@ -112,7 +116,10 @@ export class LoopOrchestrator {
scope.currentIterationOutputs.set(baseId, output)
}
evaluateLoopContinuation(ctx: ExecutionContext, loopId: string): LoopContinuationResult {
async evaluateLoopContinuation(
ctx: ExecutionContext,
loopId: string
): Promise<LoopContinuationResult> {
const scope = ctx.loopExecutions?.get(loopId)
if (!scope) {
logger.error('Loop scope not found during continuation evaluation', { loopId })
@@ -123,7 +130,6 @@ export class LoopOrchestrator {
}
}
// Check for cancellation
if (ctx.isCancelled) {
logger.info('Loop execution cancelled', { loopId, iteration: scope.iteration })
return this.createExitResult(ctx, loopId, scope)
@@ -140,7 +146,7 @@ export class LoopOrchestrator {
scope.currentIterationOutputs.clear()
if (!this.evaluateCondition(ctx, scope, scope.iteration + 1)) {
if (!(await this.evaluateCondition(ctx, scope, scope.iteration + 1))) {
return this.createExitResult(ctx, loopId, scope)
}
@@ -173,7 +179,11 @@ export class LoopOrchestrator {
}
}
private evaluateCondition(ctx: ExecutionContext, scope: LoopScope, iteration?: number): boolean {
private async evaluateCondition(
ctx: ExecutionContext,
scope: LoopScope,
iteration?: number
): Promise<boolean> {
if (!scope.condition) {
logger.warn('No condition defined for loop')
return false
@@ -184,7 +194,7 @@ export class LoopOrchestrator {
scope.iteration = iteration
}
const result = this.evaluateWhileCondition(ctx, scope.condition, scope)
const result = await this.evaluateWhileCondition(ctx, scope.condition, scope)
if (iteration !== undefined) {
scope.iteration = currentIteration
@@ -223,7 +233,6 @@ export class LoopOrchestrator {
const loopNodes = loopConfig.nodes
const allLoopNodeIds = new Set([sentinelStartId, sentinelEndId, ...loopNodes])
// Clear deactivated edges for loop nodes so error/success edges can be re-evaluated
if (this.edgeManager) {
this.edgeManager.clearDeactivatedEdgesForNodes(allLoopNodeIds)
}
@@ -263,7 +272,7 @@ export class LoopOrchestrator {
*
* @returns true if the loop should execute, false if it should be skipped
*/
evaluateInitialCondition(ctx: ExecutionContext, loopId: string): boolean {
async evaluateInitialCondition(ctx: ExecutionContext, loopId: string): Promise<boolean> {
const scope = ctx.loopExecutions?.get(loopId)
if (!scope) {
logger.warn('Loop scope not found for initial condition evaluation', { loopId })
@@ -300,7 +309,7 @@ export class LoopOrchestrator {
return false
}
const result = this.evaluateWhileCondition(ctx, scope.condition, scope)
const result = await this.evaluateWhileCondition(ctx, scope.condition, scope)
logger.info('While loop initial condition evaluation', {
loopId,
condition: scope.condition,
@@ -327,11 +336,11 @@ export class LoopOrchestrator {
return undefined
}
private evaluateWhileCondition(
private async evaluateWhileCondition(
ctx: ExecutionContext,
condition: string,
scope: LoopScope
): boolean {
): Promise<boolean> {
if (!condition) {
return false
}
@@ -343,7 +352,6 @@ export class LoopOrchestrator {
workflowVariables: ctx.workflowVariables,
})
// Use generic utility for smart variable reference replacement
const evaluatedCondition = replaceValidReferences(condition, (match) => {
const resolved = this.resolver.resolveSingleReference(ctx, '', match, scope)
logger.info('Resolved variable reference in loop condition', {
@@ -352,11 +360,9 @@ export class LoopOrchestrator {
resolvedType: typeof resolved,
})
if (resolved !== undefined) {
// For booleans and numbers, return as-is (no quotes)
if (typeof resolved === 'boolean' || typeof resolved === 'number') {
return String(resolved)
}
// For strings that represent booleans, return without quotes
if (typeof resolved === 'string') {
const lower = resolved.toLowerCase().trim()
if (lower === 'true' || lower === 'false') {
@@ -364,13 +370,33 @@ export class LoopOrchestrator {
}
return `"${resolved}"`
}
// For other types, stringify them
return JSON.stringify(resolved)
}
return match
})
const result = Boolean(new Function(`return (${evaluatedCondition})`)())
const requestId = generateRequestId()
const code = `return Boolean(${evaluatedCondition})`
const vmResult = await executeInIsolatedVM({
code,
params: {},
envVars: {},
contextVariables: {},
timeoutMs: LOOP_CONDITION_TIMEOUT_MS,
requestId,
})
if (vmResult.error) {
logger.error('Failed to evaluate loop condition', {
condition,
evaluatedCondition,
error: vmResult.error,
})
return false
}
const result = Boolean(vmResult.result)
logger.info('Loop condition evaluation result', {
originalCondition: condition,

View File

@@ -68,7 +68,7 @@ export class NodeExecutionOrchestrator {
}
if (node.metadata.isSentinel) {
const output = this.handleSentinel(ctx, node)
const output = await this.handleSentinel(ctx, node)
const isFinalOutput = node.outgoingEdges.size === 0
return {
nodeId,
@@ -86,14 +86,17 @@ export class NodeExecutionOrchestrator {
}
}
private handleSentinel(ctx: ExecutionContext, node: DAGNode): NormalizedBlockOutput {
private async handleSentinel(
ctx: ExecutionContext,
node: DAGNode
): Promise<NormalizedBlockOutput> {
const sentinelType = node.metadata.sentinelType
const loopId = node.metadata.loopId
switch (sentinelType) {
case 'start': {
if (loopId) {
const shouldExecute = this.loopOrchestrator.evaluateInitialCondition(ctx, loopId)
const shouldExecute = await this.loopOrchestrator.evaluateInitialCondition(ctx, loopId)
if (!shouldExecute) {
logger.info('While loop initial condition false, skipping loop body', { loopId })
return {
@@ -112,7 +115,7 @@ export class NodeExecutionOrchestrator {
return { shouldExit: true, selectedRoute: EDGE.LOOP_EXIT }
}
const continuationResult = this.loopOrchestrator.evaluateLoopContinuation(ctx, loopId)
const continuationResult = await this.loopOrchestrator.evaluateLoopContinuation(ctx, loopId)
if (continuationResult.shouldContinue) {
return {

View File

@@ -18,7 +18,7 @@ export const notificationKeys = {
type NotificationType = 'webhook' | 'email' | 'slack'
type LogLevel = 'info' | 'error'
type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
type AlertRuleType =
| 'consecutive_failures'

View File

@@ -1,508 +0,0 @@
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),
})
},
})
}

View File

@@ -14,7 +14,6 @@ interface UseWebhookManagementProps {
blockId: string
triggerId?: string
isPreview?: boolean
useWebhookUrl?: boolean
}
interface WebhookManagementState {
@@ -91,7 +90,6 @@ export function useWebhookManagement({
blockId,
triggerId,
isPreview = false,
useWebhookUrl = false,
}: UseWebhookManagementProps): WebhookManagementState {
const params = useParams()
const workflowId = params.workflowId as string
@@ -136,6 +134,7 @@ export function useWebhookManagement({
const currentlyLoading = store.loadingWebhooks.has(blockId)
const alreadyChecked = store.checkedWebhooks.has(blockId)
const currentWebhookId = store.getValue(blockId, 'webhookId')
if (currentlyLoading || (alreadyChecked && currentWebhookId)) {
return
}
@@ -206,9 +205,7 @@ export function useWebhookManagement({
}
}
if (useWebhookUrl) {
loadWebhookOrGenerateUrl()
}
loadWebhookOrGenerateUrl()
}, [isPreview, triggerId, workflowId, blockId])
const createWebhook = async (

View File

@@ -1,14 +1,7 @@
import { env } from '@/lib/core/config/env'
import type { TokenBucketConfig } from './storage'
export type TriggerType =
| 'api'
| 'webhook'
| 'schedule'
| 'manual'
| 'chat'
| 'mcp'
| 'api-endpoint'
export type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'api-endpoint'
export type RateLimitCounterType = 'sync' | 'async' | 'api-endpoint'

View File

@@ -204,12 +204,17 @@ async function ensureWorker(): Promise<void> {
import('node:child_process').then(({ spawn }) => {
worker = spawn('node', [workerPath], {
stdio: ['ignore', 'pipe', 'inherit', 'ipc'],
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
serialization: 'json',
})
worker.on('message', handleWorkerMessage)
let stderrData = ''
worker.stderr?.on('data', (data: Buffer) => {
stderrData += data.toString()
})
const startTimeout = setTimeout(() => {
worker?.kill()
worker = null
@@ -232,20 +237,42 @@ async function ensureWorker(): Promise<void> {
}
worker.on('message', readyHandler)
worker.on('exit', () => {
worker.on('exit', (code) => {
if (workerIdleTimeout) {
clearTimeout(workerIdleTimeout)
workerIdleTimeout = null
}
const wasStartupFailure = !workerReady && workerReadyPromise
worker = null
workerReady = false
workerReadyPromise = null
let errorMessage = 'Worker process exited unexpectedly'
if (stderrData.includes('isolated_vm') || stderrData.includes('MODULE_NOT_FOUND')) {
errorMessage =
'Code execution requires the isolated-vm native module which failed to load. ' +
'This usually means the module needs to be rebuilt for your Node.js version. ' +
'Please run: cd node_modules/isolated-vm && npm rebuild'
logger.error('isolated-vm module failed to load', { stderr: stderrData })
} else if (stderrData) {
errorMessage = `Worker process failed: ${stderrData.slice(0, 500)}`
logger.error('Worker process failed', { stderr: stderrData })
}
if (wasStartupFailure) {
clearTimeout(startTimeout)
reject(new Error(errorMessage))
return
}
for (const [id, pending] of pendingExecutions) {
clearTimeout(pending.timeout)
pending.resolve({
result: null,
stdout: '',
error: { message: 'Worker process exited unexpectedly', name: 'WorkerError' },
error: { message: errorMessage, name: 'WorkerError' },
})
pendingExecutions.delete(id)
}

View File

@@ -108,7 +108,7 @@ export interface PreprocessExecutionOptions {
// Required fields
workflowId: string
userId: string // The authenticated user ID
triggerType: 'manual' | 'api' | 'webhook' | 'schedule' | 'chat' | 'mcp'
triggerType: 'manual' | 'api' | 'webhook' | 'schedule' | 'chat'
executionId: string
requestId: string

View File

@@ -36,7 +36,6 @@ export function getTriggerOptions(): TriggerOption[] {
{ value: 'schedule', label: 'Schedule', color: '#059669' },
{ value: 'chat', label: 'Chat', color: '#7c3aed' },
{ value: 'webhook', label: 'Webhook', color: '#ea580c' },
{ value: 'mcp', label: 'MCP', color: '#dc2626' },
]
for (const trigger of triggers) {

View File

@@ -1,115 +0,0 @@
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(),
}
}

View File

@@ -1,399 +0,0 @@
/**
* 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}`)
},
})
}

View File

@@ -1,247 +0,0 @@
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
}

View File

@@ -10,43 +10,6 @@ import { getTrigger } from '@/triggers'
const logger = createLogger('TriggerUtils')
/**
* Valid start block types that can trigger a workflow
*/
export const VALID_START_BLOCK_TYPES = [
'starter',
'start',
'start_trigger',
'api',
'api_trigger',
'input_trigger',
] as const
export type ValidStartBlockType = (typeof VALID_START_BLOCK_TYPES)[number]
/**
* Check if a block type is a valid start block type
*/
export function isValidStartBlockType(blockType: string): blockType is ValidStartBlockType {
return VALID_START_BLOCK_TYPES.includes(blockType as ValidStartBlockType)
}
/**
* Check if a workflow state has a valid start block
*/
export function hasValidStartBlockInState(state: any): boolean {
if (!state?.blocks) {
return false
}
const startBlock = Object.values(state.blocks).find((block: any) => {
const blockType = block?.type
return isValidStartBlockType(blockType)
})
return !!startBlock
}
/**
* Generates mock data based on the output type definition
*/

View File

@@ -1,7 +1,6 @@
export interface InputFormatField {
name?: string
type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files' | string
description?: string
value?: unknown
}

View File

@@ -8,7 +8,7 @@
"node": ">=20.0.0"
},
"scripts": {
"dev": "next dev --port 3000",
"dev": "next dev --port 7321",
"dev:webpack": "next dev --webpack",
"dev:sockets": "bun run socket-server/index.ts",
"dev:full": "concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"bun run dev\" \"bun run dev:sockets\"",

View File

@@ -60,9 +60,7 @@ const parseTriggerArrayFromURL = (value: string | null): TriggerType[] => {
if (!value) return []
return value
.split(',')
.filter((t): t is TriggerType =>
['chat', 'api', 'webhook', 'manual', 'schedule', 'mcp'].includes(t)
)
.filter((t): t is TriggerType => ['chat', 'api', 'webhook', 'manual', 'schedule'].includes(t))
}
const parseStringArrayFromURL = (value: string | null): string[] => {

View File

@@ -166,15 +166,7 @@ export type TimeRange =
| 'Past 30 days'
| 'All time'
export type LogLevel = 'error' | 'info' | 'running' | 'pending' | 'all'
export type TriggerType =
| 'chat'
| 'api'
| 'webhook'
| 'manual'
| 'schedule'
| 'mcp'
| 'all'
| string
export type TriggerType = 'chat' | 'api' | 'webhook' | 'manual' | 'schedule' | 'all' | string
export interface FilterState {
// Workspace context

View File

@@ -1,34 +0,0 @@
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");

File diff suppressed because it is too large Load Diff

View File

@@ -862,13 +862,6 @@
"when": 1765932898404,
"tag": "0123_windy_lockheed",
"breakpoints": true
},
{
"idx": 124,
"version": "7",
"when": 1766018207289,
"tag": "0124_amused_lyja",
"breakpoints": true
}
]
}

View File

@@ -1598,62 +1598,3 @@ export const ssoProvider = pgTable(
organizationIdIdx: index('sso_provider_organization_id_idx').on(table.organizationId),
})
)
/**
* Workflow MCP Servers - User-created MCP servers that expose workflows as tools.
* These servers can be published and accessed by external MCP clients via OAuth.
*/
export const workflowMcpServer = pgTable(
'workflow_mcp_server',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
createdBy: text('created_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
isPublished: boolean('is_published').notNull().default(false),
publishedAt: timestamp('published_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceIdIdx: index('workflow_mcp_server_workspace_id_idx').on(table.workspaceId),
createdByIdx: index('workflow_mcp_server_created_by_idx').on(table.createdBy),
isPublishedIdx: index('workflow_mcp_server_is_published_idx').on(table.isPublished),
})
)
/**
* Workflow MCP Tools - Workflows registered as tools within a Workflow MCP Server.
* Each tool maps to a deployed workflow's execute endpoint.
*/
export const workflowMcpTool = pgTable(
'workflow_mcp_tool',
{
id: text('id').primaryKey(),
serverId: text('server_id')
.notNull()
.references(() => workflowMcpServer.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
toolName: text('tool_name').notNull(),
toolDescription: text('tool_description'),
parameterSchema: json('parameter_schema').notNull().default('{}'),
isEnabled: boolean('is_enabled').notNull().default(true),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
serverIdIdx: index('workflow_mcp_tool_server_id_idx').on(table.serverId),
workflowIdIdx: index('workflow_mcp_tool_workflow_id_idx').on(table.workflowId),
serverWorkflowUnique: uniqueIndex('workflow_mcp_tool_server_workflow_unique').on(
table.serverId,
table.workflowId
),
})
)