feat(workflow-as-mcp): added ability to deploy workflows as mcp servers and mcp tools (#2415)

* added a workflow as mcp

* fixed the issue of UI rendering for deleted mcp servers

* fixing lint issues

* using mcn components

* fixing merge conflicts

* fix

* fix lint errors

* refactored code to use hasstartblock from the tirgger utils

* removing unecessary auth

* using official mcp sdk and added description fields

* using normalised input schema function

* ui fixes part 1

* remove migration before merge

* fix merge conflicts

* remove migration to prep merge

* re-add migration

* cleanup code to use mcp sdk types

* fix discovery calls

* add migration

* ui improvements

* fix lint

* fix types

* fix lint

* fix spacing

* remove migration to prep merge

* add migration back

* fix imports

* fix tool refresh ux

* fix test failures

* fix tests

* cleanup code

* styling improvements, ability to edit mcp server description, etc

* fixed ui in light mode api keys modal

* update docs

* deprecated unused input components, shifted to emcn

* updated playground, simplified components

* move images and videos

* updated more docs images

---------

Co-authored-by: priyanshu.solanki <priyanshu.solanki@saviynt.com>
Co-authored-by: Siddharth Ganesan <siddharthganesan@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: waleed <walif6@gmail.com>
This commit is contained in:
Priyanshu Solanki
2025-12-30 17:52:50 -07:00
committed by GitHub
parent 34bc115468
commit c77268c13d
100 changed files with 13379 additions and 1439 deletions

View File

@@ -0,0 +1,108 @@
---
title: Deploy Workflows as MCP
description: Expose your workflows as MCP tools for external AI assistants and applications
---
import { Video } from '@/components/ui/video'
import { Callout } from 'fumadocs-ui/components/callout'
Deploy your workflows as MCP tools to make them accessible to external AI assistants like Claude Desktop, Cursor, and other MCP-compatible clients. This turns your workflows into callable tools that can be invoked from anywhere.
## Creating and Managing MCP Servers
MCP servers group your workflow tools together. Create and manage them in workspace settings:
<div className="mx-auto w-full overflow-hidden rounded-lg">
<Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div>
1. Navigate to **Settings → MCP Servers**
2. Click **Create Server**
3. Enter a name and optional description
4. Copy the server URL for use in your MCP clients
5. View and manage all tools added to the server
## Adding a Workflow as a Tool
Once your workflow is deployed, you can expose it as an MCP tool:
<div className="mx-auto w-full overflow-hidden rounded-lg">
<Video src="mcp/mcp-deploy-tool.mp4" width={700} height={450} />
</div>
1. Open your deployed workflow
2. Click **Deploy** and go to the **MCP** tab
3. Configure the tool name and description
4. Add descriptions for each parameter (helps AI understand inputs)
5. Select which MCP servers to add it to
<Callout type="info">
The workflow must be deployed before it can be added as an MCP tool.
</Callout>
## Tool Configuration
### Tool Name
Use lowercase letters, numbers, and underscores. The name should be descriptive and follow MCP naming conventions (e.g., `search_documents`, `send_email`).
### Description
Write a clear description of what the tool does. This helps AI assistants understand when to use the tool.
### Parameters
Your workflow's input format fields become tool parameters. Add descriptions to each parameter to help AI assistants provide correct values.
## Connecting MCP Clients
Use the server URL from settings to connect external applications:
### Claude Desktop
Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`):
```json
{
"mcpServers": {
"my-sim-workflows": {
"command": "npx",
"args": ["-y", "mcp-remote", "YOUR_SERVER_URL"]
}
}
}
```
### Cursor
Add the server URL in Cursor's MCP settings using the same mcp-remote pattern.
<Callout type="warn">
Include your API key header (`X-API-Key`) for authenticated access when using mcp-remote or other HTTP-based MCP transports.
</Callout>
## Server Management
From the server detail view in **Settings → MCP Servers**, you can:
- **View tools**: See all workflows added to a server
- **Copy URL**: Get the server URL for MCP clients
- **Add workflows**: Add more deployed workflows as tools
- **Remove tools**: Remove workflows from the server
- **Delete server**: Remove the entire server and all its tools
## How It Works
When an MCP client calls your tool:
1. The request is received at your MCP server URL
2. Sim validates the request and maps parameters to workflow inputs
3. The deployed workflow executes with the provided inputs
4. Results are returned to the MCP client
Workflows execute using the same deployment version as API calls, ensuring consistent behavior.
## Permission Requirements
| Action | Required Permission |
|--------|-------------------|
| Create MCP servers | **Admin** |
| Add workflows to servers | **Write** or **Admin** |
| View MCP servers | **Read**, **Write**, or **Admin** |
| Delete MCP servers | **Admin** |

View File

@@ -1,8 +1,10 @@
---
title: MCP (Model Context Protocol)
title: Using MCP Tools
description: Connect external tools and services using the Model Context Protocol
---
import { Image } from '@/components/ui/image'
import { Video } from '@/components/ui/video'
import { Callout } from 'fumadocs-ui/components/callout'
The Model Context Protocol ([MCP](https://modelcontextprotocol.com/)) allows you to connect external tools and services using a standardized protocol, enabling you to integrate APIs and services directly into your workflows. With MCP, you can extend Sim's capabilities by adding custom integrations that work seamlessly with your agents and workflows.
@@ -20,14 +22,8 @@ MCP is an open standard that enables AI assistants to securely connect to extern
MCP servers provide collections of tools that your agents can use. Configure them in workspace settings:
<div className="flex justify-center">
<Image
src="/static/blocks/mcp-1.png"
alt="Configuring MCP Server in Settings"
width={700}
height={450}
className="my-6"
/>
<div className="mx-auto w-full overflow-hidden rounded-lg">
<Video src="mcp/settings-mcp-tools.mp4" width={700} height={450} />
</div>
1. Navigate to your workspace settings
@@ -40,6 +36,10 @@ MCP servers provide collections of tools that your agents can use. Configure the
You can also configure MCP servers directly from the toolbar in an Agent block for quick setup.
</Callout>
### Refresh Tools
Click **Refresh** on a server to fetch the latest tool schemas and automatically update any agent blocks using those tools with the new parameter definitions.
## Using MCP Tools in Agents
Once MCP servers are configured, their tools become available within your agent blocks:

View File

@@ -0,0 +1,5 @@
{
"title": "MCP",
"pages": ["index", "deploy-workflows"],
"defaultOpen": false
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 155 KiB

View File

@@ -3,6 +3,7 @@
import { useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { X } from 'lucide-react'
import { Textarea } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -13,7 +14,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { isHosted } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'

View File

@@ -1,7 +1,7 @@
import type { Metadata } from 'next'
import Image from 'next/image'
import Link from 'next/link'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn'
import { FAQ } from '@/lib/blog/faq'
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry'
import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildPostMetadata } from '@/lib/blog/seo'

View File

@@ -2,7 +2,7 @@
import Image from 'next/image'
import Link from 'next/link'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn'
interface Author {
id: string

View File

@@ -0,0 +1,88 @@
import { db } from '@sim/db'
import { permissions, workflowMcpServer, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
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'
const logger = createLogger('McpDiscoverAPI')
export const dynamic = 'force-dynamic'
/**
* Discover all MCP servers available to the authenticated user.
*/
export async function GET(request: NextRequest) {
try {
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.' },
{ status: 401 }
)
}
const userId = auth.userId
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: [] })
}
const servers = await db
.select({
id: workflowMcpServer.id,
name: workflowMcpServer.name,
description: workflowMcpServer.description,
workspaceId: workflowMcpServer.workspaceId,
workspaceName: workspace.name,
createdAt: workflowMcpServer.createdAt,
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(sql`${workflowMcpServer.workspaceId} IN ${workspaceIds}`)
.orderBy(workflowMcpServer.name)
const baseUrl = getBaseUrl()
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,
createdAt: server.createdAt,
url: `${baseUrl}/api/mcp/serve/${server.id}`,
}))
logger.info(`User ${userId} discovered ${formattedServers.length} MCP servers`)
return NextResponse.json({
success: true,
servers: formattedServers,
authentication: {
method: 'API Key',
header: 'X-API-Key',
},
})
} catch (error) {
logger.error('Error discovering MCP servers:', error)
return NextResponse.json(
{ success: false, error: 'Failed to discover MCP servers' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,306 @@
/**
* MCP Serve Endpoint - Implements MCP protocol for workflow servers using SDK types.
*/
import {
type CallToolResult,
ErrorCode,
type InitializeResult,
isJSONRPCNotification,
isJSONRPCRequest,
type JSONRPCError,
type JSONRPCMessage,
type JSONRPCResponse,
type ListToolsResult,
type RequestId,
} from '@modelcontextprotocol/sdk/types.js'
import { db } from '@sim/db'
import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getBaseUrl } from '@/lib/core/utils/urls'
const logger = createLogger('WorkflowMcpServeAPI')
export const dynamic = 'force-dynamic'
interface RouteParams {
serverId: string
}
function createResponse(id: RequestId, result: unknown): JSONRPCResponse {
return {
jsonrpc: '2.0',
id,
result: result as JSONRPCResponse['result'],
}
}
function createError(id: RequestId, code: ErrorCode | number, message: string): JSONRPCError {
return {
jsonrpc: '2.0',
id,
error: { code, message },
}
}
async function getServer(serverId: string) {
const [server] = await db
.select({
id: workflowMcpServer.id,
name: workflowMcpServer.name,
workspaceId: workflowMcpServer.workspaceId,
})
.from(workflowMcpServer)
.where(eq(workflowMcpServer.id, serverId))
.limit(1)
return server
}
export async function GET(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { serverId } = await params
try {
const server = await getServer(serverId)
if (!server) {
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
}
return NextResponse.json({
name: server.name,
version: '1.0.0',
protocolVersion: '2024-11-05',
capabilities: { tools: {} },
})
} catch (error) {
logger.error('Error getting MCP server info:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { serverId } = await params
try {
const server = await getServer(serverId)
if (!server) {
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
}
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const message = body as JSONRPCMessage
if (isJSONRPCNotification(message)) {
logger.info(`Received notification: ${message.method}`)
return new NextResponse(null, { status: 202 })
}
if (!isJSONRPCRequest(message)) {
return NextResponse.json(
createError(0, ErrorCode.InvalidRequest, 'Invalid JSON-RPC message'),
{
status: 400,
}
)
}
const { id, method, params: rpcParams } = message
const apiKey =
request.headers.get('X-API-Key') ||
request.headers.get('Authorization')?.replace('Bearer ', '')
switch (method) {
case 'initialize': {
const result: InitializeResult = {
protocolVersion: '2024-11-05',
capabilities: { tools: {} },
serverInfo: { name: server.name, version: '1.0.0' },
}
return NextResponse.json(createResponse(id, result))
}
case 'ping':
return NextResponse.json(createResponse(id, {}))
case 'tools/list':
return handleToolsList(id, serverId)
case 'tools/call':
return handleToolsCall(
id,
serverId,
rpcParams as { name: string; arguments?: Record<string, unknown> },
apiKey
)
default:
return NextResponse.json(
createError(id, ErrorCode.MethodNotFound, `Method not found: ${method}`),
{
status: 404,
}
)
}
} catch (error) {
logger.error('Error handling MCP request:', error)
return NextResponse.json(createError(0, ErrorCode.InternalError, 'Internal error'), {
status: 500,
})
}
}
async function handleToolsList(id: RequestId, serverId: string): Promise<NextResponse> {
try {
const tools = await db
.select({
toolName: workflowMcpTool.toolName,
toolDescription: workflowMcpTool.toolDescription,
parameterSchema: workflowMcpTool.parameterSchema,
})
.from(workflowMcpTool)
.where(eq(workflowMcpTool.serverId, serverId))
const result: ListToolsResult = {
tools: tools.map((tool) => {
const schema = tool.parameterSchema as {
type?: string
properties?: Record<string, unknown>
required?: string[]
} | null
return {
name: tool.toolName,
description: tool.toolDescription || `Execute workflow: ${tool.toolName}`,
inputSchema: {
type: 'object' as const,
properties: schema?.properties || {},
...(schema?.required && schema.required.length > 0 && { required: schema.required }),
},
}
}),
}
return NextResponse.json(createResponse(id, result))
} catch (error) {
logger.error('Error listing tools:', error)
return NextResponse.json(createError(id, ErrorCode.InternalError, 'Failed to list tools'), {
status: 500,
})
}
}
async function handleToolsCall(
id: RequestId,
serverId: string,
params: { name: string; arguments?: Record<string, unknown> } | undefined,
apiKey?: string | null
): Promise<NextResponse> {
try {
if (!params?.name) {
return NextResponse.json(createError(id, ErrorCode.InvalidParams, 'Tool name required'), {
status: 400,
})
}
const [tool] = await db
.select({
toolName: workflowMcpTool.toolName,
workflowId: workflowMcpTool.workflowId,
})
.from(workflowMcpTool)
.where(and(eq(workflowMcpTool.serverId, serverId), eq(workflowMcpTool.toolName, params.name)))
.limit(1)
if (!tool) {
return NextResponse.json(
createError(id, ErrorCode.InvalidParams, `Tool not found: ${params.name}`),
{
status: 404,
}
)
}
const [wf] = await db
.select({ isDeployed: workflow.isDeployed })
.from(workflow)
.where(eq(workflow.id, tool.workflowId))
.limit(1)
if (!wf?.isDeployed) {
return NextResponse.json(
createError(id, ErrorCode.InternalError, 'Workflow is not deployed'),
{
status: 400,
}
)
}
const executeUrl = `${getBaseUrl()}/api/workflows/${tool.workflowId}/execute`
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (apiKey) headers['X-API-Key'] = apiKey
logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`)
const response = await fetch(executeUrl, {
method: 'POST',
headers,
body: JSON.stringify({ input: params.arguments || {}, triggerType: 'mcp' }),
signal: AbortSignal.timeout(300000), // 5 minute timeout
})
const executeResult = await response.json()
if (!response.ok) {
return NextResponse.json(
createError(
id,
ErrorCode.InternalError,
executeResult.error || 'Workflow execution failed'
),
{ status: 500 }
)
}
const result: CallToolResult = {
content: [
{ type: 'text', text: JSON.stringify(executeResult.output || executeResult, null, 2) },
],
isError: !executeResult.success,
}
return NextResponse.json(createResponse(id, result))
} catch (error) {
logger.error('Error calling tool:', error)
return NextResponse.json(createError(id, ErrorCode.InternalError, 'Tool execution failed'), {
status: 500,
})
}
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { serverId } = await params
try {
const server = await getServer(serverId)
if (!server) {
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
}
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,31 +1,150 @@
import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
import { mcpServers, workflow, workflowBlocks } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { withMcpAuth } from '@/lib/mcp/middleware'
import { mcpService } from '@/lib/mcp/service'
import type { McpServerStatusConfig } from '@/lib/mcp/types'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
import type { McpServerStatusConfig, McpTool, McpToolSchema } from '@/lib/mcp/types'
import {
createMcpErrorResponse,
createMcpSuccessResponse,
MCP_TOOL_CORE_PARAMS,
} from '@/lib/mcp/utils'
const logger = createLogger('McpServerRefreshAPI')
export const dynamic = 'force-dynamic'
/** Schema stored in workflow blocks includes description from the tool. */
type StoredToolSchema = McpToolSchema & { description?: string }
interface StoredTool {
type: string
title: string
toolId: string
params: {
serverId: string
serverUrl?: string
toolName: string
serverName?: string
}
schema?: StoredToolSchema
[key: string]: unknown
}
interface SyncResult {
updatedCount: number
updatedWorkflowIds: string[]
}
/**
* POST - Refresh an MCP server connection (requires any workspace permission)
* Syncs tool schemas from discovered MCP tools to all workflow blocks using those tools.
* Returns the count and IDs of updated workflows.
*/
async function syncToolSchemasToWorkflows(
workspaceId: string,
serverId: string,
tools: McpTool[],
requestId: string
): Promise<SyncResult> {
const toolsByName = new Map(tools.map((t) => [t.name, t]))
const workspaceWorkflows = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.workspaceId, workspaceId))
const workflowIds = workspaceWorkflows.map((w) => w.id)
if (workflowIds.length === 0) return { updatedCount: 0, updatedWorkflowIds: [] }
const agentBlocks = await db
.select({
id: workflowBlocks.id,
workflowId: workflowBlocks.workflowId,
subBlocks: workflowBlocks.subBlocks,
})
.from(workflowBlocks)
.where(eq(workflowBlocks.type, 'agent'))
const updatedWorkflowIds = new Set<string>()
for (const block of agentBlocks) {
if (!workflowIds.includes(block.workflowId)) continue
const subBlocks = block.subBlocks as Record<string, unknown> | null
if (!subBlocks) continue
const toolsSubBlock = subBlocks.tools as { value?: StoredTool[] } | undefined
if (!toolsSubBlock?.value || !Array.isArray(toolsSubBlock.value)) continue
let hasUpdates = false
const updatedTools = toolsSubBlock.value.map((tool) => {
if (tool.type !== 'mcp' || tool.params?.serverId !== serverId) {
return tool
}
const freshTool = toolsByName.get(tool.params.toolName)
if (!freshTool) return tool
const newSchema: StoredToolSchema = {
...freshTool.inputSchema,
description: freshTool.description,
}
const schemasMatch = JSON.stringify(tool.schema) === JSON.stringify(newSchema)
if (!schemasMatch) {
hasUpdates = true
const validParamKeys = new Set(Object.keys(newSchema.properties || {}))
const cleanedParams: Record<string, unknown> = {}
for (const [key, value] of Object.entries(tool.params || {})) {
if (MCP_TOOL_CORE_PARAMS.has(key) || validParamKeys.has(key)) {
cleanedParams[key] = value
}
}
return { ...tool, schema: newSchema, params: cleanedParams }
}
return tool
})
if (hasUpdates) {
const updatedSubBlocks = {
...subBlocks,
tools: { ...toolsSubBlock, value: updatedTools },
}
await db
.update(workflowBlocks)
.set({ subBlocks: updatedSubBlocks, updatedAt: new Date() })
.where(eq(workflowBlocks.id, block.id))
updatedWorkflowIds.add(block.workflowId)
}
}
if (updatedWorkflowIds.size > 0) {
logger.info(
`[${requestId}] Synced tool schemas to ${updatedWorkflowIds.size} workflow(s) for server ${serverId}`
)
}
return {
updatedCount: updatedWorkflowIds.size,
updatedWorkflowIds: Array.from(updatedWorkflowIds),
}
}
export const POST = withMcpAuth<{ id: string }>('read')(
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
const { id: serverId } = await params
try {
logger.info(
`[${requestId}] Refreshing MCP server: ${serverId} in workspace: ${workspaceId}`,
{
userId,
}
)
logger.info(`[${requestId}] Refreshing MCP server: ${serverId}`)
const [server] = await db
.select()
@@ -50,6 +169,8 @@ export const POST = withMcpAuth<{ id: string }>('read')(
let connectionStatus: 'connected' | 'disconnected' | 'error' = 'error'
let toolCount = 0
let lastError: string | null = null
let syncResult: SyncResult = { updatedCount: 0, updatedWorkflowIds: [] }
let discoveredTools: McpTool[] = []
const currentStatusConfig: McpServerStatusConfig =
(server.statusConfig as McpServerStatusConfig | null) ?? {
@@ -58,11 +179,16 @@ export const POST = withMcpAuth<{ id: string }>('read')(
}
try {
const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId)
discoveredTools = await mcpService.discoverServerTools(userId, serverId, workspaceId)
connectionStatus = 'connected'
toolCount = tools.length
logger.info(
`[${requestId}] Successfully connected to server ${serverId}, discovered ${toolCount} tools`
toolCount = discoveredTools.length
logger.info(`[${requestId}] Discovered ${toolCount} tools from server ${serverId}`)
syncResult = await syncToolSchemasToWorkflows(
workspaceId,
serverId,
discoveredTools,
requestId
)
} catch (error) {
connectionStatus = 'error'
@@ -94,14 +220,7 @@ export const POST = withMcpAuth<{ id: string }>('read')(
.returning()
if (connectionStatus === 'connected') {
logger.info(
`[${requestId}] Successfully refreshed MCP server: ${serverId} (${toolCount} tools)`
)
await mcpService.clearCache(workspaceId)
} else {
logger.warn(
`[${requestId}] Refresh completed for MCP server ${serverId} but connection failed: ${lastError}`
)
}
return createMcpSuccessResponse({
@@ -109,6 +228,8 @@ export const POST = withMcpAuth<{ id: string }>('read')(
toolCount,
lastConnected: refreshedServer?.lastConnected?.toISOString() || null,
error: lastError,
workflowsUpdated: syncResult.updatedCount,
updatedWorkflowIds: syncResult.updatedWorkflowIds,
})
} catch (error) {
logger.error(`[${requestId}] Error refreshing MCP server:`, error)

View File

@@ -5,7 +5,6 @@ import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpService } from '@/lib/mcp/service'
import { validateMcpServerUrl } from '@/lib/mcp/url-validator'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
const logger = createLogger('McpServerAPI')
@@ -27,24 +26,6 @@ export const PATCH = withMcpAuth<{ id: string }>('write')(
updates: Object.keys(body).filter((k) => k !== 'workspaceId'),
})
// Validate URL if being updated
if (
body.url &&
(body.transport === 'http' ||
body.transport === 'sse' ||
body.transport === 'streamable-http')
) {
const urlValidation = validateMcpServerUrl(body.url)
if (!urlValidation.isValid) {
return createMcpErrorResponse(
new Error(`Invalid MCP server URL: ${urlValidation.error}`),
'Invalid server URL',
400
)
}
body.url = urlValidation.normalizedUrl
}
// Remove workspaceId from body to prevent it from being updated
const { workspaceId: _, ...updateData } = body

View File

@@ -5,8 +5,6 @@ import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpService } from '@/lib/mcp/service'
import type { McpTransport } from '@/lib/mcp/types'
import { validateMcpServerUrl } from '@/lib/mcp/url-validator'
import {
createMcpErrorResponse,
createMcpSuccessResponse,
@@ -17,13 +15,6 @@ const logger = createLogger('McpServersAPI')
export const dynamic = 'force-dynamic'
/**
* Check if transport type requires a URL
*/
function isUrlBasedTransport(transport: McpTransport): boolean {
return transport === 'streamable-http'
}
/**
* GET - List all registered MCP servers for the workspace
*/
@@ -81,18 +72,6 @@ export const POST = withMcpAuth('write')(
)
}
if (isUrlBasedTransport(body.transport) && body.url) {
const urlValidation = validateMcpServerUrl(body.url)
if (!urlValidation.isValid) {
return createMcpErrorResponse(
new Error(`Invalid MCP server URL: ${urlValidation.error}`),
'Invalid server URL',
400
)
}
body.url = urlValidation.normalizedUrl
}
const serverId = body.url ? generateMcpServerId(workspaceId, body.url) : crypto.randomUUID()
const [existingServer] = await db

View File

@@ -4,7 +4,6 @@ import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import { McpClient } from '@/lib/mcp/client'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import type { McpServerConfig, McpTransport } from '@/lib/mcp/types'
import { validateMcpServerUrl } from '@/lib/mcp/url-validator'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
import { REFERENCE } from '@/executor/constants'
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
@@ -89,24 +88,12 @@ export const POST = withMcpAuth('write')(
)
}
if (isUrlBasedTransport(body.transport)) {
if (!body.url) {
return createMcpErrorResponse(
new Error('URL is required for HTTP-based transports'),
'Missing required URL',
400
)
}
const urlValidation = validateMcpServerUrl(body.url)
if (!urlValidation.isValid) {
return createMcpErrorResponse(
new Error(`Invalid MCP server URL: ${urlValidation.error}`),
'Invalid server URL',
400
)
}
body.url = urlValidation.normalizedUrl
if (isUrlBasedTransport(body.transport) && !body.url) {
return createMcpErrorResponse(
new Error('URL is required for HTTP-based transports'),
'Missing required URL',
400
)
}
let resolvedUrl = body.url

View File

@@ -9,9 +9,6 @@ const logger = createLogger('McpToolDiscoveryAPI')
export const dynamic = 'force-dynamic'
/**
* GET - Discover all tools from user's MCP servers
*/
export const GET = withMcpAuth('read')(
async (request: NextRequest, { userId, workspaceId, requestId }) => {
try {
@@ -19,18 +16,11 @@ export const GET = withMcpAuth('read')(
const serverId = searchParams.get('serverId')
const forceRefresh = searchParams.get('refresh') === 'true'
logger.info(`[${requestId}] Discovering MCP tools for user ${userId}`, {
serverId,
workspaceId,
forceRefresh,
})
logger.info(`[${requestId}] Discovering MCP tools`, { serverId, workspaceId, forceRefresh })
let tools
if (serverId) {
tools = await mcpService.discoverServerTools(userId, serverId, workspaceId)
} else {
tools = await mcpService.discoverTools(userId, workspaceId, forceRefresh)
}
const tools = serverId
? await mcpService.discoverServerTools(userId, serverId, workspaceId)
: await mcpService.discoverTools(userId, workspaceId, forceRefresh)
const byServer: Record<string, number> = {}
for (const tool of tools) {
@@ -55,9 +45,6 @@ export const GET = withMcpAuth('read')(
}
)
/**
* POST - Refresh tool discovery for specific servers
*/
export const POST = withMcpAuth('read')(
async (request: NextRequest, { userId, workspaceId, requestId }) => {
try {
@@ -72,10 +59,7 @@ export const POST = withMcpAuth('read')(
)
}
logger.info(
`[${requestId}] Refreshing tool discovery for user ${userId}, servers:`,
serverIds
)
logger.info(`[${requestId}] Refreshing tools for ${serverIds.length} servers`)
const results = await Promise.allSettled(
serverIds.map(async (serverId: string) => {
@@ -99,7 +83,8 @@ export const POST = withMcpAuth('read')(
}
})
const responseData = {
logger.info(`[${requestId}] Refresh completed: ${successes.length}/${serverIds.length}`)
return createMcpSuccessResponse({
refreshed: successes,
failed: failures,
summary: {
@@ -107,12 +92,7 @@ export const POST = withMcpAuth('read')(
successful: successes.length,
failed: failures.length,
},
}
logger.info(
`[${requestId}] Tool discovery refresh completed: ${successes.length}/${serverIds.length} successful`
)
return createMcpSuccessResponse(responseData)
})
} catch (error) {
logger.error(`[${requestId}] Error refreshing tool discovery:`, error)
const { message, status } = categorizeError(error)

View File

@@ -4,39 +4,20 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { withMcpAuth } from '@/lib/mcp/middleware'
import type { McpToolSchema, StoredMcpTool } from '@/lib/mcp/types'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
const logger = createLogger('McpStoredToolsAPI')
export const dynamic = 'force-dynamic'
interface StoredMcpTool {
workflowId: string
workflowName: string
serverId: string
serverUrl?: string
toolName: string
schema?: Record<string, unknown>
}
/**
* GET - Get all stored MCP tools from workflows in the workspace
*
* Scans all workflows in the workspace and extracts MCP tools that have been
* added to agent blocks. Returns the stored state of each tool for comparison
* against current server state.
*/
export const GET = withMcpAuth('read')(
async (request: NextRequest, { userId, workspaceId, requestId }) => {
try {
logger.info(`[${requestId}] Fetching stored MCP tools for workspace ${workspaceId}`)
// Get all workflows in workspace
const workflows = await db
.select({
id: workflow.id,
name: workflow.name,
})
.select({ id: workflow.id, name: workflow.name })
.from(workflow)
.where(eq(workflow.workspaceId, workspaceId))
@@ -47,12 +28,8 @@ export const GET = withMcpAuth('read')(
return createMcpSuccessResponse({ tools: [] })
}
// Get all agent blocks from these workflows
const agentBlocks = await db
.select({
workflowId: workflowBlocks.workflowId,
subBlocks: workflowBlocks.subBlocks,
})
.select({ workflowId: workflowBlocks.workflowId, subBlocks: workflowBlocks.subBlocks })
.from(workflowBlocks)
.where(eq(workflowBlocks.type, 'agent'))
@@ -81,7 +58,7 @@ export const GET = withMcpAuth('read')(
serverId: params.serverId as string,
serverUrl: params.serverUrl as string | undefined,
toolName: params.toolName as string,
schema: tool.schema as Record<string, unknown> | undefined,
schema: tool.schema as McpToolSchema | undefined,
})
}
}

View File

@@ -0,0 +1,155 @@
import { db } from '@sim/db'
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
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,
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

@@ -0,0 +1,176 @@
import { db } from '@sim/db'
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
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 = sanitizeToolName(body.toolName)
}
if (body.toolDescription !== undefined) {
updateData.toolDescription = body.toolDescription?.trim() || null
}
if (body.parameterSchema !== undefined) {
updateData.parameterSchema = body.parameterSchema
}
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

@@ -0,0 +1,223 @@
import { db } from '@sim/db'
import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
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,
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
)
}
const toolName = sanitizeToolName(body.toolName?.trim() || 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 || {},
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

@@ -0,0 +1,132 @@
import { db } from '@sim/db'
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, inArray, sql } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
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,
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))
// Fetch all tools for these servers
const serverIds = servers.map((s) => s.id)
const tools =
serverIds.length > 0
? await db
.select({
serverId: workflowMcpTool.serverId,
toolName: workflowMcpTool.toolName,
})
.from(workflowMcpTool)
.where(inArray(workflowMcpTool.serverId, serverIds))
: []
// Group tool names by server
const toolNamesByServer: Record<string, string[]> = {}
for (const tool of tools) {
if (!toolNamesByServer[tool.serverId]) {
toolNamesByServer[tool.serverId] = []
}
toolNamesByServer[tool.serverId].push(tool.toolName)
}
// Attach tool names to servers
const serversWithToolNames = servers.map((server) => ({
...server,
toolNames: toolNamesByServer[server.id] || [],
}))
logger.info(
`[${requestId}] Listed ${servers.length} workflow MCP servers for workspace ${workspaceId}`
)
return createMcpSuccessResponse({ servers: serversWithToolNames })
} 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,
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

@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { deployWorkflow, loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import {
createSchedulesForDeploy,
@@ -160,6 +161,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
// Sync MCP tools with the latest parameter schema
await syncMcpToolsForWorkflow({ workflowId: id, requestId, context: 'deploy' })
const responseApiKeyInfo = workflowData!.workspaceId
? 'Workspace API keys'
: 'Personal API keys'
@@ -217,6 +221,9 @@ export async function DELETE(
.where(eq(workflow.id, id))
})
// Remove all MCP tools that reference this workflow
await removeMcpToolsForWorkflow(id, requestId)
logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`)
try {

View File

@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -31,6 +32,18 @@ export async function POST(
const now = new Date()
// Get the state of the version being activated for MCP tool sync
const [versionData] = await db
.select({ state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.version, versionNum)
)
)
.limit(1)
await db.transaction(async (tx) => {
await tx
.update(workflowDeploymentVersion)
@@ -65,6 +78,16 @@ 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 syncMcpToolsForWorkflow({
workflowId: id,
requestId,
state: versionData.state,
context: 'activate',
})
}
return createSuccessResponse({ success: true, deployedAt: now })
} catch (error: any) {
logger.error(`[${requestId}] Error activating deployment for workflow: ${id}`, error)

View File

@@ -4,6 +4,7 @@ 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 { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -87,6 +88,14 @@ 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 syncMcpToolsForWorkflow({
workflowId: id,
requestId,
state: deployedState,
context: 'revert',
})
try {
const socketServerUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002'
await fetch(`${socketServerUrl}/api/workflow-reverted`, {

View File

@@ -109,7 +109,7 @@ type AsyncExecutionParams = {
workflowId: string
userId: string
input: any
triggerType: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
triggerType: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
}
/**
@@ -252,14 +252,15 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
})
const executionId = uuidv4()
type LoggingTriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
type LoggingTriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
let loggingTriggerType: LoggingTriggerType = 'manual'
if (
triggerType === 'api' ||
triggerType === 'chat' ||
triggerType === 'webhook' ||
triggerType === 'schedule' ||
triggerType === 'manual'
triggerType === 'manual' ||
triggerType === 'mcp'
) {
loggingTriggerType = triggerType as LoggingTriggerType
}

View File

@@ -2,7 +2,7 @@
import React from 'react'
import ReactMarkdown from 'react-markdown'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import type { ChangelogEntry } from '@/app/changelog/components/changelog-content'

View File

@@ -1,14 +1,18 @@
'use client'
import { useState } from 'react'
import { ArrowLeft, Bell, Folder, Key, Settings, User } from 'lucide-react'
import { useEffect, useState } from 'react'
import { ArrowLeft, Bell, Folder, Key, Moon, Settings, Sun, User } from 'lucide-react'
import { notFound, useRouter } from 'next/navigation'
import {
Avatar,
AvatarFallback,
AvatarImage,
Badge,
Breadcrumb,
BubbleChatPreview,
Button,
Card as CardIcon,
Checkbox,
ChevronDown,
Code,
Combobox,
@@ -50,6 +54,7 @@ import {
PopoverTrigger,
Redo,
Rocket,
Slider,
SModal,
SModalContent,
SModalMain,
@@ -62,6 +67,12 @@ import {
SModalSidebarSectionTitle,
SModalTrigger,
Switch,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
Tooltip,
Trash,
@@ -112,7 +123,19 @@ export default function PlaygroundPage() {
const router = useRouter()
const [comboboxValue, setComboboxValue] = useState('')
const [switchValue, setSwitchValue] = useState(false)
const [checkboxValue, setCheckboxValue] = useState(false)
const [sliderValue, setSliderValue] = useState([50])
const [activeTab, setActiveTab] = useState('profile')
const [isDarkMode, setIsDarkMode] = useState(false)
const toggleDarkMode = () => {
setIsDarkMode(!isDarkMode)
document.documentElement.classList.toggle('dark')
}
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains('dark'))
}, [])
if (!isTruthy(env.NEXT_PUBLIC_ENABLE_PLAYGROUND)) {
notFound()
@@ -121,18 +144,26 @@ export default function PlaygroundPage() {
return (
<Tooltip.Provider>
<div className='relative min-h-screen bg-[var(--bg)] p-8'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => router.back()}
className='absolute top-8 left-8 h-8 w-8 p-0'
>
<ArrowLeft className='h-4 w-4' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Go back</Tooltip.Content>
</Tooltip.Root>
<div className='absolute top-8 left-8 flex items-center gap-2'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' onClick={() => router.back()} className='h-8 w-8 p-0'>
<ArrowLeft className='h-4 w-4' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Go back</Tooltip.Content>
</Tooltip.Root>
</div>
<div className='absolute top-8 right-8'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='default' onClick={toggleDarkMode} className='h-8 w-8 p-0'>
{isDarkMode ? <Sun className='h-4 w-4' /> : <Moon className='h-4 w-4' />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>{isDarkMode ? 'Light mode' : 'Dark mode'}</Tooltip.Content>
</Tooltip.Root>
</div>
<div className='mx-auto max-w-4xl space-y-12'>
<div>
<h1 className='font-semibold text-2xl text-[var(--text-primary)]'>
@@ -185,6 +216,50 @@ export default function PlaygroundPage() {
<VariantRow label='outline'>
<Badge variant='outline'>Outline</Badge>
</VariantRow>
<VariantRow label='green'>
<Badge variant='green'>Green</Badge>
<Badge variant='green' dot>
With Dot
</Badge>
</VariantRow>
<VariantRow label='red'>
<Badge variant='red'>Red</Badge>
<Badge variant='red' dot>
With Dot
</Badge>
</VariantRow>
<VariantRow label='blue'>
<Badge variant='blue'>Blue</Badge>
<Badge variant='blue' dot>
With Dot
</Badge>
</VariantRow>
<VariantRow label='blue-secondary'>
<Badge variant='blue-secondary'>Blue Secondary</Badge>
</VariantRow>
<VariantRow label='purple'>
<Badge variant='purple'>Purple</Badge>
</VariantRow>
<VariantRow label='orange'>
<Badge variant='orange'>Orange</Badge>
</VariantRow>
<VariantRow label='amber'>
<Badge variant='amber'>Amber</Badge>
</VariantRow>
<VariantRow label='teal'>
<Badge variant='teal'>Teal</Badge>
</VariantRow>
<VariantRow label='gray'>
<Badge variant='gray'>Gray</Badge>
</VariantRow>
<VariantRow label='gray-secondary'>
<Badge variant='gray-secondary'>Gray Secondary</Badge>
</VariantRow>
<VariantRow label='sizes'>
<Badge size='sm'>Small</Badge>
<Badge size='md'>Medium</Badge>
<Badge size='lg'>Large</Badge>
</VariantRow>
</Section>
{/* Input */}
@@ -220,6 +295,143 @@ export default function PlaygroundPage() {
</VariantRow>
</Section>
{/* Checkbox */}
<Section title='Checkbox'>
<VariantRow label='default'>
<Checkbox checked={checkboxValue} onCheckedChange={(c) => setCheckboxValue(!!c)} />
<span className='text-[var(--text-secondary)] text-sm'>
{checkboxValue ? 'Checked' : 'Unchecked'}
</span>
</VariantRow>
<VariantRow label='size sm'>
<Checkbox size='sm' />
<span className='text-[var(--text-secondary)] text-sm'>Small (14px)</span>
</VariantRow>
<VariantRow label='size md'>
<Checkbox size='md' />
<span className='text-[var(--text-secondary)] text-sm'>Medium (16px)</span>
</VariantRow>
<VariantRow label='size lg'>
<Checkbox size='lg' />
<span className='text-[var(--text-secondary)] text-sm'>Large (20px)</span>
</VariantRow>
<VariantRow label='disabled'>
<Checkbox disabled />
<Checkbox disabled checked />
</VariantRow>
</Section>
{/* Slider */}
<Section title='Slider'>
<VariantRow label='default'>
<div className='w-48'>
<Slider value={sliderValue} onValueChange={setSliderValue} max={100} step={1} />
</div>
<span className='text-[var(--text-secondary)] text-sm'>{sliderValue[0]}</span>
</VariantRow>
<VariantRow label='disabled'>
<div className='w-48'>
<Slider value={[30]} disabled max={100} step={1} />
</div>
</VariantRow>
</Section>
{/* Avatar */}
<Section title='Avatar'>
<VariantRow label='sizes'>
<Avatar size='xs'>
<AvatarFallback>XS</AvatarFallback>
</Avatar>
<Avatar size='sm'>
<AvatarFallback>SM</AvatarFallback>
</Avatar>
<Avatar size='md'>
<AvatarFallback>MD</AvatarFallback>
</Avatar>
<Avatar size='lg'>
<AvatarFallback>LG</AvatarFallback>
</Avatar>
<Avatar size='xl'>
<AvatarFallback>XL</AvatarFallback>
</Avatar>
</VariantRow>
<VariantRow label='with image'>
<Avatar size='md'>
<AvatarImage src='https://github.com/shadcn.png' alt='User' />
<AvatarFallback>CN</AvatarFallback>
</Avatar>
</VariantRow>
<VariantRow label='status online'>
<Avatar size='md' status='online'>
<AvatarFallback>JD</AvatarFallback>
</Avatar>
</VariantRow>
<VariantRow label='status offline'>
<Avatar size='md' status='offline'>
<AvatarFallback>JD</AvatarFallback>
</Avatar>
</VariantRow>
<VariantRow label='status busy'>
<Avatar size='md' status='busy'>
<AvatarFallback>JD</AvatarFallback>
</Avatar>
</VariantRow>
<VariantRow label='status away'>
<Avatar size='md' status='away'>
<AvatarFallback>JD</AvatarFallback>
</Avatar>
</VariantRow>
<VariantRow label='all sizes with status'>
<Avatar size='xs' status='online'>
<AvatarFallback>XS</AvatarFallback>
</Avatar>
<Avatar size='sm' status='online'>
<AvatarFallback>SM</AvatarFallback>
</Avatar>
<Avatar size='md' status='online'>
<AvatarFallback>MD</AvatarFallback>
</Avatar>
<Avatar size='lg' status='online'>
<AvatarFallback>LG</AvatarFallback>
</Avatar>
<Avatar size='xl' status='online'>
<AvatarFallback>XL</AvatarFallback>
</Avatar>
</VariantRow>
</Section>
{/* Table */}
<Section title='Table'>
<VariantRow label='default'>
<Table className='max-w-md'>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Role</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className='hover:bg-[var(--surface-2)]'>
<TableCell>Alice</TableCell>
<TableCell>Active</TableCell>
<TableCell>Admin</TableCell>
</TableRow>
<TableRow className='hover:bg-[var(--surface-2)]'>
<TableCell>Bob</TableCell>
<TableCell>Pending</TableCell>
<TableCell>User</TableCell>
</TableRow>
<TableRow className='hover:bg-[var(--surface-2)]'>
<TableCell>Charlie</TableCell>
<TableCell>Active</TableCell>
<TableCell>User</TableCell>
</TableRow>
</TableBody>
</Table>
</VariantRow>
</Section>
{/* Combobox */}
<Section title='Combobox'>
<VariantRow label='default'>

View File

@@ -2,7 +2,9 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Badge, Button, Input, Label, Textarea } from '@/components/emcn'
import { Badge, Button, Textarea } from '@/components/emcn'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,

View File

@@ -17,26 +17,24 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation'
import {
Breadcrumb,
Button,
Checkbox,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Tooltip,
Trash,
} from '@/components/emcn'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { SearchHighlight } from '@/components/ui/search-highlight'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
Tooltip,
Trash,
} from '@/components/emcn'
import { Input } from '@/components/ui/input'
import { SearchHighlight } from '@/components/ui/search-highlight'
import { Skeleton } from '@/components/ui/skeleton'
import {
CreateChunkModal,
DeleteChunkModal,
@@ -920,6 +918,7 @@ export function Document({
>
<div className='flex items-center'>
<Checkbox
size='sm'
checked={isAllSelected}
onCheckedChange={handleSelectAll}
disabled={
@@ -927,7 +926,6 @@ export function Document({
!userPermissions.canEdit
}
aria-label='Select all chunks'
className='h-[14px] w-[14px] border-[var(--border-2)] focus-visible:ring-[var(--brand-primary-hex)]/20 data-[state=checked]:border-[var(--brand-primary-hex)] data-[state=checked]:bg-[var(--brand-primary-hex)] [&>*]:h-[12px] [&>*]:w-[12px]'
/>
</div>
</TableHead>
@@ -999,13 +997,13 @@ export function Document({
>
<div className='flex items-center'>
<Checkbox
size='sm'
checked={selectedChunks.has(chunk.id)}
onCheckedChange={(checked) =>
handleSelectChunk(chunk.id, checked as boolean)
}
disabled={!userPermissions.canEdit}
aria-label={`Select chunk ${chunk.chunkIndex}`}
className='h-[14px] w-[14px] border-[var(--border-2)] focus-visible:ring-[var(--brand-primary-hex)]/20 data-[state=checked]:border-[var(--brand-primary-hex)] data-[state=checked]:bg-[var(--brand-primary-hex)] [&>*]:h-[12px] [&>*]:w-[12px]'
onClick={(e) => e.stopPropagation()}
/>
</div>

View File

@@ -21,26 +21,24 @@ import {
Badge,
Breadcrumb,
Button,
Checkbox,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Tooltip,
Trash,
} from '@/components/emcn'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { SearchHighlight } from '@/components/ui/search-highlight'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
Tooltip,
Trash,
} from '@/components/emcn'
import { Input } from '@/components/ui/input'
import { SearchHighlight } from '@/components/ui/search-highlight'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/core/utils/cn'
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
import {
@@ -591,6 +589,11 @@ export function KnowledgeBase({
const document = documents.find((doc) => doc.id === docId)
if (!document) return
const newEnabled = !document.enabled
// Optimistic update - immediately update the UI
updateDocument(docId, { enabled: newEnabled })
try {
const response = await fetch(`/api/knowledge/${id}/documents/${docId}`, {
method: 'PUT',
@@ -598,7 +601,7 @@ export function KnowledgeBase({
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: !document.enabled,
enabled: newEnabled,
}),
})
@@ -608,10 +611,13 @@ export function KnowledgeBase({
const result = await response.json()
if (result.success) {
updateDocument(docId, { enabled: !document.enabled })
if (!result.success) {
// Revert on failure
updateDocument(docId, { enabled: !newEnabled })
}
} catch (err) {
// Revert on error
updateDocument(docId, { enabled: !newEnabled })
logger.error('Error updating document:', err)
}
}
@@ -1125,11 +1131,11 @@ export function KnowledgeBase({
<TableHead className='w-[28px] py-[8px] pr-0 pl-0'>
<div className='flex items-center justify-center'>
<Checkbox
size='sm'
checked={isAllSelected}
onCheckedChange={handleSelectAll}
disabled={!userPermissions.canEdit}
aria-label='Select all documents'
className='h-[14px] w-[14px] border-[var(--border-2)] focus-visible:ring-[var(--brand-primary-hex)]/20 data-[state=checked]:border-[var(--brand-primary-hex)] data-[state=checked]:bg-[var(--brand-primary-hex)] [&>*]:h-[12px] [&>*]:w-[12px]'
/>
</div>
</TableHead>
@@ -1170,10 +1176,10 @@ export function KnowledgeBase({
onCheckedChange={(checked) =>
handleSelectDocument(doc.id, checked as boolean)
}
size='sm'
disabled={!userPermissions.canEdit}
onClick={(e) => e.stopPropagation()}
aria-label={`Select ${doc.filename}`}
className='h-[14px] w-[14px] border-[var(--border-2)] focus-visible:ring-[var(--brand-primary-hex)]/20 data-[state=checked]:border-[var(--brand-primary-hex)] data-[state=checked]:bg-[var(--brand-primary-hex)] [&>*]:h-[12px] [&>*]:w-[12px]'
/>
</div>
</TableCell>

View File

@@ -16,8 +16,7 @@ import {
X,
Zap,
} from 'lucide-react'
import { Modal, ModalBody, ModalContent, ModalHeader } from '@/components/emcn'
import { Badge } from '@/components/ui/badge'
import { Badge, Modal, ModalBody, ModalContent, ModalHeader } from '@/components/emcn'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { cn } from '@/lib/core/utils/cn'
@@ -200,7 +199,7 @@ function PinnedLogs({
</button>
</div>
<div className='flex items-center gap-[8px]'>
<Badge variant='secondary'>{formatted.blockType}</Badge>
<Badge variant='gray-secondary'>{formatted.blockType}</Badge>
<Badge variant='outline'>not executed</Badge>
</div>
</CardHeader>
@@ -254,7 +253,7 @@ function PinnedLogs({
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[8px]'>
<Badge variant={formatted.status === 'success' ? 'default' : 'destructive'}>
<Badge variant={formatted.status === 'success' ? 'default' : 'red'}>
{formatted.blockType}
</Badge>
<Badge variant='outline'>{formatted.status}</Badge>

View File

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

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'] as const
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook', 'mcp'] as const
/** Possible execution status values for workflow logs */
export type LogStatus = 'error' | 'pending' | 'running' | 'info' | 'cancelled'

View File

@@ -26,9 +26,8 @@
import * as React from 'react'
import { Check, GripHorizontal, Pencil, X } from 'lucide-react'
import { Button } from '@/components/emcn'
import { Button, Textarea } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { Textarea } from '@/components/ui'
import { cn } from '@/lib/core/utils/cn'
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'

View File

@@ -12,8 +12,7 @@ import { createLogger } from '@sim/logger'
import { ArrowUp, AtSign, Image, Loader2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { createPortal } from 'react-dom'
import { Badge, Button } from '@/components/emcn'
import { Textarea } from '@/components/ui'
import { Badge, Button, Textarea } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import {

View File

@@ -0,0 +1,557 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { Badge, Combobox, type ComboboxOption, Input, Label, Textarea } from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils'
import { isValidStartBlockType } from '@/lib/workflows/triggers/trigger-utils'
import type { InputFormatField } from '@/lib/workflows/types'
import {
useAddWorkflowMcpTool,
useDeleteWorkflowMcpTool,
useUpdateWorkflowMcpTool,
useWorkflowMcpServers,
useWorkflowMcpTools,
type WorkflowMcpServer,
type WorkflowMcpTool,
} from '@/hooks/queries/workflow-mcp-servers'
import { useSettingsModalStore } from '@/stores/settings-modal/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('McpToolDeploy')
/** InputFormatField with guaranteed name (after normalization) */
type NormalizedField = InputFormatField & { name: string }
interface McpDeployProps {
workflowId: string
workflowName: string
workflowDescription?: string | null
isDeployed: boolean
onAddedToServer?: () => void
onSubmittingChange?: (submitting: boolean) => void
onCanSaveChange?: (canSave: boolean) => void
}
/**
* Generate JSON Schema from input format with optional descriptions
*/
function generateParameterSchema(
inputFormat: NormalizedField[],
descriptions: Record<string, string>
): Record<string, unknown> {
const fieldsWithDescriptions = inputFormat.map((field) => ({
...field,
description: descriptions[field.name]?.trim() || undefined,
}))
return generateToolInputSchema(fieldsWithDescriptions) as unknown as Record<string, unknown>
}
/**
* Component to query tools for a single server and report back via callback.
*/
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
}
export function McpDeploy({
workflowId,
workflowName,
workflowDescription,
isDeployed,
onAddedToServer,
onSubmittingChange,
onCanSaveChange,
}: McpDeployProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const openSettingsModal = useSettingsModalStore((state) => state.openModal)
const {
data: servers = [],
isLoading: isLoadingServers,
refetch: refetchServers,
} = useWorkflowMcpServers(workspaceId)
const addToolMutation = useAddWorkflowMcpTool()
const deleteToolMutation = useDeleteWorkflowMcpTool()
const updateToolMutation = useUpdateWorkflowMcpTool()
const blocks = useWorkflowStore((state) => state.blocks)
const starterBlockId = useMemo(() => {
for (const [blockId, block] of Object.entries(blocks)) {
if (!block || typeof block !== 'object') continue
const blockType = (block as { type?: string }).type
if (blockType && isValidStartBlockType(blockType)) {
return blockId
}
}
return null
}, [blocks])
const subBlockValues = useSubBlockStore((state) =>
workflowId ? (state.workflowValues[workflowId] ?? {}) : {}
)
const inputFormat = useMemo((): NormalizedField[] => {
if (!starterBlockId) return []
const storeValue = subBlockValues[starterBlockId]?.inputFormat
const normalized = normalizeInputFormatValue(storeValue) as NormalizedField[]
if (normalized.length > 0) return normalized
const startBlock = blocks[starterBlockId]
const blockValue = startBlock?.subBlocks?.inputFormat?.value
return normalizeInputFormatValue(blockValue) as NormalizedField[]
}, [starterBlockId, subBlockValues, blocks])
const [toolName, setToolName] = useState(() => sanitizeToolName(workflowName))
const [toolDescription, setToolDescription] = useState(() => {
const isDefaultDescription =
!workflowDescription ||
workflowDescription === workflowName ||
workflowDescription.toLowerCase() === 'new workflow'
return isDefaultDescription ? '' : workflowDescription
})
const [parameterDescriptions, setParameterDescriptions] = useState<Record<string, string>>({})
const [pendingServerChanges, setPendingServerChanges] = useState<Set<string>>(new Set())
const parameterSchema = useMemo(
() => generateParameterSchema(inputFormat, parameterDescriptions),
[inputFormat, parameterDescriptions]
)
const [serverToolsMap, setServerToolsMap] = useState<
Record<string, { tool: WorkflowMcpTool | null; isLoading: boolean }>
>({})
const handleServerToolData = useCallback(
(serverId: string, tool: WorkflowMcpTool | null, isLoading: boolean) => {
setServerToolsMap((prev) => {
const existing = prev[serverId]
if (existing?.tool?.id === tool?.id && existing?.isLoading === isLoading) {
return prev
}
return {
...prev,
[serverId]: { tool, isLoading },
}
})
},
[]
)
const selectedServerIds = useMemo(() => {
const ids: string[] = []
for (const server of servers) {
const toolInfo = serverToolsMap[server.id]
if (toolInfo?.tool) {
ids.push(server.id)
}
}
return ids
}, [servers, serverToolsMap])
const hasLoadedInitialData = useRef(false)
useEffect(() => {
for (const server of servers) {
const toolInfo = serverToolsMap[server.id]
if (toolInfo?.tool) {
setToolName(toolInfo.tool.toolName)
const loadedDescription = toolInfo.tool.toolDescription || ''
const isDefaultDescription =
!loadedDescription ||
loadedDescription === workflowName ||
loadedDescription.toLowerCase() === 'new workflow'
setToolDescription(isDefaultDescription ? '' : loadedDescription)
const schema = toolInfo.tool.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)) {
if (
prop.description &&
prop.description !== name &&
prop.description !== 'Array of file objects'
) {
descriptions[name] = prop.description
}
}
if (Object.keys(descriptions).length > 0) {
setParameterDescriptions(descriptions)
}
}
hasLoadedInitialData.current = true
break
}
}
}, [servers, serverToolsMap, workflowName])
const [savedValues, setSavedValues] = useState<{
toolName: string
toolDescription: string
parameterDescriptions: Record<string, string>
} | null>(null)
useEffect(() => {
if (hasLoadedInitialData.current && !savedValues) {
setSavedValues({
toolName,
toolDescription,
parameterDescriptions: { ...parameterDescriptions },
})
}
}, [toolName, toolDescription, parameterDescriptions, savedValues])
const hasDeployedTools = selectedServerIds.length > 0
const hasChanges = useMemo(() => {
if (!savedValues || !hasDeployedTools) return false
if (toolName !== savedValues.toolName) return true
if (toolDescription !== savedValues.toolDescription) return true
if (
JSON.stringify(parameterDescriptions) !== JSON.stringify(savedValues.parameterDescriptions)
) {
return true
}
return false
}, [toolName, toolDescription, parameterDescriptions, hasDeployedTools, savedValues])
useEffect(() => {
onCanSaveChange?.(hasChanges && hasDeployedTools && !!toolName.trim())
}, [hasChanges, hasDeployedTools, toolName, onCanSaveChange])
/**
* Save tool configuration to all deployed servers
*/
const handleSave = useCallback(async () => {
if (!toolName.trim()) return
const toolsToUpdate: Array<{ serverId: string; toolId: string }> = []
for (const server of servers) {
const toolInfo = serverToolsMap[server.id]
if (toolInfo?.tool) {
toolsToUpdate.push({ serverId: server.id, toolId: toolInfo.tool.id })
}
}
if (toolsToUpdate.length === 0) return
onSubmittingChange?.(true)
try {
for (const { serverId, toolId } of toolsToUpdate) {
await updateToolMutation.mutateAsync({
workspaceId,
serverId,
toolId,
toolName: toolName.trim(),
toolDescription: toolDescription.trim() || undefined,
parameterSchema,
})
}
// Update saved values after successful save (triggers re-render → hasChanges becomes false)
setSavedValues({
toolName,
toolDescription,
parameterDescriptions: { ...parameterDescriptions },
})
onCanSaveChange?.(false)
onSubmittingChange?.(false)
} catch (error) {
logger.error('Failed to save tool configuration:', error)
onSubmittingChange?.(false)
}
}, [
toolName,
toolDescription,
parameterDescriptions,
parameterSchema,
servers,
serverToolsMap,
workspaceId,
updateToolMutation,
onSubmittingChange,
onCanSaveChange,
])
const serverOptions: ComboboxOption[] = useMemo(() => {
return servers.map((server) => ({
label: server.name,
value: server.id,
}))
}, [servers])
const handleServerSelectionChange = useCallback(
async (newSelectedIds: string[]) => {
if (!toolName.trim()) return
const currentIds = new Set(selectedServerIds)
const newIds = new Set(newSelectedIds)
const toAdd = newSelectedIds.filter((id) => !currentIds.has(id))
const toRemove = selectedServerIds.filter((id) => !newIds.has(id))
for (const serverId of toAdd) {
setPendingServerChanges((prev) => new Set(prev).add(serverId))
try {
await addToolMutation.mutateAsync({
workspaceId,
serverId,
workflowId,
toolName: toolName.trim(),
toolDescription: toolDescription.trim() || undefined,
parameterSchema,
})
refetchServers()
onAddedToServer?.()
logger.info(`Added workflow ${workflowId} as tool to server ${serverId}`)
} catch (error) {
logger.error('Failed to add tool:', error)
} finally {
setPendingServerChanges((prev) => {
const next = new Set(prev)
next.delete(serverId)
return next
})
}
}
for (const serverId of toRemove) {
const toolInfo = serverToolsMap[serverId]
if (toolInfo?.tool) {
setPendingServerChanges((prev) => new Set(prev).add(serverId))
try {
await deleteToolMutation.mutateAsync({
workspaceId,
serverId,
toolId: toolInfo.tool.id,
})
setServerToolsMap((prev) => {
const next = { ...prev }
delete next[serverId]
return next
})
refetchServers()
} catch (error) {
logger.error('Failed to remove tool:', error)
} finally {
setPendingServerChanges((prev) => {
const next = new Set(prev)
next.delete(serverId)
return next
})
}
}
}
},
[
selectedServerIds,
serverToolsMap,
toolName,
toolDescription,
workspaceId,
workflowId,
parameterSchema,
addToolMutation,
deleteToolMutation,
refetchServers,
onAddedToServer,
]
)
const selectedServersLabel = useMemo(() => {
const count = selectedServerIds.length
if (count === 0) return 'Select servers...'
if (count === 1) {
const server = servers.find((s) => s.id === selectedServerIds[0])
return server?.name || '1 server'
}
return `${count} servers selected`
}, [selectedServerIds, servers])
const isPending = pendingServerChanges.size > 0
if (!isDeployed) {
return (
<div className='flex h-full items-center justify-center text-[13px] text-[var(--text-muted)]'>
Deploy your workflow first to add it as an MCP tool.
</div>
)
}
if (isLoadingServers) {
return (
<div className='-mx-1 space-y-4 px-1'>
<div className='space-y-[12px]'>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[70px]' />
<Skeleton className='h-[34px] w-full rounded-[4px]' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[80px]' />
<Skeleton className='h-[34px] w-full rounded-[4px]' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[50px]' />
<Skeleton className='h-[34px] w-full rounded-[4px]' />
</div>
</div>
</div>
)
}
if (servers.length === 0) {
return (
<div className='flex h-full items-center justify-center text-[13px] text-[var(--text-muted)]'>
<button
type='button'
onClick={() => openSettingsModal({ section: 'workflow-mcp-servers' })}
className='transition-colors hover:text-[var(--text-secondary)]'
>
Create an MCP Server in Settings MCP Servers first.
</button>
</div>
)
}
return (
<form
id='mcp-deploy-form'
className='-mx-1 space-y-[12px] overflow-y-auto px-1'
onSubmit={(e) => {
e.preventDefault()
handleSave()
}}
>
{/* Hidden submit button for parent modal to trigger */}
<button type='submit' hidden />
{servers.map((server) => (
<ServerToolsQuery
key={server.id}
workspaceId={workspaceId}
server={server}
workflowId={workflowId}
onData={handleServerToolData}
/>
))}
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Tool name
</Label>
<Input
value={toolName}
onChange={(e) => setToolName(e.target.value)}
placeholder='e.g., book_flight'
/>
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
Use lowercase letters, numbers, and underscores only
</p>
</div>
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Description
</Label>
<Textarea
placeholder='Describe what this tool does...'
className='min-h-[100px] resize-none'
value={toolDescription}
onChange={(e) => setToolDescription(e.target.value)}
/>
</div>
{inputFormat.length > 0 && (
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Parameters ({inputFormat.length})
</Label>
<div className='flex flex-col gap-[8px]'>
{inputFormat.map((field) => (
<div
key={field.name}
className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
>
<div className='flex items-center justify-between'>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>{field.name}</p>
<Badge variant='outline' className='text-[10px]'>
{field.type}
</Badge>
</div>
<Input
value={parameterDescriptions[field.name] || ''}
onChange={(e) =>
setParameterDescriptions((prev) => ({
...prev,
[field.name]: e.target.value,
}))
}
placeholder='Description'
className='mt-[6px] h-[28px] text-[12px]'
/>
</div>
))}
</div>
</div>
)}
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Servers
</Label>
<Combobox
options={serverOptions}
multiSelect
multiSelectValues={selectedServerIds}
onMultiSelectChange={handleServerSelectionChange}
placeholder='Select servers...'
searchable
searchPlaceholder='Search servers...'
disabled={!toolName.trim() || isPending}
isLoading={isPending}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{selectedServersLabel}</span>
}
/>
{!toolName.trim() && (
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
Enter a tool name to select servers
</p>
)}
</div>
{addToolMutation.isError && (
<p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>
{addToolMutation.error?.message || 'Failed to add tool'}
</p>
)}
</form>
)
}

View File

@@ -25,6 +25,7 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { ApiDeploy } from './components/api/api'
import { ChatDeploy, type ExistingChat } from './components/chat/chat'
import { GeneralDeploy } from './components/general/general'
import { McpDeploy } from './components/mcp/mcp'
import { TemplateDeploy } from './components/template/template'
const logger = createLogger('DeployModal')
@@ -49,7 +50,7 @@ interface WorkflowDeploymentInfo {
needsRedeployment: boolean
}
type TabView = 'general' | 'api' | 'chat' | 'template'
type TabView = 'general' | 'api' | 'chat' | 'template' | 'mcp'
export function DeployModal({
open,
@@ -86,6 +87,8 @@ export function DeployModal({
const [showUndeployConfirm, setShowUndeployConfirm] = useState(false)
const [templateFormValid, setTemplateFormValid] = useState(false)
const [templateSubmitting, setTemplateSubmitting] = useState(false)
const [mcpToolSubmitting, setMcpToolSubmitting] = useState(false)
const [mcpToolCanSave, setMcpToolCanSave] = useState(false)
const [hasExistingTemplate, setHasExistingTemplate] = useState(false)
const [templateStatus, setTemplateStatus] = useState<{
status: 'pending' | 'approved' | 'rejected' | null
@@ -528,6 +531,11 @@ export function DeployModal({
form?.requestSubmit()
}, [])
const handleMcpToolFormSubmit = useCallback(() => {
const form = document.getElementById('mcp-deploy-form') as HTMLFormElement
form?.requestSubmit()
}, [])
const handleTemplateDelete = useCallback(() => {
const form = document.getElementById('template-deploy-form')
const deleteTrigger = form?.querySelector('[data-template-delete-trigger]') as HTMLButtonElement
@@ -548,6 +556,7 @@ export function DeployModal({
<ModalTabsList activeValue={activeTab}>
<ModalTabsTrigger value='general'>General</ModalTabsTrigger>
<ModalTabsTrigger value='api'>API</ModalTabsTrigger>
<ModalTabsTrigger value='mcp'>MCP</ModalTabsTrigger>
<ModalTabsTrigger value='chat'>Chat</ModalTabsTrigger>
<ModalTabsTrigger value='template'>Template</ModalTabsTrigger>
</ModalTabsList>
@@ -608,6 +617,19 @@ export function DeployModal({
/>
)}
</ModalTabsContent>
<ModalTabsContent value='mcp' className='h-full'>
{workflowId && (
<McpDeploy
workflowId={workflowId}
workflowName={workflowMetadata?.name || 'Workflow'}
workflowDescription={workflowMetadata?.description}
isDeployed={isDeployed}
onSubmittingChange={setMcpToolSubmitting}
onCanSaveChange={setMcpToolCanSave}
/>
)}
</ModalTabsContent>
</ModalBody>
</ModalTabs>
@@ -652,6 +674,18 @@ export function DeployModal({
</div>
</ModalFooter>
)}
{activeTab === 'mcp' && isDeployed && (
<ModalFooter className='items-center'>
<Button
type='button'
variant='tertiary'
onClick={handleMcpToolFormSubmit}
disabled={mcpToolSubmitting || !mcpToolCanSave}
>
{mcpToolSubmitting ? 'Saving...' : 'Save Tool Schema'}
</Button>
</ModalFooter>
)}
{activeTab === 'template' && (
<ModalFooter
className={`items-center ${hasExistingTemplate && templateStatus ? 'justify-between' : ''}`}

View File

@@ -1,5 +1,4 @@
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Checkbox, Label } from '@/components/emcn'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
interface CheckboxListProps {

View File

@@ -2,8 +2,7 @@
import { useMemo, useState } from 'react'
import { Settings2 } from 'lucide-react'
import { Button } from '@/components/emcn/components'
import { Checkbox } from '@/components/ui/checkbox'
import { Button, Checkbox } from '@/components/emcn/components'
import {
Dialog,
DialogContent,

View File

@@ -1,9 +1,7 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { Combobox, Input, Label, Textarea } from '@/components/emcn/components'
import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch'
import { Combobox, Input, Label, Slider, Switch, Textarea } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import {

View File

@@ -1,5 +1,4 @@
import { Label } from '@/components/ui/label'
import { Switch as UISwitch } from '@/components/ui/switch'
import { Label, Switch as UISwitch } from '@/components/emcn'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
interface SwitchProps {
@@ -23,11 +22,9 @@ export function Switch({
}: SwitchProps) {
const [storeValue, setStoreValue] = useSubBlockValue<boolean>(blockId, subBlockId)
// Use preview value when in preview mode, otherwise use store value or prop value
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
const handleChange = (checked: boolean) => {
// Only update store when not in preview mode and not disabled
if (!isPreview && !disabled) {
setStoreValue(checked)
}

View File

@@ -12,11 +12,17 @@ import {
PopoverContent,
PopoverItem,
PopoverTrigger,
Switch,
Tooltip,
} from '@/components/emcn'
import { McpIcon } from '@/components/icons'
import { Switch } from '@/components/ui/switch'
import { cn } from '@/lib/core/utils/cn'
import {
getIssueBadgeLabel,
getIssueBadgeVariant,
isToolUnavailable,
getMcpToolIssue as validateMcpTool,
} from '@/lib/mcp/tool-validation'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
@@ -26,7 +32,6 @@ import {
import {
CheckboxList,
Code,
ComboBox,
FileSelectorInput,
FileUpload,
LongInput,
@@ -50,7 +55,7 @@ import {
type CustomTool as CustomToolDefinition,
useCustomTools,
} from '@/hooks/queries/custom-tools'
import { useMcpServers } from '@/hooks/queries/mcp'
import { useForceRefreshMcpTools, useMcpServers, useStoredMcpTools } from '@/hooks/queries/mcp'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useMcpTools } from '@/hooks/use-mcp-tools'
import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils'
@@ -447,35 +452,28 @@ function CheckboxListSyncWrapper({
}
function ComboboxSyncWrapper({
blockId,
paramId,
value,
onChange,
uiComponent,
disabled,
}: {
blockId: string
paramId: string
value: string
onChange: (value: string) => void
uiComponent: any
disabled: boolean
}) {
const options = (uiComponent.options || []).map((opt: any) =>
typeof opt === 'string' ? { label: opt, value: opt } : { label: opt.label, value: opt.id }
)
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
<ComboBox
blockId={blockId}
subBlockId={paramId}
options={uiComponent.options || []}
placeholder={uiComponent.placeholder}
config={{
id: paramId,
type: 'combobox' as const,
title: paramId,
}}
disabled={disabled}
/>
</GenericSyncWrapper>
<Combobox
options={options}
value={value}
onChange={onChange}
placeholder={uiComponent.placeholder || 'Select option'}
disabled={disabled}
/>
)
}
@@ -552,8 +550,6 @@ function ChannelSelectorSyncWrapper({
}
function WorkflowSelectorSyncWrapper({
blockId,
paramId,
value,
onChange,
uiComponent,
@@ -561,8 +557,6 @@ function WorkflowSelectorSyncWrapper({
workspaceId,
currentWorkflowId,
}: {
blockId: string
paramId: string
value: string
onChange: (value: string) => void
uiComponent: any
@@ -578,26 +572,17 @@ function WorkflowSelectorSyncWrapper({
const options = availableWorkflows.map((workflow) => ({
label: workflow.name,
id: workflow.id,
value: workflow.id,
}))
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
<ComboBox
blockId={blockId}
subBlockId={paramId}
options={options}
value={value}
placeholder={uiComponent.placeholder || 'Select workflow'}
disabled={disabled || isLoading}
config={{
id: paramId,
type: 'combobox',
options: options,
placeholder: uiComponent.placeholder || 'Select workflow',
}}
/>
</GenericSyncWrapper>
<Combobox
options={options}
value={value}
onChange={onChange}
placeholder={uiComponent.placeholder || 'Select workflow'}
disabled={disabled || isLoading}
/>
)
}
@@ -847,30 +832,65 @@ export function ToolInput({
} = useMcpTools(workspaceId)
const { data: mcpServers = [], isLoading: mcpServersLoading } = useMcpServers(workspaceId)
const { data: storedMcpTools = [] } = useStoredMcpTools(workspaceId)
const forceRefreshMcpTools = useForceRefreshMcpTools()
const openSettingsModal = useSettingsModalStore((state) => state.openModal)
const mcpDataLoading = mcpLoading || mcpServersLoading
const hasRefreshedRef = useRef(false)
const value = isPreview ? previewValue : storeValue
const selectedTools: StoredTool[] =
Array.isArray(value) &&
value.length > 0 &&
value[0] !== null &&
typeof value[0]?.type === 'string'
? (value as StoredTool[])
: []
const hasMcpTools = selectedTools.some((tool) => tool.type === 'mcp')
useEffect(() => {
if (hasMcpTools && !hasRefreshedRef.current) {
hasRefreshedRef.current = true
forceRefreshMcpTools(workspaceId)
}
}, [hasMcpTools, forceRefreshMcpTools, workspaceId])
/**
* Returns issue info for an MCP tool using shared validation logic.
* Returns issue info for an MCP tool.
* Uses DB schema (storedMcpTools) when available for real-time updates after refresh,
* otherwise falls back to Zustand schema (tool.schema) which is always available.
*/
const getMcpToolIssue = useCallback(
(tool: StoredTool) => {
if (tool.type !== 'mcp') return null
const { getMcpToolIssue: validateTool } = require('@/lib/mcp/tool-validation')
const serverId = tool.params?.serverId as string
const toolName = tool.params?.toolName as string
return validateTool(
// Try to get fresh schema from DB (enables real-time updates after MCP refresh)
const storedTool =
storedMcpTools.find(
(st) =>
st.serverId === serverId && st.toolName === toolName && st.workflowId === workflowId
) || storedMcpTools.find((st) => st.serverId === serverId && st.toolName === toolName)
// Use DB schema if available, otherwise use Zustand schema
const schema = storedTool?.schema ?? tool.schema
return validateMcpTool(
{
serverId: tool.params?.serverId as string,
serverId,
serverUrl: tool.params?.serverUrl as string | undefined,
toolName: tool.params?.toolName as string,
schema: tool.schema,
toolName,
schema,
},
mcpServers.map((s) => ({
id: s.id,
url: s.url,
connectionStatus: s.connectionStatus,
lastError: s.lastError,
lastError: s.lastError ?? undefined,
})),
mcpTools.map((t) => ({
serverId: t.serverId,
@@ -879,24 +899,16 @@ export function ToolInput({
}))
)
},
[mcpTools, mcpServers]
[mcpTools, mcpServers, storedMcpTools, workflowId]
)
const isMcpToolUnavailable = useCallback(
(tool: StoredTool): boolean => {
const { isToolUnavailable } = require('@/lib/mcp/tool-validation')
return isToolUnavailable(getMcpToolIssue(tool))
},
[getMcpToolIssue]
)
const hasMcpToolIssue = useCallback(
(tool: StoredTool): boolean => {
return getMcpToolIssue(tool) !== null
},
[getMcpToolIssue]
)
// Filter out MCP tools from unavailable servers for the dropdown
const availableMcpTools = useMemo(() => {
return mcpTools.filter((mcpTool) => {
@@ -922,12 +934,20 @@ export function ToolInput({
block.type !== 'file'
)
const value = isPreview ? previewValue : storeValue
const customFilter = useCallback((value: string, search: string) => {
if (!search.trim()) return 1
const selectedTools: StoredTool[] =
Array.isArray(value) && value.length > 0 && typeof value[0] === 'object'
? (value as unknown as StoredTool[])
: []
const normalizedValue = value.toLowerCase()
const normalizedSearch = search.toLowerCase()
if (normalizedValue === normalizedSearch) return 1
if (normalizedValue.startsWith(normalizedSearch)) return 0.8
if (normalizedValue.includes(normalizedSearch)) return 0.6
return 0
}, [])
const hasBackfilledRef = useRef(false)
useEffect(() => {
@@ -1872,8 +1892,6 @@ export function ToolInput({
case 'combobox':
return (
<ComboboxSyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
onChange={onChange}
uiComponent={uiComponent}
@@ -1932,8 +1950,6 @@ export function ToolInput({
case 'workflow-selector':
return (
<WorkflowSelectorSyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
onChange={onChange}
uiComponent={uiComponent}
@@ -2181,13 +2197,12 @@ export function ToolInput({
(() => {
const issue = getMcpToolIssue(tool)
if (!issue) return null
const { getIssueBadgeLabel } = require('@/lib/mcp/tool-validation')
const serverId = tool.params?.serverId
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Badge
variant='amber'
variant={getIssueBadgeVariant(issue)}
className='cursor-pointer'
size='sm'
dot

View File

@@ -1,10 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import { Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Badge, Button, Combobox, Input } from '@/components/emcn'
import { Badge, Button, Combobox, Input, Label, Textarea } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/core/utils/cn'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import {

View File

@@ -16,6 +16,7 @@ import {
} from 'lucide-react'
import {
Button,
Checkbox,
Input,
Label,
Modal,
@@ -28,7 +29,6 @@ import {
ModalTabsTrigger,
Textarea,
} from '@/components/emcn'
import { Checkbox } from '@/components/ui/checkbox'
import { cn } from '@/lib/core/utils/cn'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { formatEditSequence } from '@/lib/workflows/training/compute-edit-sequence'

View File

@@ -12,9 +12,10 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Switch,
Tooltip,
} from '@/components/emcn'
import { Input, Skeleton, Switch } from '@/components/ui'
import { Input, Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
@@ -468,7 +469,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
if (createError) setCreateError(null)
}}
disabled={!allowPersonalApiKeys}
className='disabled:cursor-not-allowed disabled:opacity-60'
className={`disabled:cursor-not-allowed disabled:opacity-60 ${keyType === 'personal' ? 'bg-[var(--border-1)] hover:bg-[var(--border-1)] dark:bg-[var(--surface-5)] dark:hover:bg-[var(--border-1)]' : ''}`}
>
Personal
</Button>
@@ -479,6 +480,11 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
setKeyType('workspace')
if (createError) setCreateError(null)
}}
className={
keyType === 'workspace'
? 'bg-[var(--border-1)] hover:bg-[var(--border-1)] dark:bg-[var(--surface-5)] dark:hover:bg-[var(--border-1)]'
: ''
}
>
Workspace
</Button>

View File

@@ -163,7 +163,7 @@ export function CustomTools() {
Edit
</Button>
<Button
variant='ghost'
variant='destructive'
onClick={() => handleDeleteClick(tool.id)}
disabled={deletingTools.has(tool.id)}
>

View File

@@ -4,16 +4,18 @@ import { useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ArrowDown, Loader2, Plus, Search, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Tooltip, Trash } from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
import {
Button,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
Tooltip,
Trash,
} from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { cn } from '@/lib/core/utils/cn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'

View File

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

View File

@@ -40,7 +40,7 @@ export function FormattedInput({
onChange={onChange}
onScroll={handleScroll}
onInput={handleScroll}
className='h-9 text-transparent caret-foreground placeholder:text-[var(--text-tertiary)]'
className='h-9 text-transparent caret-foreground placeholder:text-[var(--text-muted)]'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-[8px] py-[6px] font-medium font-sans text-sm'>
<div className='whitespace-nowrap' style={{ transform: `translateX(-${scrollLeft}px)` }}>

View File

@@ -64,7 +64,7 @@ export function ServerListItem({
</p>
</div>
<div className='flex flex-shrink-0 items-center gap-[4px]'>
<Button variant='tertiary' onClick={onViewDetails}>
<Button variant='ghost' onClick={onViewDetails}>
Details
</Button>
<Button variant='destructive' onClick={onRemove} disabled={isDeleting}>

View File

@@ -13,19 +13,28 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Tooltip,
} from '@/components/emcn'
import { Input } from '@/components/ui'
import { getIssueBadgeLabel, getMcpToolIssue, type McpToolIssue } from '@/lib/mcp/tool-validation'
import {
getIssueBadgeLabel,
getIssueBadgeVariant,
getMcpToolIssue,
type McpToolIssue,
} from '@/lib/mcp/tool-validation'
import { checkEnvVarTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
import {
useCreateMcpServer,
useDeleteMcpServer,
useForceRefreshMcpTools,
useMcpServers,
useMcpToolsQuery,
useRefreshMcpServer,
useStoredMcpTools,
} from '@/hooks/queries/mcp'
import { useMcpServerTest } from '@/hooks/use-mcp-server-test'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { InputFieldType, McpServerFormData, McpServerTestResult } from './components'
import {
FormattedInput,
@@ -98,7 +107,8 @@ export function MCP({ initialServerId }: MCPProps) {
isLoading: toolsLoading,
isFetching: toolsFetching,
} = useMcpToolsQuery(workspaceId)
const { data: storedTools = [] } = useStoredMcpTools(workspaceId)
const { data: storedTools = [], refetch: refetchStoredTools } = useStoredMcpTools(workspaceId)
const forceRefreshTools = useForceRefreshMcpTools()
const createServerMutation = useCreateMcpServer()
const deleteServerMutation = useDeleteMcpServer()
const refreshServerMutation = useRefreshMcpServer()
@@ -118,7 +128,7 @@ export function MCP({ initialServerId }: MCPProps) {
const [selectedServerId, setSelectedServerId] = useState<string | null>(null)
const [refreshingServers, setRefreshingServers] = useState<
Record<string, 'refreshing' | 'refreshed'>
Record<string, { status: 'refreshing' | 'refreshed'; workflowsUpdated?: number }>
>({})
const [showEnvVars, setShowEnvVars] = useState(false)
@@ -137,6 +147,14 @@ export function MCP({ initialServerId }: MCPProps) {
}
}, [initialServerId, servers])
// Force refresh tools when entering server detail view to detect stale schemas
useEffect(() => {
if (selectedServerId) {
forceRefreshTools(workspaceId)
refetchStoredTools()
}
}, [selectedServerId, workspaceId, forceRefreshTools, refetchStoredTools])
/**
* Resets environment variable dropdown state.
*/
@@ -404,21 +422,48 @@ export function MCP({ initialServerId }: MCPProps) {
/**
* Refreshes a server's tools by re-discovering them from the MCP server.
* Also syncs updated tool schemas to all workflows using those tools.
* If the active workflow was updated, reloads its subblock values.
*/
const handleRefreshServer = useCallback(
async (serverId: string) => {
try {
setRefreshingServers((prev) => ({ ...prev, [serverId]: 'refreshing' }))
await refreshServerMutation.mutateAsync({ workspaceId, serverId })
logger.info(`Refreshed MCP server: ${serverId}`)
setRefreshingServers((prev) => ({ ...prev, [serverId]: 'refreshed' }))
setRefreshingServers((prev) => ({ ...prev, [serverId]: { status: 'refreshing' } }))
const result = await refreshServerMutation.mutateAsync({ workspaceId, serverId })
logger.info(
`Refreshed MCP server: ${serverId}, workflows updated: ${result.workflowsUpdated}`
)
// If the active workflow was updated, reload its subblock values from DB
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId && result.updatedWorkflowIds?.includes(activeWorkflowId)) {
logger.info(`Active workflow ${activeWorkflowId} was updated, reloading subblock values`)
try {
const response = await fetch(`/api/workflows/${activeWorkflowId}`)
if (response.ok) {
const { data: workflowData } = await response.json()
if (workflowData?.state?.blocks) {
useSubBlockStore
.getState()
.initializeFromWorkflow(activeWorkflowId, workflowData.state.blocks)
}
}
} catch (reloadError) {
logger.warn('Failed to reload workflow subblock values:', reloadError)
}
}
setRefreshingServers((prev) => ({
...prev,
[serverId]: { status: 'refreshed', workflowsUpdated: result.workflowsUpdated },
}))
setTimeout(() => {
setRefreshingServers((prev) => {
const newState = { ...prev }
delete newState[serverId]
return newState
})
}, 2000)
}, 3000)
} catch (error) {
logger.error('Failed to refresh MCP server:', error)
setRefreshingServers((prev) => {
@@ -444,7 +489,7 @@ export function MCP({ initialServerId }: MCPProps) {
const error = toolsError || serversError
const hasServers = servers && servers.length > 0
const showEmptyState = !hasServers && !showAddForm
const shouldShowForm = showAddForm || !hasServers
const showNoResults = searchTerm.trim() && filteredServers.length === 0 && servers.length > 0
const isFormValid = formData.name.trim() && formData.url?.trim()
@@ -523,9 +568,7 @@ export function MCP({ initialServerId }: MCPProps) {
{server.url && (
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>URL</span>
<p className='break-all font-mono text-[13px] text-[var(--text-secondary)]'>
{server.url}
</p>
<p className='break-all text-[14px] text-[var(--text-secondary)]'>{server.url}</p>
</div>
)}
@@ -548,25 +591,33 @@ export function MCP({ initialServerId }: MCPProps) {
<div className='flex flex-col gap-[8px]'>
{tools.map((tool) => {
const issues = getStoredToolIssues(server.id, tool.name)
const affectedWorkflows = issues.map((i) => i.workflowName)
return (
<div
key={tool.name}
className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[8px]'>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
{tool.name}
</p>
{issues.length > 0 && (
<Badge
variant='outline'
style={{
borderColor: 'var(--warning)',
color: 'var(--warning)',
}}
>
{getIssueBadgeLabel(issues[0].issue)}
</Badge>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div>
<Badge
variant={getIssueBadgeVariant(issues[0].issue)}
size='sm'
className='cursor-help'
>
{getIssueBadgeLabel(issues[0].issue)}
</Badge>
</div>
</Tooltip.Trigger>
<Tooltip.Content>
Update in: {affectedWorkflows.join(', ')}
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
{tool.description && (
@@ -589,10 +640,12 @@ export function MCP({ initialServerId }: MCPProps) {
variant='default'
disabled={!!refreshingServers[server.id]}
>
{refreshingServers[server.id] === 'refreshing'
{refreshingServers[server.id]?.status === 'refreshing'
? 'Refreshing...'
: refreshingServers[server.id] === 'refreshed'
? 'Refreshed'
: refreshingServers[server.id]?.status === 'refreshed'
? refreshingServers[server.id].workflowsUpdated
? `Synced (${refreshingServers[server.id].workflowsUpdated} workflow${refreshingServers[server.id].workflowsUpdated === 1 ? '' : 's'})`
: 'Refreshed'
: 'Refresh Tools'}
</Button>
<Button onClick={handleBackToList} variant='tertiary'>
@@ -629,8 +682,8 @@ export function MCP({ initialServerId }: MCPProps) {
</Button>
</div>
{showAddForm && !serversLoading && (
<div className='rounded-[8px] border bg-[var(--surface-3)] p-[10px]'>
{shouldShowForm && !serversLoading && (
<div className='rounded-[8px] border p-[10px]'>
<div className='flex flex-col gap-[8px]'>
<FormField label='Server Name'>
<EmcnInput
@@ -736,10 +789,6 @@ export function MCP({ initialServerId }: MCPProps) {
<McpServerSkeleton />
<McpServerSkeleton />
</div>
) : showEmptyState ? (
<div className='flex h-full items-center justify-center text-[13px] text-[var(--text-muted)]'>
Click "Add" above to get started
</div>
) : (
<div className='flex flex-col gap-[8px]'>
{filteredServers.map((server) => {
@@ -754,7 +803,7 @@ export function MCP({ initialServerId }: MCPProps) {
tools={tools}
isDeleting={deletingServers.has(server.id)}
isLoadingTools={isLoadingTools}
isRefreshing={refreshingServers[server.id] === 'refreshing'}
isRefreshing={refreshingServers[server.id]?.status === 'refreshing'}
onRemove={() => handleRemoveServer(server.id, server.name || 'this server')}
onViewDetails={() => handleViewDetails(server.id)}
/>

View File

@@ -4,13 +4,13 @@ import React, { useMemo, useState } from 'react'
import { CheckCircle, ChevronDown } from 'lucide-react'
import {
Button,
Checkbox,
Input,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
} from '@/components/emcn'
import { Checkbox } from '@/components/ui/checkbox'
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import type { AdminWorkspace } from '@/hooks/queries/workspace'

View File

@@ -1,5 +1,12 @@
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
import { Checkbox } from '@/components/ui/checkbox'
import {
Button,
Checkbox,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
interface RemoveMemberDialogProps {
open: boolean

View File

@@ -0,0 +1,654 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Check, Clipboard, Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
Button,
Combobox,
type ComboboxOption,
Input as EmcnInput,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
} from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
import { getBaseUrl } from '@/lib/core/utils/urls'
import {
useAddWorkflowMcpTool,
useCreateWorkflowMcpServer,
useDeleteWorkflowMcpServer,
useDeleteWorkflowMcpTool,
useDeployedWorkflows,
useUpdateWorkflowMcpTool,
useWorkflowMcpServer,
useWorkflowMcpServers,
type WorkflowMcpServer,
type WorkflowMcpTool,
} from '@/hooks/queries/workflow-mcp-servers'
import { FormField, McpServerSkeleton } from '../mcp/components'
const logger = createLogger('WorkflowMcpServers')
interface ServerDetailViewProps {
workspaceId: string
serverId: string
onBack: () => void
}
function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewProps) {
const { data, isLoading, error, refetch } = useWorkflowMcpServer(workspaceId, serverId)
const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } =
useDeployedWorkflows(workspaceId)
const deleteToolMutation = useDeleteWorkflowMcpTool()
const addToolMutation = useAddWorkflowMcpTool()
const updateToolMutation = useUpdateWorkflowMcpTool()
const [copiedUrl, setCopiedUrl] = useState(false)
const [toolToDelete, setToolToDelete] = useState<WorkflowMcpTool | null>(null)
const [toolToView, setToolToView] = useState<WorkflowMcpTool | null>(null)
const [editingDescription, setEditingDescription] = useState<string>('')
const [showAddWorkflow, setShowAddWorkflow] = useState(false)
useEffect(() => {
if (toolToView) {
setEditingDescription(toolToView.toolDescription || '')
}
}, [toolToView])
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null)
const mcpServerUrl = useMemo(() => {
return `${getBaseUrl()}/api/mcp/serve/${serverId}`
}, [serverId])
const handleCopyUrl = () => {
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 (err) {
logger.error('Failed to delete tool:', err)
}
}
const handleAddWorkflow = async () => {
if (!selectedWorkflowId) return
try {
await addToolMutation.mutateAsync({
workspaceId,
serverId,
workflowId: selectedWorkflowId,
})
setShowAddWorkflow(false)
setSelectedWorkflowId(null)
refetch()
} catch (err) {
logger.error('Failed to add workflow:', err)
}
}
const tools = data?.tools ?? []
const availableWorkflows = useMemo(() => {
const existingWorkflowIds = new Set(tools.map((t) => t.workflowId))
return deployedWorkflows.filter((w) => !existingWorkflowIds.has(w.id))
}, [deployedWorkflows, tools])
const workflowOptions: ComboboxOption[] = useMemo(() => {
return availableWorkflows.map((w) => ({
label: w.name,
value: w.id,
}))
}, [availableWorkflows])
const selectedWorkflow = useMemo(() => {
return availableWorkflows.find((w) => w.id === selectedWorkflowId)
}, [availableWorkflows, selectedWorkflowId])
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-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
Failed to load server details
</p>
<Button variant='default' onClick={onBack}>
Go Back
</Button>
</div>
)
}
const { server } = 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>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>Transport</span>
<p className='text-[14px] text-[var(--text-secondary)]'>Streamable-HTTP</p>
</div>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>URL</span>
<div className='flex items-center gap-[8px]'>
<p className='flex-1 break-all text-[14px] text-[var(--text-secondary)]'>
{mcpServerUrl}
</p>
<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>
</div>
<div className='flex flex-col gap-[8px]'>
<div className='flex items-center justify-between'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Workflows ({tools.length})
</span>
<Button
variant='tertiary'
onClick={() => setShowAddWorkflow(true)}
disabled={availableWorkflows.length === 0}
>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
Add
</Button>
</div>
{tools.length === 0 ? (
<p className='text-[13px] text-[var(--text-muted)]'>
No workflows added yet. Click "Add" to add a deployed workflow.
</p>
) : (
<div className='flex flex-col gap-[8px]'>
{tools.map((tool) => (
<div key={tool.id} className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<span className='font-medium text-[14px]'>{tool.toolName}</span>
<p className='truncate text-[13px] text-[var(--text-muted)]'>
{tool.toolDescription || 'No description'}
</p>
</div>
<div className='flex flex-shrink-0 items-center gap-[4px]'>
<Button variant='ghost' onClick={() => setToolToView(tool)}>
Details
</Button>
<Button
variant='destructive'
onClick={() => setToolToDelete(tool)}
disabled={deleteToolMutation.isPending}
>
Remove
</Button>
</div>
</div>
))}
</div>
)}
{availableWorkflows.length === 0 && deployedWorkflows.length > 0 && (
<p className='mt-[4px] text-[11px] text-[var(--text-muted)]'>
All deployed workflows have been added to this server.
</p>
)}
{deployedWorkflows.length === 0 && !isLoadingWorkflows && (
<p className='mt-[4px] text-[11px] text-[var(--text-muted)]'>
Deploy a workflow first to add it to this server.
</p>
)}
</div>
</div>
</div>
<div className='mt-auto flex items-center justify-end'>
<Button onClick={onBack} variant='tertiary'>
Back
</Button>
</div>
</div>
<Modal open={!!toolToDelete} onOpenChange={(open) => !open && setToolToDelete(null)}>
<ModalContent className='w-[400px]'>
<ModalHeader>Remove Workflow</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to remove{' '}
<span className='font-medium text-[var(--text-primary)]'>
{toolToDelete?.toolName}
</span>{' '}
from this server? The workflow will remain deployed and can be added back later.
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setToolToDelete(null)}>
Cancel
</Button>
<Button
variant='destructive'
onClick={handleDeleteTool}
disabled={deleteToolMutation.isPending}
>
{deleteToolMutation.isPending ? 'Removing...' : 'Remove'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal
open={!!toolToView}
onOpenChange={(open) => {
if (!open) {
setToolToView(null)
setEditingDescription('')
}
}}
>
<ModalContent className='w-[480px]'>
<ModalHeader>{toolToView?.toolName}</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-[16px]'>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Description
</span>
<Textarea
value={editingDescription}
onChange={(e) => setEditingDescription(e.target.value)}
placeholder='Describe what this tool does...'
className='min-h-[80px] resize-none'
/>
</div>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Parameters
</span>
{(() => {
const schema = toolToView?.parameterSchema as
| { properties?: Record<string, { type?: string; description?: string }> }
| undefined
const properties = schema?.properties
if (!properties || Object.keys(properties).length === 0) {
return <p className='text-[13px] text-[var(--text-muted)]'>No parameters</p>
}
return (
<div className='flex flex-col gap-[8px]'>
{Object.entries(properties).map(([name, prop]) => (
<div
key={name}
className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
>
<div className='flex items-center justify-between'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
{name}
</span>
<Badge variant='outline' size='sm'>
{prop.type || 'any'}
</Badge>
</div>
{prop.description && (
<p className='mt-[4px] text-[12px] text-[var(--text-muted)]'>
{prop.description}
</p>
)}
</div>
))}
</div>
)
})()}
</div>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setToolToView(null)}>
Cancel
</Button>
<Button
variant='tertiary'
onClick={async () => {
if (!toolToView) return
try {
await updateToolMutation.mutateAsync({
workspaceId,
serverId,
toolId: toolToView.id,
toolDescription: editingDescription.trim() || undefined,
})
refetch()
setToolToView(null)
setEditingDescription('')
} catch (err) {
logger.error('Failed to update tool description:', err)
}
}}
disabled={
updateToolMutation.isPending ||
editingDescription.trim() === (toolToView?.toolDescription || '')
}
>
{updateToolMutation.isPending ? 'Saving...' : 'Save'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal
open={showAddWorkflow}
onOpenChange={(open) => {
if (!open) {
setShowAddWorkflow(false)
setSelectedWorkflowId(null)
}
}}
>
<ModalContent className='w-[420px]'>
<ModalHeader>Add Workflow</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Select a deployed workflow to add to this MCP server. The workflow will be available
as a tool.
</p>
<div className='mt-[16px] flex flex-col gap-[8px]'>
<Label className='font-medium text-[13px] text-[var(--text-secondary)]'>
Select Workflow
</Label>
<Combobox
options={workflowOptions}
value={selectedWorkflowId || undefined}
onChange={(value: string) => setSelectedWorkflowId(value)}
placeholder='Select a workflow...'
searchable
searchPlaceholder='Search workflows...'
disabled={addToolMutation.isPending}
overlayContent={
selectedWorkflow ? (
<span className='truncate text-[var(--text-primary)]'>
{selectedWorkflow.name}
</span>
) : undefined
}
/>
{addToolMutation.isError && (
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
{addToolMutation.error?.message || 'Failed to add workflow'}
</p>
)}
</div>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={() => {
setShowAddWorkflow(false)
setSelectedWorkflowId(null)
}}
>
Cancel
</Button>
<Button
variant='tertiary'
onClick={handleAddWorkflow}
disabled={!selectedWorkflowId || addToolMutation.isPending}
>
{addToolMutation.isPending ? 'Adding...' : 'Add Workflow'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}
/**
* 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: '' })
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))
}, [servers, searchTerm])
const resetForm = useCallback(() => {
setFormData({ name: '' })
setShowAddForm(false)
}, [])
const handleCreateServer = async () => {
if (!formData.name.trim()) return
try {
await createServerMutation.mutateAsync({
workspaceId,
name: formData.name.trim(),
})
resetForm()
} catch (err) {
logger.error('Failed to create server:', err)
}
}
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 (err) {
logger.error('Failed to delete server:', err)
} finally {
setDeletingServers((prev) => {
const next = new Set(prev)
next.delete(serverToDelete.id)
return next
})
}
}
const hasServers = servers.length > 0
const shouldShowForm = showAddForm || !hasServers
const showNoResults = searchTerm.trim() && filteredServers.length === 0 && hasServers
const isFormValid = formData.name.trim().length > 0
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='flex flex-1 items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]'>
<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)}
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'
/>
</div>
<Button onClick={() => setShowAddForm(true)} disabled={isLoading} variant='tertiary'>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
Add
</Button>
</div>
{shouldShowForm && !isLoading && (
<div className='rounded-[8px] border p-[10px]'>
<div className='flex flex-col gap-[8px]'>
<FormField label='Server Name'>
<EmcnInput
placeholder='e.g., My MCP Server'
value={formData.name}
onChange={(e) => setFormData({ name: e.target.value })}
className='h-9'
/>
</FormField>
<div className='flex items-center justify-end gap-[8px] pt-[12px]'>
<Button variant='ghost' onClick={resetForm}>
Cancel
</Button>
<Button
onClick={handleCreateServer}
disabled={!isFormValid || createServerMutation.isPending}
variant='tertiary'
>
{createServerMutation.isPending ? 'Adding...' : 'Add 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 MCP servers'}
</p>
</div>
) : isLoading ? (
<div className='flex flex-col gap-[8px]'>
<McpServerSkeleton />
<McpServerSkeleton />
<McpServerSkeleton />
</div>
) : (
<div className='flex flex-col gap-[8px]'>
{filteredServers.map((server) => {
const count = server.toolCount || 0
const toolNames = server.toolNames || []
const names = count > 0 ? `: ${toolNames.join(', ')}` : ''
const toolsLabel = `${count} tool${count !== 1 ? 's' : ''}${names}`
const isDeleting = deletingServers.has(server.id)
return (
<div key={server.id} className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-[6px]'>
<span className='max-w-[200px] truncate font-medium text-[14px]'>
{server.name}
</span>
<span className='text-[13px] text-[var(--text-secondary)]'>
(Streamable-HTTP)
</span>
</div>
<p className='truncate text-[13px] text-[var(--text-muted)]'>{toolsLabel}</p>
</div>
<div className='flex flex-shrink-0 items-center gap-[4px]'>
<Button variant='ghost' onClick={() => setSelectedServerId(server.id)}>
Details
</Button>
<Button
variant='destructive'
onClick={() => setServerToDelete(server)}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
</div>
</div>
)
})}
{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-secondary)]'>
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 action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setServerToDelete(null)}>
Cancel
</Button>
<Button variant='destructive' onClick={handleDeleteServer}>
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, KeySquare, LogIn, Settings, User, Users, Wrench } from 'lucide-react'
import { Files, KeySquare, LogIn, Server, Settings, User, Users, Wrench } from 'lucide-react'
import {
Card,
Connections,
@@ -41,6 +41,7 @@ import {
SSO,
Subscription,
TeamManagement,
WorkflowMcpServers,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components'
import { TemplateProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile'
import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general-settings'
@@ -71,6 +72,7 @@ type SettingsSection =
| 'copilot'
| 'mcp'
| 'custom-tools'
| 'workflow-mcp-servers'
type NavigationSection = 'account' | 'subscription' | 'tools' | 'system'
@@ -113,9 +115,10 @@ 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: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' },
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
{ id: 'workflow-mcp-servers', label: 'MCP Servers', icon: Server, section: 'system' },
{
id: 'byok',
label: 'BYOK',
@@ -468,6 +471,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
{activeSection === 'copilot' && <Copilot />}
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
{activeSection === 'custom-tools' && <CustomTools />}
{activeSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
</SModalMainBody>
</SModalMain>
</SModalContent>

View File

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

View File

@@ -0,0 +1,151 @@
'use client'
import * as React from 'react'
import * as AvatarPrimitive from '@radix-ui/react-avatar'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/core/utils/cn'
/**
* Variant styles for the Avatar component.
* Supports multiple sizes for different use cases.
*/
const avatarVariants = cva('relative flex shrink-0 overflow-hidden rounded-full', {
variants: {
size: {
xs: 'h-6 w-6',
sm: 'h-8 w-8',
md: 'h-10 w-10',
lg: 'h-12 w-12',
xl: 'h-16 w-16',
},
},
defaultVariants: {
size: 'md',
},
})
/**
* Variant styles for the status indicator.
*/
const avatarStatusVariants = cva(
'absolute bottom-0 right-0 rounded-full border-2 border-[var(--bg)]',
{
variants: {
status: {
online: 'bg-[#16a34a]',
offline: 'bg-[var(--text-muted)]',
busy: 'bg-[#dc2626]',
away: 'bg-[#f59e0b]',
},
size: {
xs: 'h-2 w-2',
sm: 'h-2.5 w-2.5',
md: 'h-3 w-3',
lg: 'h-3.5 w-3.5',
xl: 'h-4 w-4',
},
},
defaultVariants: {
status: 'online',
size: 'md',
},
}
)
type AvatarStatus = 'online' | 'offline' | 'busy' | 'away'
export interface AvatarProps
extends React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>,
VariantProps<typeof avatarVariants> {
/** Shows a status indicator badge on the avatar */
status?: AvatarStatus
}
/**
* Avatar component for displaying user profile images with fallback support.
*
* @example
* ```tsx
* import { Avatar, AvatarImage, AvatarFallback } from '@/components/emcn'
*
* // Basic usage
* <Avatar>
* <AvatarImage src="/avatar.jpg" alt="User" />
* <AvatarFallback>JD</AvatarFallback>
* </Avatar>
*
* // With size variant
* <Avatar size="lg">
* <AvatarImage src="/avatar.jpg" alt="User" />
* <AvatarFallback>JD</AvatarFallback>
* </Avatar>
*
* // With status indicator
* <Avatar status="online">
* <AvatarImage src="/avatar.jpg" alt="User" />
* <AvatarFallback>JD</AvatarFallback>
* </Avatar>
*
* // All status types
* <Avatar status="online" /> // Green
* <Avatar status="offline" /> // Gray
* <Avatar status="busy" /> // Red
* <Avatar status="away" /> // Yellow/Amber
* ```
*/
const Avatar = React.forwardRef<React.ElementRef<typeof AvatarPrimitive.Root>, AvatarProps>(
({ className, size, status, children, ...props }, ref) => (
<div className='relative inline-flex'>
<AvatarPrimitive.Root
ref={ref}
className={cn(avatarVariants({ size }), className)}
{...props}
>
{children}
</AvatarPrimitive.Root>
{status && (
<span
data-slot='avatar-status'
className={cn(avatarStatusVariants({ status, size }))}
aria-label={`Status: ${status}`}
/>
)}
</div>
)
)
Avatar.displayName = 'Avatar'
/**
* Image component for Avatar. Renders the user's profile picture.
*/
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full object-cover object-center', className)}
{...props}
/>
))
AvatarImage.displayName = 'AvatarImage'
/**
* Fallback component for Avatar. Displays initials or icon when image is unavailable.
*/
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full border border-[var(--border-1)] bg-[var(--surface-4)] font-medium text-[var(--text-secondary)] text-xs',
className
)}
{...props}
/>
))
AvatarFallback.displayName = 'AvatarFallback'
export { Avatar, AvatarImage, AvatarFallback, avatarVariants, avatarStatusVariants }

View File

@@ -0,0 +1,94 @@
'use client'
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { cva, type VariantProps } from 'class-variance-authority'
import { Check } from 'lucide-react'
import { cn } from '@/lib/core/utils/cn'
/**
* Variant styles for the Checkbox component.
* Controls size and visual style.
*
* @example
* ```tsx
* // Default checkbox
* <Checkbox />
*
* // Small checkbox (for tables)
* <Checkbox size="sm" />
*
* // Large checkbox
* <Checkbox size="lg" />
* ```
*/
const checkboxVariants = cva(
'peer shrink-0 rounded-sm border border-[var(--border-1)] bg-[var(--surface-4)] ring-offset-background transition-colors hover:border-[var(--border-muted)] hover:bg-[var(--surface-7)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-[var(--text-muted)] data-[state=checked]:bg-[var(--text-muted)] data-[state=checked]:text-white dark:bg-[var(--surface-5)] dark:data-[state=checked]:border-[var(--surface-7)] dark:data-[state=checked]:bg-[var(--surface-7)] dark:data-[state=checked]:text-[var(--text-primary)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
{
variants: {
size: {
sm: 'h-[14px] w-[14px]',
md: 'h-4 w-4',
lg: 'h-5 w-5',
},
},
defaultVariants: {
size: 'md',
},
}
)
/**
* Variant styles for the Checkbox indicator icon.
*/
const checkboxIconVariants = cva('stroke-[3]', {
variants: {
size: {
sm: 'h-[10px] w-[10px]',
md: 'h-3.5 w-3.5',
lg: 'h-4 w-4',
},
},
defaultVariants: {
size: 'md',
},
})
export interface CheckboxProps
extends React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>,
VariantProps<typeof checkboxVariants> {}
/**
* A checkbox component with size variants.
*
* @example
* ```tsx
* // Basic usage
* <Checkbox checked={checked} onCheckedChange={setChecked} />
*
* // Small checkbox for tables
* <Checkbox size="sm" checked={isSelected} onCheckedChange={handleSelect} />
*
* // With label
* <div className="flex items-center gap-2">
* <Checkbox id="terms" />
* <Label htmlFor="terms">Accept terms</Label>
* </div>
* ```
*/
const Checkbox = React.forwardRef<React.ElementRef<typeof CheckboxPrimitive.Root>, CheckboxProps>(
({ className, size, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(checkboxVariants({ size }), className)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<Check className={cn(checkboxIconVariants({ size }))} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
)
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox, checkboxVariants, checkboxIconVariants }

View File

@@ -1,6 +1,20 @@
export {
Avatar,
AvatarFallback,
AvatarImage,
type AvatarProps,
avatarStatusVariants,
avatarVariants,
} from './avatar/avatar'
export { Badge } from './badge/badge'
export { Breadcrumb, type BreadcrumbItem, type BreadcrumbProps } from './breadcrumb/breadcrumb'
export { Button, buttonVariants } from './button/button'
export {
Checkbox,
type CheckboxProps,
checkboxIconVariants,
checkboxVariants,
} from './checkbox/checkbox'
export {
CODE_LINE_HEIGHT_PX,
Code,
@@ -73,5 +87,15 @@ export {
} from './s-modal/s-modal'
export { Slider, type SliderProps } from './slider/slider'
export { Switch } from './switch/switch'
export {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from './table/table'
export { Textarea } from './textarea/textarea'
export { Tooltip } from './tooltip/tooltip'

View File

@@ -1,10 +1,33 @@
'use client'
import * as React from 'react'
import { cn } from '@/lib/core/utils/cn'
/**
* A simple Table component for displaying data.
*
* @example
* ```tsx
* <Table>
* <TableHeader>
* <TableRow>
* <TableHead>Name</TableHead>
* <TableHead>Status</TableHead>
* </TableRow>
* </TableHeader>
* <TableBody>
* <TableRow>
* <TableCell>Document.pdf</TableCell>
* <TableCell>Active</TableCell>
* </TableRow>
* </TableBody>
* </Table>
* ```
*/
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className='relative w-full overflow-auto'>
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
<table ref={ref} className={cn('w-full caption-bottom text-[13px]', className)} {...props} />
</div>
)
)
@@ -32,7 +55,10 @@ const TableFooter = React.forwardRef<
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
className={cn(
'border-t bg-[var(--surface-3)]/50 font-medium [&>tr]:last:border-b-0',
className
)}
{...props}
/>
))
@@ -42,10 +68,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className
)}
className={cn('border-[var(--border)] border-b transition-colors', className)}
{...props}
/>
)
@@ -59,7 +82,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
'h-10 px-[12px] py-[8px] text-left align-middle font-medium text-[var(--text-secondary)] [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...props}
@@ -73,7 +96,10 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
className={cn(
'px-[12px] py-[8px] align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...props}
/>
))
@@ -83,7 +109,11 @@ const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption ref={ref} className={cn('mt-4 text-muted-foreground text-sm', className)} {...props} />
<caption
ref={ref}
className={cn('mt-4 text-[var(--text-muted)] text-sm', className)}
{...props}
/>
))
TableCaption.displayName = 'TableCaption'

File diff suppressed because one or more lines are too long

View File

@@ -1,88 +0,0 @@
'use client'
import * as React from 'react'
import * as AvatarPrimitive from '@radix-ui/react-avatar'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/core/utils/cn'
const avatarStatusVariants = cva(
'flex items-center rounded-full size-2 border-2 border-background',
{
variants: {
variant: {
online: 'bg-green-600',
offline: 'bg-zinc-600 dark:bg-zinc-300',
busy: 'bg-yellow-600',
away: 'bg-blue-600',
},
},
defaultVariants: {
variant: 'online',
},
}
)
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full object-cover object-center', className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full border border-border bg-accent text-accent-foreground text-xs',
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
function AvatarIndicator({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
data-slot='avatar-indicator'
className={cn('absolute flex size-6 items-center justify-center', className)}
{...props}
/>
)
}
function AvatarStatus({
className,
variant,
...props
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof avatarStatusVariants>) {
return (
<div
data-slot='avatar-status'
className={cn(avatarStatusVariants({ variant }), className)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback, AvatarIndicator, AvatarStatus, avatarStatusVariants }

View File

@@ -1,32 +0,0 @@
import type * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/core/utils/cn'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }

View File

@@ -1,27 +0,0 @@
'use client'
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { Check } from 'lucide-react'
import { cn } from '@/lib/core/utils/cn'
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-[var(--border-1)] bg-[var(--surface-4)] ring-offset-background transition-colors hover:border-[var(--surface-7)] hover:bg-[var(--surface-5)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-[var(--surface-7)] data-[state=checked]:bg-[var(--border-1)] data-[state=checked]:text-[var(--text-primary)] dark:border-[var(--border-1)] dark:bg-[var(--surface-5)] dark:data-[state=checked]:border-[var(--surface-7)] dark:data-[state=checked]:bg-[var(--surface-7)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<Check className='h-3.5 w-3.5 stroke-[3]' />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -1,9 +1,6 @@
export { Alert, AlertDescription, AlertTitle } from './alert'
export { Avatar, AvatarFallback, AvatarImage } from './avatar'
export { Badge, badgeVariants } from './badge'
export { Button, buttonVariants } from './button'
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './card'
export { Checkbox } from './checkbox'
export { Collapsible, CollapsibleContent, CollapsibleTrigger } from './collapsible'
export {
Dialog,
@@ -54,18 +51,5 @@ export {
} from './select'
export { Separator } from './separator'
export { Skeleton } from './skeleton'
export { Slider } from './slider'
export { Switch } from './switch'
export {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from './table'
export { TagInput } from './tag-input'
export { Textarea } from './textarea'
export { ToolCallCompletion, ToolCallExecution } from './tool-call'

View File

@@ -1,24 +0,0 @@
'use client'
import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import { cn } from '@/lib/core/utils/cn'
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn('relative flex w-full touch-none select-none items-center', className)}
{...props}
>
<SliderPrimitive.Track className='relative h-2 w-full grow overflow-hidden rounded-full bg-input-background'>
<SliderPrimitive.Range className='absolute h-full bg-primary dark:bg-white' />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className='block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:border-white dark:bg-black' />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@@ -1,28 +0,0 @@
'use client'
import * as React from 'react'
import * as SwitchPrimitives from '@radix-ui/react-switch'
import { cn } from '@/lib/core/utils/cn'
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-[var(--surface-4)] transition-colors hover:bg-[var(--surface-5)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-[var(--border-1)] data-[state=unchecked]:bg-[var(--surface-4)] dark:bg-[var(--surface-4)] dark:data-[state=checked]:bg-[var(--surface-7)] dark:data-[state=unchecked]:bg-[var(--surface-5)] dark:hover:bg-[var(--surface-5)]',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-[var(--text-primary)] shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -1,20 +0,0 @@
import * as React from 'react'
import { cn } from '@/lib/core/utils/cn'
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'textarea'>>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-input-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = 'Textarea'
export { Textarea }

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'
import { CheckCircle, ChevronDown, ChevronRight, Loader2, Settings, XCircle } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Badge } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import type { ToolCallGroup, ToolCallState } from '@/lib/copilot/types'
@@ -219,7 +219,6 @@ export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps
)
}
// Completion State Component
export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProps) {
const [isExpanded, setIsExpanded] = useState(false)
const isSuccess = toolCall.state === 'completed'
@@ -390,7 +389,7 @@ export function ToolCallGroupComponent({ group, isCompact = false }: ToolCallGro
{isAllCompleted ? 'Completed' : 'In Progress'} ({completedCount}/{totalCount})
</span>
{hasErrors && (
<Badge variant='destructive' className='shrink-0 text-xs'>
<Badge variant='red' className='shrink-0 text-xs'>
Errors
</Badge>
)}

View File

@@ -1,15 +1,17 @@
import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { McpServerStatusConfig } from '@/lib/mcp/types'
import type { McpServerStatusConfig, McpTool, StoredMcpTool } from '@/lib/mcp/types'
import { sanitizeForHttp, sanitizeHeaders } from '@/lib/mcp/utils'
const logger = createLogger('McpQueries')
export type { McpServerStatusConfig }
export type { McpServerStatusConfig, McpTool, StoredMcpTool }
export const mcpKeys = {
all: ['mcp'] as const,
servers: (workspaceId: string) => [...mcpKeys.all, 'servers', workspaceId] as const,
tools: (workspaceId: string) => [...mcpKeys.all, 'tools', workspaceId] as const,
storedTools: (workspaceId: string) => [...mcpKeys.all, 'stored', workspaceId] as const,
}
export interface McpServer {
@@ -32,7 +34,10 @@ export interface McpServer {
deletedAt?: string
}
export interface McpServerConfig {
/**
* Input for creating/updating an MCP server (distinct from McpServerConfig in types.ts)
*/
export interface McpServerInput {
name: string
transport: 'streamable-http' | 'stdio'
url?: string
@@ -41,17 +46,6 @@ export interface McpServerConfig {
enabled: boolean
}
export interface McpTool {
serverId: string
serverName: string
name: string
description?: string
inputSchema?: any
}
/**
* Fetch MCP servers for a workspace
*/
async function fetchMcpServers(workspaceId: string): Promise<McpServer[]> {
const response = await fetch(`/api/mcp/servers?workspaceId=${workspaceId}`)
@@ -68,23 +62,17 @@ async function fetchMcpServers(workspaceId: string): Promise<McpServer[]> {
return data.data?.servers || []
}
/**
* Hook to fetch MCP servers
*/
export function useMcpServers(workspaceId: string) {
return useQuery({
queryKey: mcpKeys.servers(workspaceId),
queryFn: () => fetchMcpServers(workspaceId),
enabled: !!workspaceId,
retry: false, // Don't retry on 404 (no servers configured)
staleTime: 60 * 1000, // 1 minute - servers don't change frequently
retry: false,
staleTime: 60 * 1000,
placeholderData: keepPreviousData,
})
}
/**
* Fetch MCP tools for a workspace
*/
async function fetchMcpTools(workspaceId: string, forceRefresh = false): Promise<McpTool[]> {
const params = new URLSearchParams({ workspaceId })
if (forceRefresh) {
@@ -93,7 +81,6 @@ async function fetchMcpTools(workspaceId: string, forceRefresh = false): Promise
const response = await fetch(`/api/mcp/tools/discover?${params.toString()}`)
// Treat 404 as "no tools available" - return empty array
if (response.status === 404) {
return []
}
@@ -107,26 +94,30 @@ async function fetchMcpTools(workspaceId: string, forceRefresh = false): Promise
return data.data?.tools || []
}
/**
* Hook to fetch MCP tools
*/
export function useMcpToolsQuery(workspaceId: string) {
return useQuery({
queryKey: mcpKeys.tools(workspaceId),
queryFn: () => fetchMcpTools(workspaceId),
enabled: !!workspaceId,
retry: false, // Don't retry on 404 (no tools available)
staleTime: 30 * 1000, // 30 seconds - tools can change when servers are added/removed
retry: false,
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}
/**
* Create MCP server mutation
*/
export function useForceRefreshMcpTools() {
const queryClient = useQueryClient()
return async (workspaceId: string) => {
const freshTools = await fetchMcpTools(workspaceId, true)
queryClient.setQueryData(mcpKeys.tools(workspaceId), freshTools)
return freshTools
}
}
interface CreateMcpServerParams {
workspaceId: string
config: McpServerConfig
config: McpServerInput
}
export function useCreateMcpServer() {
@@ -136,6 +127,8 @@ export function useCreateMcpServer() {
mutationFn: async ({ workspaceId, config }: CreateMcpServerParams) => {
const serverData = {
...config,
url: config.url ? sanitizeForHttp(config.url) : config.url,
headers: sanitizeHeaders(config.headers),
workspaceId,
}
@@ -204,9 +197,6 @@ export function useCreateMcpServer() {
})
}
/**
* Delete MCP server mutation
*/
interface DeleteMcpServerParams {
workspaceId: string
serverId: string
@@ -240,13 +230,10 @@ export function useDeleteMcpServer() {
})
}
/**
* Update MCP server mutation
*/
interface UpdateMcpServerParams {
workspaceId: string
serverId: string
updates: Partial<McpServerConfig & { enabled?: boolean }>
updates: Partial<McpServerInput>
}
export function useUpdateMcpServer() {
@@ -254,10 +241,16 @@ export function useUpdateMcpServer() {
return useMutation({
mutationFn: async ({ workspaceId, serverId, updates }: UpdateMcpServerParams) => {
const sanitizedUpdates = {
...updates,
url: updates.url ? sanitizeForHttp(updates.url) : updates.url,
headers: updates.headers ? sanitizeHeaders(updates.headers) : updates.headers,
}
const response = await fetch(`/api/mcp/servers/${serverId}?workspaceId=${workspaceId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
body: JSON.stringify(sanitizedUpdates),
})
const data = await response.json()
@@ -299,9 +292,6 @@ export function useUpdateMcpServer() {
})
}
/**
* Refresh MCP server mutation - re-discovers tools from the server
*/
interface RefreshMcpServerParams {
workspaceId: string
serverId: string
@@ -312,6 +302,8 @@ export interface RefreshMcpServerResult {
toolCount: number
lastConnected: string | null
error: string | null
workflowsUpdated: number
updatedWorkflowIds: string[]
}
export function useRefreshMcpServer() {
@@ -339,79 +331,14 @@ export function useRefreshMcpServer() {
return data.data
},
onSuccess: async (_data, variables) => {
queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) })
const freshTools = await fetchMcpTools(variables.workspaceId, true)
queryClient.setQueryData(mcpKeys.tools(variables.workspaceId), freshTools)
await queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) })
await queryClient.refetchQueries({ queryKey: mcpKeys.storedTools(variables.workspaceId) })
},
})
}
/**
* Test MCP server connection
*/
export interface McpServerTestParams {
name: string
transport: 'streamable-http' | 'stdio'
url?: string
headers?: Record<string, string>
timeout: number
workspaceId: string
}
export interface McpServerTestResult {
success: boolean
error?: string
tools?: Array<{ name: string; description?: string }>
}
export function useTestMcpServer() {
return useMutation({
mutationFn: async (params: McpServerTestParams): Promise<McpServerTestResult> => {
try {
const response = await fetch('/api/mcp/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
})
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data.error || 'Failed to test connection',
}
}
return {
success: true,
tools: data.tools || [],
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Connection test failed',
}
}
},
})
}
/**
* Stored MCP tool from workflow state
*/
export interface StoredMcpTool {
workflowId: string
workflowName: string
serverId: string
serverUrl?: string
toolName: string
schema?: Record<string, unknown>
}
/**
* Fetch stored MCP tools from all workflows in the workspace
*/
async function fetchStoredMcpTools(workspaceId: string): Promise<StoredMcpTool[]> {
const response = await fetch(`/api/mcp/tools/stored?workspaceId=${workspaceId}`)
@@ -424,14 +351,11 @@ async function fetchStoredMcpTools(workspaceId: string): Promise<StoredMcpTool[]
return data.data?.tools || []
}
/**
* Hook to fetch stored MCP tools from all workflows
*/
export function useStoredMcpTools(workspaceId: string) {
return useQuery({
queryKey: [...mcpKeys.all, workspaceId, 'stored'],
queryKey: mcpKeys.storedTools(workspaceId),
queryFn: () => fetchStoredMcpTools(workspaceId),
enabled: !!workspaceId,
staleTime: 60 * 1000, // 1 minute - workflows don't change frequently
staleTime: 60 * 1000,
})
}

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'
type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
type AlertRuleType =
| 'consecutive_failures'

View File

@@ -0,0 +1,466 @@
import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
const logger = createLogger('WorkflowMcpServerQueries')
/**
* Deployed Workflow type for adding to MCP servers
*/
export interface DeployedWorkflow {
id: string
name: string
description: string | null
isDeployed: boolean
}
/**
* 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
createdAt: string
updatedAt: string
toolCount?: number
toolNames?: string[]
}
export interface WorkflowMcpTool {
id: string
serverId: string
workflowId: string
toolName: string
toolDescription: string | null
parameterSchema: Record<string, unknown>
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),
})
},
})
}
/**
* 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>
}
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),
})
},
})
}
/**
* Fetch deployed workflows for a workspace
*/
async function fetchDeployedWorkflows(workspaceId: string): Promise<DeployedWorkflow[]> {
const response = await fetch(`/api/workflows?workspaceId=${workspaceId}`)
if (!response.ok) {
throw new Error('Failed to fetch workflows')
}
const { data }: { data: any[] } = await response.json()
return data
.filter((w) => w.isDeployed)
.map((w) => ({
id: w.id,
name: w.name,
description: w.description,
isDeployed: w.isDeployed,
}))
}
/**
* Hook to fetch deployed workflows for a workspace
*/
export function useDeployedWorkflows(workspaceId: string) {
return useQuery({
queryKey: ['deployed-workflows', workspaceId],
queryFn: () => fetchDeployedWorkflows(workspaceId),
enabled: !!workspaceId,
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}

View File

@@ -3,6 +3,7 @@
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import type { McpTransport } from '@/lib/mcp/types'
import { sanitizeForHttp, sanitizeHeaders } from '@/lib/mcp/utils'
const logger = createLogger('useMcpServerTest')
@@ -68,13 +69,8 @@ export function useMcpServerTest() {
try {
const cleanConfig = {
...config,
headers: config.headers
? Object.fromEntries(
Object.entries(config.headers).filter(
([key, value]) => key.trim() !== '' && value.trim() !== ''
)
)
: {},
url: config.url ? sanitizeForHttp(config.url) : config.url,
headers: sanitizeHeaders(config.headers) || {},
}
const response = await fetch('/api/mcp/servers/test-connection', {

View File

@@ -95,45 +95,3 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult {
getToolsByServer,
}
}
export function useMcpToolExecution(workspaceId: string) {
const executeTool = useCallback(
async (serverId: string, toolName: string, args: Record<string, any>) => {
if (!workspaceId) {
throw new Error('workspaceId is required for MCP tool execution')
}
logger.info(
`Executing MCP tool ${toolName} on server ${serverId} in workspace ${workspaceId}`
)
const response = await fetch('/api/mcp/tools/execute', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
serverId,
toolName,
arguments: args,
workspaceId,
}),
})
if (!response.ok) {
throw new Error(`Tool execution failed: ${response.status} ${response.statusText}`)
}
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Tool execution failed')
}
return result.data
},
[workspaceId]
)
return { executeTool }
}

View File

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

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'
triggerType: 'manual' | 'api' | 'webhook' | 'schedule' | 'chat' | 'mcp'
executionId: string
requestId: string

View File

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

View File

@@ -51,7 +51,7 @@ export interface ExecutionEnvironment {
workspaceId: string
}
export const ALL_TRIGGER_TYPES = ['api', 'webhook', 'schedule', 'manual', 'chat'] as const
export const ALL_TRIGGER_TYPES = ['api', 'webhook', 'schedule', 'manual', 'chat', 'mcp'] as const
export type TriggerType = (typeof ALL_TRIGGER_TYPES)[number]
export interface ExecutionTrigger {

View File

@@ -32,7 +32,7 @@ const logger = createLogger('McpService')
class McpService {
private cacheAdapter: McpCacheStorageAdapter
private readonly cacheTimeout = MCP_CONSTANTS.CACHE_TIMEOUT // 5 minutes
private readonly cacheTimeout = MCP_CONSTANTS.CACHE_TIMEOUT
constructor() {
this.cacheAdapter = createMcpCacheAdapter()

View File

@@ -7,8 +7,8 @@ import {
isToolUnavailable,
type McpToolIssue,
type ServerState,
type StoredMcpTool,
} from './tool-validation'
import type { StoredMcpToolReference } from './types'
describe('hasSchemaChanged', () => {
it.concurrent('returns false when both schemas are undefined', () => {
@@ -24,76 +24,78 @@ describe('hasSchemaChanged', () => {
})
it.concurrent('returns false for identical schemas', () => {
const schema = { type: 'object', properties: { name: { type: 'string' } } }
const schema = { type: 'object' as const, properties: { name: { type: 'string' } } }
expect(hasSchemaChanged(schema, { ...schema })).toBe(false)
})
it.concurrent('returns false when only description differs', () => {
const stored = {
type: 'object',
type: 'object' as const,
properties: { name: { type: 'string' } },
description: 'Old description',
}
const server = {
type: 'object',
type: 'object' as const,
properties: { name: { type: 'string' } },
description: 'New description',
}
expect(hasSchemaChanged(stored, server)).toBe(false)
})
it.concurrent('returns true when type differs', () => {
const stored = { type: 'object', properties: {} }
const server = { type: 'array', properties: {} }
expect(hasSchemaChanged(stored, server)).toBe(true)
})
it.concurrent('returns true when properties differ', () => {
const stored = { type: 'object', properties: { name: { type: 'string' } } }
const server = { type: 'object', properties: { id: { type: 'number' } } }
const stored = { type: 'object' as const, properties: { name: { type: 'string' } } }
const server = { type: 'object' as const, properties: { id: { type: 'number' } } }
expect(hasSchemaChanged(stored, server)).toBe(true)
})
it.concurrent('returns true when required fields differ', () => {
const stored = { type: 'object', properties: {}, required: ['name'] }
const server = { type: 'object', properties: {}, required: ['id'] }
const stored = { type: 'object' as const, properties: {}, required: ['name'] }
const server = { type: 'object' as const, properties: {}, required: ['id'] }
expect(hasSchemaChanged(stored, server)).toBe(true)
})
it.concurrent('returns false for deep equal schemas with different key order', () => {
const stored = { type: 'object', properties: { a: 1, b: 2 } }
const server = { properties: { b: 2, a: 1 }, type: 'object' }
const stored = { type: 'object' as const, properties: { a: 1, b: 2 } }
const server = { properties: { b: 2, a: 1 }, type: 'object' as const }
expect(hasSchemaChanged(stored, server)).toBe(false)
})
it.concurrent('returns true when nested properties differ', () => {
const stored = {
type: 'object',
type: 'object' as const,
properties: { config: { type: 'object', properties: { enabled: { type: 'boolean' } } } },
}
const server = {
type: 'object',
type: 'object' as const,
properties: { config: { type: 'object', properties: { enabled: { type: 'string' } } } },
}
expect(hasSchemaChanged(stored, server)).toBe(true)
})
it.concurrent('returns true when additional properties setting differs', () => {
const stored = { type: 'object', additionalProperties: true }
const server = { type: 'object', additionalProperties: false }
const stored = { type: 'object' as const, additionalProperties: true }
const server = { type: 'object' as const, additionalProperties: false }
expect(hasSchemaChanged(stored, server)).toBe(true)
})
it.concurrent('ignores description at property level', () => {
const stored = { type: 'object', properties: { name: { type: 'string', description: 'Old' } } }
const server = { type: 'object', properties: { name: { type: 'string', description: 'New' } } }
const stored = {
type: 'object' as const,
properties: { name: { type: 'string', description: 'Old' } },
}
const server = {
type: 'object' as const,
properties: { name: { type: 'string', description: 'New' } },
}
// Only top-level description is ignored, not nested ones
expect(hasSchemaChanged(stored, server)).toBe(true)
})
})
describe('getMcpToolIssue', () => {
const createStoredTool = (overrides?: Partial<StoredMcpTool>): StoredMcpTool => ({
const createStoredTool = (
overrides?: Partial<StoredMcpToolReference>
): StoredMcpToolReference => ({
serverId: 'server-1',
serverUrl: 'https://api.example.com/mcp',
toolName: 'test-tool',
@@ -191,7 +193,7 @@ describe('getMcpToolIssue', () => {
expect(result).toEqual({
type: 'url_changed',
message: 'Server URL changed - tools may be different',
message: 'Server URL changed',
})
})
@@ -298,7 +300,7 @@ describe('getMcpToolIssue', () => {
})
it.concurrent('returns null when schemas match exactly', () => {
const schema = { type: 'object', properties: { name: { type: 'string' } } }
const schema = { type: 'object' as const, properties: { name: { type: 'string' } } }
const storedTool = createStoredTool({ schema })
const servers = [createServerState()]
const tools = [createDiscoveredTool({ inputSchema: schema })]

View File

@@ -1,12 +1,6 @@
/**
* MCP Tool Validation
*
* Shared logic for detecting issues with MCP tools across the platform.
* Used by both tool-input.tsx (workflow context) and MCP modal (workspace context).
*/
import isEqual from 'lodash/isEqual'
import omit from 'lodash/omit'
import type { McpToolSchema, StoredMcpToolReference } from '@/lib/mcp/types'
export type McpToolIssueType =
| 'server_not_found'
@@ -20,13 +14,6 @@ export interface McpToolIssue {
message: string
}
export interface StoredMcpTool {
serverId: string
serverUrl?: string
toolName: string
schema?: Record<string, unknown>
}
export interface ServerState {
id: string
url?: string
@@ -37,17 +24,12 @@ export interface ServerState {
export interface DiscoveredTool {
serverId: string
name: string
inputSchema?: Record<string, unknown>
inputSchema?: McpToolSchema
}
/**
* Compares two schemas to detect changes.
* Uses lodash isEqual for deep, key-order-independent comparison.
* Ignores description field which may be backfilled.
*/
export function hasSchemaChanged(
storedSchema: Record<string, unknown> | undefined,
serverSchema: Record<string, unknown> | undefined
storedSchema: McpToolSchema | undefined,
serverSchema: McpToolSchema | undefined
): boolean {
if (!storedSchema || !serverSchema) return false
@@ -57,23 +39,18 @@ export function hasSchemaChanged(
return !isEqual(storedWithoutDesc, serverWithoutDesc)
}
/**
* Detects issues with a stored MCP tool by comparing against current server/tool state.
*/
export function getMcpToolIssue(
storedTool: StoredMcpTool,
storedTool: StoredMcpToolReference,
servers: ServerState[],
discoveredTools: DiscoveredTool[]
): McpToolIssue | null {
const { serverId, serverUrl, toolName, schema } = storedTool
// Check server exists
const server = servers.find((s) => s.id === serverId)
if (!server) {
return { type: 'server_not_found', message: 'Server not found' }
}
// Check server connection status
if (server.connectionStatus === 'error') {
return { type: 'server_error', message: server.lastError || 'Server connection error' }
}
@@ -81,18 +58,15 @@ export function getMcpToolIssue(
return { type: 'server_error', message: 'Server not connected' }
}
// Check server URL changed (if we have stored URL)
if (serverUrl && server.url && serverUrl !== server.url) {
return { type: 'url_changed', message: 'Server URL changed - tools may be different' }
return { type: 'url_changed', message: 'Server URL changed' }
}
// Check tool exists on server
const serverTool = discoveredTools.find((t) => t.serverId === serverId && t.name === toolName)
if (!serverTool) {
return { type: 'tool_not_found', message: 'Tool not found on server' }
}
// Check schema changed
if (schema && serverTool.inputSchema) {
if (hasSchemaChanged(schema, serverTool.inputSchema)) {
return { type: 'schema_changed', message: 'Tool schema changed' }
@@ -102,13 +76,9 @@ export function getMcpToolIssue(
return null
}
/**
* Returns a user-friendly label for the issue badge
*/
export function getIssueBadgeLabel(issue: McpToolIssue): string {
switch (issue.type) {
case 'schema_changed':
return 'stale'
case 'url_changed':
return 'stale'
default:
@@ -116,9 +86,16 @@ export function getIssueBadgeLabel(issue: McpToolIssue): string {
}
}
/**
* Checks if an issue means the tool cannot be used (vs just being stale)
*/
export function getIssueBadgeVariant(issue: McpToolIssue): 'amber' | 'red' {
switch (issue.type) {
case 'schema_changed':
case 'url_changed':
return 'amber'
default:
return 'red'
}
}
export function isToolUnavailable(issue: McpToolIssue | null): boolean {
if (!issue) return false
return (

View File

@@ -81,7 +81,7 @@ describe('McpError', () => {
const error = new McpError('Multiple validation errors', 400, complexData)
expect(error.data).toEqual(complexData)
expect(error.data.errors).toHaveLength(2)
expect((error.data as typeof complexData).errors).toHaveLength(2)
})
it.concurrent('handles array as data', () => {

View File

@@ -1,9 +1,7 @@
/**
* Model Context Protocol (MCP) Types
* MCP Types - for connecting to external MCP servers
*/
// MCP Transport Types
// Modern MCP uses Streamable HTTP which handles both HTTP POST and SSE responses
export type McpTransport = 'streamable-http'
export interface McpServerStatusConfig {
@@ -16,12 +14,8 @@ export interface McpServerConfig {
name: string
description?: string
transport: McpTransport
// HTTP/SSE transport config
url?: string
headers?: Record<string, string>
// Common config
timeout?: number
retries?: number
enabled?: boolean
@@ -30,31 +24,29 @@ export interface McpServerConfig {
updatedAt?: string
}
// Version negotiation support
export interface McpVersionInfo {
supported: string[] // List of supported protocol versions
preferred: string // Preferred version to use
supported: string[]
preferred: string
}
// Security and Consent Framework
export interface McpConsentRequest {
type: 'tool_execution' | 'resource_access' | 'data_sharing'
context: {
serverId: string
serverName: string
action: string // Tool name or resource path
description?: string // Human-readable description
dataAccess?: string[] // Types of data being accessed
sideEffects?: string[] // Potential side effects
action: string
description?: string
dataAccess?: string[]
sideEffects?: string[]
}
expires?: number // Consent expiration timestamp
expires?: number
}
export interface McpConsentResponse {
granted: boolean
expires?: number
restrictions?: Record<string, any> // Any access restrictions
auditId?: string // For audit trail
restrictions?: Record<string, unknown>
auditId?: string
}
export interface McpSecurityPolicy {
@@ -65,15 +57,20 @@ export interface McpSecurityPolicy {
auditLevel: 'none' | 'basic' | 'detailed'
}
// MCP Tool Types
/**
* JSON Schema for tool input parameters.
* Aligns with MCP SDK's Tool.inputSchema structure.
*/
export interface McpToolSchema {
type: string
properties?: Record<string, any>
type: 'object'
properties?: Record<string, unknown>
required?: string[]
additionalProperties?: boolean
description?: string
}
/**
* MCP Tool with server context.
* Extends the SDK's Tool type with app-specific server tracking.
*/
export interface McpTool {
name: string
description?: string
@@ -84,10 +81,9 @@ export interface McpTool {
export interface McpToolCall {
name: string
arguments: Record<string, any>
arguments: Record<string, unknown>
}
// Standard MCP protocol response format
export interface McpToolResult {
content?: Array<{
type: 'text' | 'image' | 'resource'
@@ -96,11 +92,9 @@ export interface McpToolResult {
mimeType?: string
}>
isError?: boolean
// Allow additional fields that some MCP servers return
[key: string]: any
[key: string]: unknown
}
// Connection and Error Types
export interface McpConnectionStatus {
connected: boolean
lastConnected?: Date
@@ -111,7 +105,7 @@ export class McpError extends Error {
constructor(
message: string,
public code?: number,
public data?: any
public data?: unknown
) {
super(message)
this.name = 'McpError'
@@ -138,8 +132,7 @@ export interface McpServerSummary {
error?: string
}
// API Response Types
export interface McpApiResponse<T = any> {
export interface McpApiResponse<T = unknown> {
success: boolean
data?: T
error?: string
@@ -150,3 +143,23 @@ export interface McpToolDiscoveryResponse {
totalCount: number
byServer: Record<string, number>
}
/**
* MCP tool reference stored in workflow blocks (for validation).
* Minimal version used for comparing against discovered tools.
*/
export interface StoredMcpToolReference {
serverId: string
serverUrl?: string
toolName: string
schema?: McpToolSchema
}
/**
* Full stored MCP tool with workflow context (for API responses).
* Extended version that includes which workflow the tool is used in.
*/
export interface StoredMcpTool extends StoredMcpToolReference {
workflowId: string
workflowName: string
}

View File

@@ -1,387 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}))
import { validateMcpServerUrl } from './url-validator'
describe('validateMcpServerUrl', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Basic URL validation', () => {
it.concurrent('accepts valid HTTPS URL', () => {
const result = validateMcpServerUrl('https://api.example.com/mcp')
expect(result.isValid).toBe(true)
expect(result.normalizedUrl).toBe('https://api.example.com/mcp')
})
it.concurrent('accepts valid HTTP URL', () => {
const result = validateMcpServerUrl('http://api.example.com/mcp')
expect(result.isValid).toBe(true)
expect(result.normalizedUrl).toBe('http://api.example.com/mcp')
})
it.concurrent('rejects empty string', () => {
const result = validateMcpServerUrl('')
expect(result.isValid).toBe(false)
expect(result.error).toBe('URL is required and must be a string')
})
it.concurrent('rejects null', () => {
const result = validateMcpServerUrl(null as any)
expect(result.isValid).toBe(false)
expect(result.error).toBe('URL is required and must be a string')
})
it.concurrent('rejects undefined', () => {
const result = validateMcpServerUrl(undefined as any)
expect(result.isValid).toBe(false)
expect(result.error).toBe('URL is required and must be a string')
})
it.concurrent('rejects non-string values', () => {
const result = validateMcpServerUrl(123 as any)
expect(result.isValid).toBe(false)
expect(result.error).toBe('URL is required and must be a string')
})
it.concurrent('rejects invalid URL format', () => {
const result = validateMcpServerUrl('not-a-valid-url')
expect(result.isValid).toBe(false)
expect(result.error).toBe('Invalid URL format')
})
it.concurrent('trims whitespace from URL', () => {
const result = validateMcpServerUrl(' https://api.example.com/mcp ')
expect(result.isValid).toBe(true)
expect(result.normalizedUrl).toBe('https://api.example.com/mcp')
})
})
describe('Protocol validation', () => {
it.concurrent('rejects FTP protocol', () => {
const result = validateMcpServerUrl('ftp://files.example.com/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toBe('Only HTTP and HTTPS protocols are allowed')
})
it.concurrent('rejects file protocol', () => {
const result = validateMcpServerUrl('file:///etc/passwd')
expect(result.isValid).toBe(false)
expect(result.error).toBe('Only HTTP and HTTPS protocols are allowed')
})
it.concurrent('rejects javascript protocol', () => {
const result = validateMcpServerUrl('javascript:alert(1)')
expect(result.isValid).toBe(false)
expect(result.error).toBe('Only HTTP and HTTPS protocols are allowed')
})
it.concurrent('rejects data protocol', () => {
const result = validateMcpServerUrl('data:text/html,<script>alert(1)</script>')
expect(result.isValid).toBe(false)
expect(result.error).toBe('Only HTTP and HTTPS protocols are allowed')
})
it.concurrent('rejects ssh protocol', () => {
const result = validateMcpServerUrl('ssh://user@host.com')
expect(result.isValid).toBe(false)
expect(result.error).toBe('Only HTTP and HTTPS protocols are allowed')
})
})
describe('SSRF Protection - Blocked Hostnames', () => {
it.concurrent('rejects localhost', () => {
const result = validateMcpServerUrl('https://localhost/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toContain('localhost')
expect(result.error).toContain('not allowed for security reasons')
})
it.concurrent('rejects Google Cloud metadata endpoint', () => {
const result = validateMcpServerUrl('http://metadata.google.internal/computeMetadata/v1/')
expect(result.isValid).toBe(false)
expect(result.error).toContain('metadata.google.internal')
})
it.concurrent('rejects Azure metadata endpoint', () => {
const result = validateMcpServerUrl('http://metadata.azure.com/metadata/instance')
expect(result.isValid).toBe(false)
expect(result.error).toContain('metadata.azure.com')
})
it.concurrent('rejects AWS metadata IP', () => {
const result = validateMcpServerUrl('http://169.254.169.254/latest/meta-data/')
expect(result.isValid).toBe(false)
expect(result.error).toContain('169.254.169.254')
})
it.concurrent('rejects consul service discovery', () => {
const result = validateMcpServerUrl('http://consul/v1/agent/services')
expect(result.isValid).toBe(false)
expect(result.error).toContain('consul')
})
it.concurrent('rejects etcd service discovery', () => {
const result = validateMcpServerUrl('http://etcd/v2/keys/')
expect(result.isValid).toBe(false)
expect(result.error).toContain('etcd')
})
})
describe('SSRF Protection - Private IPv4 Ranges', () => {
it.concurrent('rejects loopback address 127.0.0.1', () => {
const result = validateMcpServerUrl('http://127.0.0.1/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toContain('Private IP addresses are not allowed')
})
it.concurrent('rejects loopback address 127.0.0.100', () => {
const result = validateMcpServerUrl('http://127.0.0.100/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toContain('Private IP addresses are not allowed')
})
it.concurrent('rejects private class A (10.x.x.x)', () => {
const result = validateMcpServerUrl('http://10.0.0.1/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toContain('Private IP addresses are not allowed')
})
it.concurrent('rejects private class A (10.255.255.255)', () => {
const result = validateMcpServerUrl('http://10.255.255.255/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toContain('Private IP addresses are not allowed')
})
it.concurrent('rejects private class B (172.16.x.x)', () => {
const result = validateMcpServerUrl('http://172.16.0.1/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toContain('Private IP addresses are not allowed')
})
it.concurrent('rejects private class B (172.31.255.255)', () => {
const result = validateMcpServerUrl('http://172.31.255.255/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toContain('Private IP addresses are not allowed')
})
it.concurrent('rejects private class C (192.168.x.x)', () => {
const result = validateMcpServerUrl('http://192.168.0.1/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toContain('Private IP addresses are not allowed')
})
it.concurrent('rejects private class C (192.168.255.255)', () => {
const result = validateMcpServerUrl('http://192.168.255.255/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toContain('Private IP addresses are not allowed')
})
it.concurrent('rejects link-local address (169.254.x.x)', () => {
const result = validateMcpServerUrl('http://169.254.1.1/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toContain('Private IP addresses are not allowed')
})
it.concurrent('rejects invalid zero range (0.x.x.x)', () => {
const result = validateMcpServerUrl('http://0.0.0.0/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toContain('Private IP addresses are not allowed')
})
it.concurrent('accepts valid public IP', () => {
const result = validateMcpServerUrl('http://8.8.8.8/mcp')
expect(result.isValid).toBe(true)
})
it.concurrent('accepts public IP in non-private range', () => {
const result = validateMcpServerUrl('http://203.0.113.50/mcp')
expect(result.isValid).toBe(true)
})
})
/**
* Note: IPv6 private range validation has a known issue where the brackets
* are not stripped before testing against private ranges. The isIPv6 function
* strips brackets, but the range test still uses the original bracketed hostname.
* These tests document the current (buggy) behavior rather than expected behavior.
*/
describe('SSRF Protection - Private IPv6 Ranges', () => {
it.concurrent('identifies IPv6 addresses (isIPv6 works correctly)', () => {
// The validator correctly identifies these as IPv6 addresses
// but fails to block them due to bracket handling issue
const result = validateMcpServerUrl('http://[::1]/mcp')
// Current behavior: passes validation (should ideally be blocked)
expect(result.isValid).toBe(true)
})
it.concurrent('handles IPv4-mapped IPv6 addresses', () => {
const result = validateMcpServerUrl('http://[::ffff:192.168.1.1]/mcp')
// Current behavior: passes validation
expect(result.isValid).toBe(true)
})
it.concurrent('handles unique local addresses', () => {
const result = validateMcpServerUrl('http://[fc00::1]/mcp')
// Current behavior: passes validation
expect(result.isValid).toBe(true)
})
it.concurrent('handles link-local IPv6 addresses', () => {
const result = validateMcpServerUrl('http://[fe80::1]/mcp')
// Current behavior: passes validation
expect(result.isValid).toBe(true)
})
})
describe('SSRF Protection - Blocked Ports', () => {
it.concurrent('rejects SSH port (22)', () => {
const result = validateMcpServerUrl('https://api.example.com:22/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toBe('Port 22 is not allowed for security reasons')
})
it.concurrent('rejects Telnet port (23)', () => {
const result = validateMcpServerUrl('https://api.example.com:23/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toBe('Port 23 is not allowed for security reasons')
})
it.concurrent('rejects SMTP port (25)', () => {
const result = validateMcpServerUrl('https://api.example.com:25/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toBe('Port 25 is not allowed for security reasons')
})
it.concurrent('rejects DNS port (53)', () => {
const result = validateMcpServerUrl('https://api.example.com:53/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toBe('Port 53 is not allowed for security reasons')
})
it.concurrent('rejects MySQL port (3306)', () => {
const result = validateMcpServerUrl('https://api.example.com:3306/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toBe('Port 3306 is not allowed for security reasons')
})
it.concurrent('rejects PostgreSQL port (5432)', () => {
const result = validateMcpServerUrl('https://api.example.com:5432/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toBe('Port 5432 is not allowed for security reasons')
})
it.concurrent('rejects Redis port (6379)', () => {
const result = validateMcpServerUrl('https://api.example.com:6379/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toBe('Port 6379 is not allowed for security reasons')
})
it.concurrent('rejects MongoDB port (27017)', () => {
const result = validateMcpServerUrl('https://api.example.com:27017/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toBe('Port 27017 is not allowed for security reasons')
})
it.concurrent('rejects Elasticsearch port (9200)', () => {
const result = validateMcpServerUrl('https://api.example.com:9200/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toBe('Port 9200 is not allowed for security reasons')
})
it.concurrent('accepts common web ports (8080)', () => {
const result = validateMcpServerUrl('https://api.example.com:8080/mcp')
expect(result.isValid).toBe(true)
})
it.concurrent('accepts common web ports (3000)', () => {
const result = validateMcpServerUrl('https://api.example.com:3000/mcp')
expect(result.isValid).toBe(true)
})
it.concurrent('accepts default HTTPS port (443)', () => {
const result = validateMcpServerUrl('https://api.example.com:443/mcp')
expect(result.isValid).toBe(true)
})
it.concurrent('accepts default HTTP port (80)', () => {
const result = validateMcpServerUrl('http://api.example.com:80/mcp')
expect(result.isValid).toBe(true)
})
})
describe('Protocol-Port Mismatch Detection', () => {
it.concurrent('rejects HTTPS on port 80', () => {
const result = validateMcpServerUrl('https://api.example.com:80/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toBe('HTTPS URLs should not use port 80')
})
it.concurrent('rejects HTTP on port 443', () => {
const result = validateMcpServerUrl('http://api.example.com:443/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toBe('HTTP URLs should not use port 443')
})
})
describe('URL Length Validation', () => {
it.concurrent('accepts URL within length limit', () => {
const result = validateMcpServerUrl('https://api.example.com/mcp')
expect(result.isValid).toBe(true)
})
it.concurrent('rejects URL exceeding 2048 characters', () => {
const longPath = 'a'.repeat(2100)
const result = validateMcpServerUrl(`https://api.example.com/${longPath}`)
expect(result.isValid).toBe(false)
expect(result.error).toBe('URL is too long (maximum 2048 characters)')
})
})
describe('Edge Cases', () => {
it.concurrent('handles URL with query parameters', () => {
const result = validateMcpServerUrl('https://api.example.com/mcp?token=abc123')
expect(result.isValid).toBe(true)
})
it.concurrent('handles URL with fragments', () => {
const result = validateMcpServerUrl('https://api.example.com/mcp#section')
expect(result.isValid).toBe(true)
})
it.concurrent('handles URL with username:password (basic auth)', () => {
const result = validateMcpServerUrl('https://user:pass@api.example.com/mcp')
expect(result.isValid).toBe(true)
})
it.concurrent('handles URL with subdomain', () => {
const result = validateMcpServerUrl('https://mcp.api.example.com/v1')
expect(result.isValid).toBe(true)
})
it.concurrent('handles URL with multiple path segments', () => {
const result = validateMcpServerUrl('https://api.example.com/v1/mcp/tools')
expect(result.isValid).toBe(true)
})
it.concurrent('is case insensitive for hostname', () => {
const result = validateMcpServerUrl('https://API.EXAMPLE.COM/mcp')
expect(result.isValid).toBe(true)
})
it.concurrent('rejects localhost regardless of case', () => {
const result = validateMcpServerUrl('https://LOCALHOST/mcp')
expect(result.isValid).toBe(false)
expect(result.error).toContain('not allowed for security reasons')
})
})
})

View File

@@ -1,178 +0,0 @@
/**
* URL Validator for MCP Servers
*
* Provides SSRF (Server-Side Request Forgery) protection by validating
* MCP server URLs against common attack patterns and dangerous destinations.
*/
import { createLogger } from '@sim/logger'
const logger = createLogger('McpUrlValidator')
// Blocked IPv4 ranges
const PRIVATE_IP_RANGES = [
/^127\./, // Loopback (127.0.0.0/8)
/^10\./, // Private class A (10.0.0.0/8)
/^172\.(1[6-9]|2[0-9]|3[01])\./, // Private class B (172.16.0.0/12)
/^192\.168\./, // Private class C (192.168.0.0/16)
/^169\.254\./, // Link-local (169.254.0.0/16)
/^0\./, // Invalid range
]
// Blocked IPv6 ranges
const PRIVATE_IPV6_RANGES = [
/^::1$/, // Localhost
/^::ffff:/, // IPv4-mapped IPv6
/^fc00:/, // Unique local (fc00::/7)
/^fd00:/, // Unique local (fd00::/8)
/^fe80:/, // Link-local (fe80::/10)
]
// Blocked hostnames - SSRF protection
const BLOCKED_HOSTNAMES = [
'localhost',
// Cloud metadata endpoints
'metadata.google.internal', // Google Cloud metadata
'metadata.gce.internal', // Google Compute Engine metadata (legacy)
'169.254.169.254', // AWS/Azure/GCP metadata service IP
'metadata.azure.com', // Azure Instance Metadata Service
'instance-data.ec2.internal', // AWS EC2 instance metadata (internal)
// Service discovery endpoints
'consul', // HashiCorp Consul
'etcd', // etcd key-value store
]
// Blocked ports
const BLOCKED_PORTS = [
22, // SSH
23, // Telnet
25, // SMTP
53, // DNS
110, // POP3
143, // IMAP
993, // IMAPS
995, // POP3S
1433, // SQL Server
1521, // Oracle
3306, // MySQL
5432, // PostgreSQL
6379, // Redis
9200, // Elasticsearch
27017, // MongoDB
]
export interface UrlValidationResult {
isValid: boolean
error?: string
normalizedUrl?: string
}
export function validateMcpServerUrl(urlString: string): UrlValidationResult {
if (!urlString || typeof urlString !== 'string') {
return {
isValid: false,
error: 'URL is required and must be a string',
}
}
let url: URL
try {
url = new URL(urlString.trim())
} catch (error) {
return {
isValid: false,
error: 'Invalid URL format',
}
}
if (!['http:', 'https:'].includes(url.protocol)) {
return {
isValid: false,
error: 'Only HTTP and HTTPS protocols are allowed',
}
}
const hostname = url.hostname.toLowerCase()
if (BLOCKED_HOSTNAMES.includes(hostname)) {
return {
isValid: false,
error: `Hostname '${hostname}' is not allowed for security reasons`,
}
}
if (isIPv4(hostname)) {
for (const range of PRIVATE_IP_RANGES) {
if (range.test(hostname)) {
return {
isValid: false,
error: `Private IP addresses are not allowed: ${hostname}`,
}
}
}
}
if (isIPv6(hostname)) {
for (const range of PRIVATE_IPV6_RANGES) {
if (range.test(hostname)) {
return {
isValid: false,
error: `Private IPv6 addresses are not allowed: ${hostname}`,
}
}
}
}
if (url.port) {
const port = Number.parseInt(url.port, 10)
if (BLOCKED_PORTS.includes(port)) {
return {
isValid: false,
error: `Port ${port} is not allowed for security reasons`,
}
}
}
if (url.toString().length > 2048) {
return {
isValid: false,
error: 'URL is too long (maximum 2048 characters)',
}
}
if (url.protocol === 'https:' && url.port === '80') {
return {
isValid: false,
error: 'HTTPS URLs should not use port 80',
}
}
if (url.protocol === 'http:' && url.port === '443') {
return {
isValid: false,
error: 'HTTP URLs should not use port 443',
}
}
logger.debug(`Validated MCP server URL: ${hostname}`)
return {
isValid: true,
normalizedUrl: url.toString(),
}
}
function isIPv4(hostname: string): boolean {
const ipv4Regex =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
return ipv4Regex.test(hostname)
}
function isIPv6(hostname: string): boolean {
const cleanHostname = hostname.replace(/^\[|\]$/g, '')
const ipv6Regex =
/^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::$|^::1$|^(?:[0-9a-fA-F]{1,4}:)*::[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4})*$/
return ipv6Regex.test(cleanHostname)
}

View File

@@ -7,13 +7,44 @@ import { isMcpTool, MCP } from '@/executor/constants'
*/
export const MCP_CONSTANTS = {
EXECUTION_TIMEOUT: 60000,
CACHE_TIMEOUT: 5 * 60 * 1000, // 5 minutes
CACHE_TIMEOUT: 5 * 60 * 1000,
DEFAULT_RETRIES: 3,
DEFAULT_CONNECTION_TIMEOUT: 30000,
MAX_CACHE_SIZE: 1000,
MAX_CONSECUTIVE_FAILURES: 3,
} as const
/**
* Core MCP tool parameter keys that are metadata, not user-entered test values.
* These should be preserved when cleaning up params during schema updates.
*/
export const MCP_TOOL_CORE_PARAMS = new Set(['serverId', 'serverUrl', 'toolName', 'serverName'])
/**
* Sanitizes a string by removing invisible Unicode characters that cause HTTP header errors.
* Handles characters like U+2028 (Line Separator) that can be introduced via copy-paste.
*/
export function sanitizeForHttp(value: string): string {
return value
.replace(/[\u2028\u2029\u200B-\u200D\uFEFF]/g, '')
.replace(/[\x00-\x1F\x7F]/g, '')
.trim()
}
/**
* Sanitizes all header key-value pairs for HTTP usage.
*/
export function sanitizeHeaders(
headers: Record<string, string> | undefined
): Record<string, string> | undefined {
if (!headers) return headers
return Object.fromEntries(
Object.entries(headers)
.map(([key, value]) => [sanitizeForHttp(key), sanitizeForHttp(value)])
.filter(([key, value]) => key !== '' && value !== '')
)
}
/**
* Client-safe MCP constants
*/

View File

@@ -0,0 +1,106 @@
import { db, workflowMcpTool } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
import { extractInputFormatFromBlocks, generateToolInputSchema } from './workflow-tool-schema'
const logger = createLogger('WorkflowMcpSync')
/**
* Generate MCP tool parameter schema from workflow blocks
*/
function generateSchemaFromBlocks(blocks: Record<string, unknown>): Record<string, unknown> {
const inputFormat = extractInputFormatFromBlocks(blocks)
if (!inputFormat || inputFormat.length === 0) {
return { type: 'object', properties: {} }
}
return generateToolInputSchema(inputFormat) as unknown as Record<string, unknown>
}
interface SyncOptions {
workflowId: string
requestId: string
/** If provided, use this state instead of loading from DB */
state?: { blocks?: Record<string, unknown> }
/** Context for logging (e.g., 'deploy', 'revert', 'activate') */
context?: string
}
/**
* Sync MCP tools for a workflow with the latest parameter schema.
* - If the workflow has no start block, removes all MCP tools
* - Otherwise, updates all MCP tools with the current schema
*
* @param options.workflowId - The workflow ID to sync
* @param options.requestId - Request ID for logging
* @param options.state - Optional workflow state (if not provided, loads from DB)
* @param options.context - Optional context for log messages
*/
export async function syncMcpToolsForWorkflow(options: SyncOptions): Promise<void> {
const { workflowId, requestId, state, context = 'sync' } = options
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
}
// Get workflow state (from param or load from DB)
let workflowState: { blocks?: Record<string, unknown> } | null = state ?? null
if (!workflowState) {
workflowState = await loadWorkflowFromNormalizedTables(workflowId)
}
// Check if workflow has a valid start block
if (!hasValidStartBlockInState(workflowState)) {
await db.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId))
logger.info(
`[${requestId}] Removed ${tools.length} MCP tool(s) - workflow has no start block (${context}): ${workflowId}`
)
return
}
// Generate and update parameter schema
const parameterSchema = workflowState?.blocks
? generateSchemaFromBlocks(workflowState.blocks)
: { type: 'object', properties: {} }
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 (${context}): ${workflowId}`
)
} catch (error) {
logger.error(`[${requestId}] Error syncing MCP tools (${context}):`, error)
// Don't throw - this is a non-critical operation
}
}
/**
* Remove all MCP tools for a workflow (used when undeploying)
*/
export async function removeMcpToolsForWorkflow(
workflowId: string,
requestId: string
): Promise<void> {
try {
await db.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId))
logger.info(`[${requestId}] Removed MCP tools for workflow: ${workflowId}`)
} catch (error) {
logger.error(`[${requestId}] Error removing MCP tools:`, error)
// Don't throw - this is a non-critical operation
}
}

View File

@@ -0,0 +1,236 @@
import { z } from 'zod'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils'
import { isValidStartBlockType } from '@/lib/workflows/triggers/trigger-utils'
import type { InputFormatField } from '@/lib/workflows/types'
import type { McpToolSchema } from './types'
/**
* Extended property definition for workflow tool schemas.
* More specific than the generic McpToolSchema properties.
*/
export interface McpToolProperty {
type: string
description?: string
items?: McpToolProperty
properties?: Record<string, McpToolProperty>
}
/**
* Extended MCP tool schema with typed properties (for workflow tool generation).
* Extends the base McpToolSchema with more specific property types.
*/
export interface McpToolInputSchema extends McpToolSchema {
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),
}
}
/**
* 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 (isValidStartBlockType(blockType)) {
// Try to get inputFormat from subBlocks.inputFormat.value
const subBlocks = blockObj.subBlocks as Record<string, { value?: unknown }> | undefined
const subBlockValue = subBlocks?.inputFormat?.value
// Try legacy config.params.inputFormat
const config = blockObj.config as Record<string, unknown> | undefined
const params = config?.params as Record<string, unknown> | undefined
const paramsValue = params?.inputFormat
const normalized = normalizeInputFormatValue(subBlockValue ?? paramsValue)
return normalized.length > 0 ? normalized : null
}
}
return null
}

View File

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

View File

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

View File

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

View File

@@ -173,7 +173,15 @@ export type TimeRange =
| 'Custom range'
export type LogLevel = 'error' | 'info' | 'running' | 'pending' | 'all' | (string & {})
export type TriggerType = 'chat' | 'api' | 'webhook' | 'manual' | 'schedule' | 'all' | string
export type TriggerType =
| 'chat'
| 'api'
| 'webhook'
| 'manual'
| 'schedule'
| 'mcp'
| 'all'
| (string & {})
/** Filter state for logs and dashboard views */
export interface FilterState {

View File

@@ -15,6 +15,7 @@ type SettingsSection =
| 'copilot'
| 'mcp'
| 'custom-tools'
| 'workflow-mcp-servers'
interface SettingsModalState {
isOpen: boolean

View File

@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "simstudio",

View File

@@ -0,0 +1,30 @@
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,
"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,
"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_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

@@ -932,6 +932,13 @@
"when": 1766607372265,
"tag": "0133_smiling_cargill",
"breakpoints": true
},
{
"idx": 134,
"version": "7",
"when": 1766779827389,
"tag": "0134_parallel_galactus",
"breakpoints": true
}
]
}

View File

@@ -1688,7 +1688,61 @@ export const ssoProvider = pgTable(
})
)
// Usage logging for tracking individual billable operations
/**
* Workflow MCP Servers - User-created MCP servers that expose workflows as tools.
* These servers are accessible by external MCP clients via API key authentication.
*/
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'),
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),
})
)
/**
* 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('{}'),
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
),
})
)
export const usageLogCategoryEnum = pgEnum('usage_log_category', ['model', 'fixed'])
export const usageLogSourceEnum = pgEnum('usage_log_source', ['workflow', 'wand', 'copilot'])
@@ -1700,38 +1754,26 @@ export const usageLog = pgTable(
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
// Charge category: 'model' (token-based) or 'fixed' (flat fee)
category: usageLogCategoryEnum('category').notNull(),
// What generated this charge: 'workflow', 'wand', 'copilot'
source: usageLogSourceEnum('source').notNull(),
// For model charges: model name (e.g., 'gpt-4o', 'claude-4.5-opus')
// For fixed charges: charge type (e.g., 'execution_fee', 'search_query')
description: text('description').notNull(),
// Category-specific metadata (e.g., tokens for 'model' category)
metadata: jsonb('metadata'),
// Cost in USD
cost: decimal('cost').notNull(),
// Optional context references
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'set null' }),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'set null' }),
executionId: text('execution_id'),
// Timestamp
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
// Index for querying user's usage history (most common query)
userCreatedAtIdx: index('usage_log_user_created_at_idx').on(table.userId, table.createdAt),
// Index for filtering by source
sourceIdx: index('usage_log_source_idx').on(table.source),
// Index for workspace-specific queries
workspaceIdIdx: index('usage_log_workspace_id_idx').on(table.workspaceId),
// Index for workflow-specific queries
workflowIdIdx: index('usage_log_workflow_id_idx').on(table.workflowId),
})
)