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>
108
apps/docs/content/docs/en/mcp/deploy-workflows.mdx
Normal 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** |
|
||||
|
||||
@@ -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:
|
||||
|
||||
5
apps/docs/content/docs/en/mcp/meta.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "MCP",
|
||||
"pages": ["index", "deploy-workflows"],
|
||||
"defaultOpen": false
|
||||
}
|
||||
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 271 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 155 KiB |
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
88
apps/sim/app/api/mcp/discover/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
306
apps/sim/app/api/mcp/serve/[serverId]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
155
apps/sim/app/api/mcp/workflow-servers/[id]/route.ts
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
223
apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
132
apps/sim/app/api/mcp/workflow-servers/route.ts
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' : ''}`}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -163,7 +163,7 @@ export function CustomTools() {
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
variant='destructive'
|
||||
onClick={() => handleDeleteClick(tool.id)}
|
||||
disabled={deletingTools.has(tool.id)}
|
||||
>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)` }}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
151
apps/sim/components/emcn/components/avatar/avatar.tsx
Normal 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 }
|
||||
94
apps/sim/components/emcn/components/checkbox/checkbox.tsx
Normal 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 }
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
466
apps/sim/hooks/queries/workflow-mcp-servers.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import type { TokenBucketConfig } from './storage'
|
||||
|
||||
export type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'api-endpoint'
|
||||
export type TriggerType =
|
||||
| 'api'
|
||||
| 'webhook'
|
||||
| 'schedule'
|
||||
| 'manual'
|
||||
| 'chat'
|
||||
| 'mcp'
|
||||
| 'api-endpoint'
|
||||
|
||||
export type RateLimitCounterType = 'sync' | 'async' | 'api-endpoint'
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ export interface PreprocessExecutionOptions {
|
||||
// Required fields
|
||||
workflowId: string
|
||||
userId: string // The authenticated user ID
|
||||
triggerType: 'manual' | 'api' | 'webhook' | 'schedule' | 'chat'
|
||||
triggerType: 'manual' | 'api' | 'webhook' | 'schedule' | 'chat' | 'mcp'
|
||||
executionId: string
|
||||
requestId: string
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export function getTriggerOptions(): TriggerOption[] {
|
||||
{ value: 'schedule', label: 'Schedule', color: '#059669' },
|
||||
{ value: 'chat', label: 'Chat', color: '#7c3aed' },
|
||||
{ value: 'webhook', label: 'Webhook', color: '#ea580c' },
|
||||
{ value: 'mcp', label: 'MCP', color: '#dc2626' },
|
||||
]
|
||||
|
||||
for (const trigger of triggers) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })]
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
106
apps/sim/lib/mcp/workflow-mcp-sync.ts
Normal 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
|
||||
}
|
||||
}
|
||||
236
apps/sim/lib/mcp/workflow-tool-schema.ts
Normal 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
|
||||
}
|
||||
@@ -10,6 +10,43 @@ import { getTrigger } from '@/triggers'
|
||||
|
||||
const logger = createLogger('TriggerUtils')
|
||||
|
||||
/**
|
||||
* Valid start block types that can trigger a workflow
|
||||
*/
|
||||
export const VALID_START_BLOCK_TYPES = [
|
||||
'starter',
|
||||
'start',
|
||||
'start_trigger',
|
||||
'api',
|
||||
'api_trigger',
|
||||
'input_trigger',
|
||||
] as const
|
||||
|
||||
export type ValidStartBlockType = (typeof VALID_START_BLOCK_TYPES)[number]
|
||||
|
||||
/**
|
||||
* Check if a block type is a valid start block type
|
||||
*/
|
||||
export function isValidStartBlockType(blockType: string): blockType is ValidStartBlockType {
|
||||
return VALID_START_BLOCK_TYPES.includes(blockType as ValidStartBlockType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a workflow state has a valid start block
|
||||
*/
|
||||
export function hasValidStartBlockInState(state: any): boolean {
|
||||
if (!state?.blocks) {
|
||||
return false
|
||||
}
|
||||
|
||||
const startBlock = Object.values(state.blocks).find((block: any) => {
|
||||
const blockType = block?.type
|
||||
return isValidStartBlockType(blockType)
|
||||
})
|
||||
|
||||
return !!startBlock
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates mock data based on the output type definition
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface InputFormatField {
|
||||
name?: string
|
||||
type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files' | string
|
||||
description?: string
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
|
||||
@@ -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[] => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,6 +15,7 @@ type SettingsSection =
|
||||
| 'copilot'
|
||||
| 'mcp'
|
||||
| 'custom-tools'
|
||||
| 'workflow-mcp-servers'
|
||||
|
||||
interface SettingsModalState {
|
||||
isOpen: boolean
|
||||
|
||||
1
bun.lock
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "simstudio",
|
||||
|
||||
30
packages/db/migrations/0134_parallel_galactus.sql
Normal 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");
|
||||
8813
packages/db/migrations/meta/0134_snapshot.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
)
|
||||
|
||||