allow importing mcp tools

This commit is contained in:
ramnique 2025-03-15 02:51:24 +05:30
parent 51166c19b4
commit 5dca666879
15 changed files with 1584 additions and 165 deletions

View file

@ -0,0 +1,82 @@
"use server";
import { z } from "zod";
import { WorkflowTool } from "../lib/types/workflow_types";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { projectAuthCheck } from "./project_actions";
import { callMcpTool } from "../lib/utils";
import { projectsCollection } from "../lib/mongodb";
import { Project } from "../lib/types/project_types";
export async function fetchMcpTools(projectId: string): Promise<z.infer<typeof WorkflowTool>[]> {
await projectAuthCheck(projectId);
const project = await projectsCollection.findOne({
_id: projectId,
});
const mcpServers = project?.mcpServers ?? [];
const tools: z.infer<typeof WorkflowTool>[] = [];
for (const mcpServer of mcpServers) {
try {
const transport = new SSEClientTransport(new URL(mcpServer.url));
const client = new Client(
{
name: "rowboat-client",
version: "1.0.0"
},
{
capabilities: {
prompts: {},
resources: {},
tools: {}
}
}
);
await client.connect(transport);
// List tools
const result = await client.listTools();
await client.close();
tools.push(...result.tools.map((mcpTool) => {
let props = mcpTool.inputSchema.properties as Record<string, { description: string; type: string }>;
const tool: z.infer<typeof WorkflowTool> = {
name: mcpTool.name,
description: mcpTool.description ?? "",
parameters: {
type: "object",
properties: props ?? {},
required: mcpTool.inputSchema.required as string[] ?? [],
},
isMcp: true,
mcpServerName: mcpServer.name,
}
return tool;
}));
} catch (e) {
console.error(`Error fetching MCP tools from ${mcpServer.name}: ${e}`);
}
}
return tools;
}
export async function updateMcpServers(projectId: string, mcpServers: z.infer<typeof Project>['mcpServers']): Promise<void> {
await projectAuthCheck(projectId);
await projectsCollection.updateOne({
_id: projectId,
}, { $set: { mcpServers } });
}
export async function executeMcpTool(projectId: string, mcpServerName: string, toolName: string, parameters: Record<string, unknown>): Promise<unknown> {
await projectAuthCheck(projectId);
const result = await callMcpTool(projectId, mcpServerName, toolName, parameters);
return result;
}

View file

@ -5,7 +5,7 @@ import { ObjectId } from "mongodb";
import { authCheck } from "../../utils";
import { ApiRequest, ApiResponse } from "../../../../lib/types/types";
import { AgenticAPIChatRequest, AgenticAPIChatMessage, convertFromAgenticApiToApiMessages, convertFromApiToAgenticApiMessages, convertWorkflowToAgenticAPI } from "../../../../lib/types/agents_api_types";
import { getAgenticApiResponse, callClientToolWebhook, runRAGToolCall, mockToolResponse } from "../../../../lib/utils";
import { getAgenticApiResponse, callClientToolWebhook, runRAGToolCall, mockToolResponse, callMcpTool } from "../../../../lib/utils";
import { check_query_limit } from "../../../../lib/rate_limiting";
import { apiV1 } from "rowboat-shared";
import { PrefixLogger } from "../../../../lib/utils";
@ -162,7 +162,7 @@ export async function POST(
return Response.json({ error: "Error running RAG tool call" }, { status: 500 });
}
} else {
logger.log(`Running client tool webhook for tool ${toolCall.function.name}`);
logger.log(`Processing tool call ${toolCall.function.name}`);
try {
// if tool is supposed to be mocked, mock it
@ -170,6 +170,10 @@ export async function POST(
if (testProfile?.mockTools || workflowTool?.mockTool) {
logger.log(`Mocking tool call ${toolCall.function.name}`);
result = await mockToolResponse(toolCall.id, currentMessages, testProfile?.mockPrompt || workflowTool?.mockInstructions || '');
} else if (workflowTool?.isMcp) {
// else run the tool call by calling the MCP tool
logger.log(`Calling MCP tool: ${toolCall.function.name}`);
result = await callMcpTool(projectId, workflowTool.mcpServerName ?? 'default', toolCall.function.name, JSON.parse(toolCall.function.arguments));
} else {
// else run the tool call by calling the client tool webhook
logger.log(`Running client tool webhook for tool ${toolCall.function.name}`);

View file

@ -8,7 +8,7 @@ import { convertFromAgenticAPIChatMessages } from "../../../../../../lib/types/a
import { convertToAgenticAPIChatMessages } from "../../../../../../lib/types/agents_api_types";
import { convertWorkflowToAgenticAPI } from "../../../../../../lib/types/agents_api_types";
import { AgenticAPIChatRequest } from "../../../../../../lib/types/agents_api_types";
import { callClientToolWebhook, getAgenticApiResponse, runRAGToolCall, mockToolResponse } from "../../../../../../lib/utils";
import { callClientToolWebhook, getAgenticApiResponse, runRAGToolCall, mockToolResponse, callMcpTool } from "../../../../../../lib/utils";
import { check_query_limit } from "../../../../../../lib/rate_limiting";
import { PrefixLogger } from "../../../../../../lib/utils";
@ -158,14 +158,22 @@ export async function POST(
[...messages, ...unsavedMessages],
workflowTool.mockInstructions || ''
);
} else if (workflowTool?.isMcp) {
logger.log(`Calling MCP tool: ${toolCall.function.name}`);
return await callMcpTool(
session.projectId,
workflowTool.mcpServerName ?? 'default',
toolCall.function.name,
JSON.parse(toolCall.function.arguments)
);
} else {
logger.log(`Calling webhook for tool: ${toolCall.function.name}`);
return await callClientToolWebhook(
toolCall,
[...messages, ...unsavedMessages],
session.projectId,
);
}
logger.log(`Calling webhook for tool: ${toolCall.function.name}`);
return await callClientToolWebhook(
toolCall,
[...messages, ...unsavedMessages],
session.projectId,
);
} catch (error) {
logger.log(`Error executing tool call ${toolCall.id}: ${error}`);
return { error: "Tool execution failed" };

View file

@ -23,7 +23,8 @@ export function ListItem({
onClick,
disabled,
rightElement,
selectedRef
selectedRef,
icon
}: {
name: string;
isSelected: boolean;
@ -31,6 +32,7 @@ export function ListItem({
disabled?: boolean;
rightElement?: React.ReactNode;
selectedRef?: React.RefObject<HTMLButtonElement>;
icon?: React.ReactNode;
}) {
return (
<button
@ -41,9 +43,12 @@ export function ListItem({
"hover:bg-gray-50 dark:hover:bg-gray-800": !isSelected,
})}
>
<div className={clsx("truncate text-sm dark:text-gray-200", {
"text-gray-400 dark:text-gray-500": disabled,
})}>{name}</div>
<div className="flex items-center gap-1">
{icon && <div className="w-4 shrink-0">{icon}</div>}
<div className={clsx("truncate text-sm dark:text-gray-200", {
"text-gray-400 dark:text-gray-500": disabled,
})}>{name}</div>
</div>
{rightElement}
</button>
);

View file

@ -1,4 +1,5 @@
import { z } from "zod";
export const Project = z.object({
_id: z.string().uuid(),
name: z.string(),
@ -11,16 +12,22 @@ export const Project = z.object({
publishedWorkflowId: z.string().optional(),
nextWorkflowNumber: z.number().optional(),
testRunCounter: z.number().default(0),
});export const ProjectMember = z.object({
mcpServers: z.array(z.object({
name: z.string(),
url: z.string(),
})).optional(),
});
export const ProjectMember = z.object({
userId: z.string(),
projectId: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
});
export const ApiKey = z.object({
projectId: z.string(),
key: z.string(),
createdAt: z.string().datetime(),
lastUsedAt: z.string().datetime().optional(),
});
});

View file

@ -45,6 +45,8 @@ export const WorkflowTool = z.object({
})),
required: z.array(z.string()).optional(),
}),
isMcp: z.boolean().default(false).optional(),
mcpServerName: z.string().optional(),
});
export const Workflow = z.object({
name: z.string().optional(),

View file

@ -18,7 +18,54 @@ import { qdrantClient } from "./qdrant";
import { EmbeddingRecord } from "./types/datasource_types";
import { ApiMessage } from "./types/types";
import { openai } from "@ai-sdk/openai";
import { TestProfile } from "./types/testing_types";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
export async function callMcpTool(
projectId: string,
mcpServerName: string,
toolName: string,
parameters: Record<string, unknown>,
): Promise<unknown> {
const project = await projectsCollection.findOne({
"_id": projectId,
});
if (!project) {
throw new Error('Project not found');
}
const mcpServer = project.mcpServers?.find(s => s.name === mcpServerName);
if (!mcpServer) {
throw new Error('MCP server not found');
}
const transport = new SSEClientTransport(new URL(mcpServer.url));
const client = new Client(
{
name: "rowboat-client",
version: "1.0.0"
},
{
capabilities: {
prompts: {},
resources: {},
tools: {}
}
}
);
await client.connect(transport);
const result = await client.callTool({
name: toolName,
arguments: parameters,
});
await client.close();
return result;
}
export async function callClientToolWebhook(
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number],

View file

@ -2,8 +2,9 @@
import { Metadata } from "next";
import { Spinner, Textarea, Button, Dropdown, DropdownMenu, DropdownItem, DropdownTrigger, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure, Divider } from "@heroui/react";
import { ReactNode, useEffect, useState, useCallback } from "react";
import { ReactNode, useEffect, useState, useCallback, useMemo } from "react";
import { getProjectConfig, updateProjectName, updateWebhookUrl, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret } from "../../../actions/project_actions";
import { updateMcpServers } from "../../../actions/mcp_actions";
import { CopyButton } from "../../../lib/components/copy-button";
import { EditableField } from "../../../lib/components/editable-field";
import { EyeIcon, EyeOffIcon, CopyIcon, MoreVerticalIcon, PlusIcon, EllipsisVerticalIcon } from "lucide-react";
@ -113,6 +114,227 @@ export function BasicSettingsSection({
</Section>;
}
function McpServersSection({
projectId,
}: {
projectId: string;
}) {
const [servers, setServers] = useState<Array<{ name: string; url: string }>>([]);
const [originalServers, setOriginalServers] = useState<Array<{ name: string; url: string }>>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
const [newServer, setNewServer] = useState({ name: '', url: '' });
const [validationErrors, setValidationErrors] = useState<{
name?: string;
url?: string;
}>({});
// Load initial servers
useEffect(() => {
setLoading(true);
getProjectConfig(projectId).then((project) => {
const initialServers = project.mcpServers || [];
setServers(JSON.parse(JSON.stringify(initialServers))); // Deep copy
setOriginalServers(JSON.parse(JSON.stringify(initialServers))); // Deep copy
setLoading(false);
});
}, [projectId]);
// Check if there are unsaved changes by comparing the arrays
const hasChanges = useMemo(() => {
if (servers.length !== originalServers.length) return true;
return servers.some((server, index) => {
return server.name !== originalServers[index]?.name ||
server.url !== originalServers[index]?.url;
});
}, [servers, originalServers]);
const handleAddServer = () => {
setNewServer({ name: '', url: '' });
setValidationErrors({});
onOpen();
};
const handleRemoveServer = (index: number) => {
setServers(servers.filter((_, i) => i !== index));
};
const handleCreateServer = () => {
// Clear previous validation errors
setValidationErrors({});
const errors: typeof validationErrors = {};
// Validate name uniqueness
if (!newServer.name.trim()) {
errors.name = 'Server name is required';
} else if (servers.some(s => s.name === newServer.name)) {
errors.name = 'Server name must be unique';
}
// Validate URL
if (!newServer.url.trim()) {
errors.url = 'Server URL is required';
} else {
try {
new URL(newServer.url);
} catch {
errors.url = 'Invalid URL format';
}
}
if (Object.keys(errors).length > 0) {
setValidationErrors(errors);
return;
}
setServers([...servers, newServer]);
onClose();
};
const handleSave = async () => {
setSaving(true);
try {
await updateMcpServers(projectId, servers);
setOriginalServers(JSON.parse(JSON.stringify(servers))); // Update original servers after successful save
setMessage({ type: 'success', text: 'Servers updated successfully' });
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage({ type: 'error', text: 'Failed to update servers' });
}
setSaving(false);
};
return <Section title="MCP servers">
<div className="space-y-4">
<div className="flex justify-between items-center">
<p className="text-sm text-muted-foreground">
MCP servers are used to execute MCP tools.
</p>
<Button
size="sm"
variant="flat"
startContent={<PlusIcon className="w-4 h-4" />}
onPress={handleAddServer}
>
Add Server
</Button>
</div>
{loading ? (
<Spinner size="sm" />
) : (
<>
<div className="space-y-3">
{servers.map((server, index) => (
<div key={index} className="flex gap-3 items-center p-3 border border-border rounded-md">
<div className="flex-1">
<div className="font-medium">{server.name}</div>
<div className="text-sm text-muted-foreground">{server.url}</div>
</div>
<Button
size="sm"
color="danger"
variant="light"
onPress={() => handleRemoveServer(index)}
>
Remove
</Button>
</div>
))}
{servers.length === 0 && (
<div className="text-center text-muted-foreground p-4">
No servers configured
</div>
)}
</div>
{hasChanges && (
<div className="flex justify-end">
<Button
size="sm"
color="primary"
onPress={handleSave}
isLoading={saving}
>
Save Changes
</Button>
</div>
)}
{message && (
<div className={`text-sm p-2 rounded-md ${
message.type === 'success' ? 'bg-green-50 text-green-500' : 'bg-red-50 text-red-500'
}`}>
{message.text}
</div>
)}
</>
)}
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader>Add MCP Server</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-4">
<Input
label="Server Name"
placeholder="Enter server name"
value={newServer.name}
onChange={(e) => {
setNewServer({ ...newServer, name: e.target.value });
// Clear name error when user types
if (validationErrors.name) {
setValidationErrors(prev => ({
...prev,
name: undefined
}));
}
}}
errorMessage={validationErrors.name}
isInvalid={!!validationErrors.name}
isRequired
/>
<Input
label="SSE URL"
placeholder="https://localhost:8000/sse"
value={newServer.url}
onChange={(e) => {
setNewServer({ ...newServer, url: e.target.value });
// Clear URL error when user types
if (validationErrors.url) {
setValidationErrors(prev => ({
...prev,
url: undefined
}));
}
}}
errorMessage={validationErrors.url}
isInvalid={!!validationErrors.url}
isRequired
/>
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button
color="primary"
onPress={handleCreateServer}
isDisabled={!newServer.name || !newServer.url}
>
Add Server
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
</Section>;
}
function ApiKeyDisplay({ apiKey }: { apiKey: string }) {
const [isVisible, setIsVisible] = useState(false);
@ -587,6 +809,7 @@ export default function App({
<BasicSettingsSection projectId={projectId} />
<SecretSection projectId={projectId} />
<ApiKeysSection projectId={projectId} />
<McpServersSection projectId={projectId} />
<WebhookUrlSection projectId={projectId} />
{useChatWidget && <ChatWidgetSection projectId={projectId} chatWidgetHost={chatWidgetHost} />}
<DeleteProjectSection projectId={projectId} />

View file

@ -4,15 +4,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import z from "zod";
import { Workflow } from "../../../lib/types/workflow_types";
import { WorkflowTool } from "../../../lib/types/workflow_types";
import { WebpageCrawlResponse } from "../../../lib/types/tool_types";
import { GetInformationToolResult } from "../../../lib/types/tool_types";
import { executeClientTool, getInformationTool, scrapeWebpage, suggestToolResponse } from "../../../actions/actions";
import { executeClientTool, getInformationTool, suggestToolResponse } from "../../../actions/actions";
import MarkdownContent from "../../../lib/components/markdown-content";
import Link from "next/link";
import { apiV1 } from "rowboat-shared";
import { EditableField } from "../../../lib/components/editable-field";
import { MessageSquareIcon, EllipsisIcon, CircleCheckIcon, ChevronsDownIcon, ChevronsRightIcon, ChevronRightIcon, ChevronDownIcon, ExternalLinkIcon, XIcon } from "lucide-react";
import { MessageSquareIcon, EllipsisIcon, CircleCheckIcon, ChevronRightIcon, ChevronDownIcon, XIcon } from "lucide-react";
import { TestProfile } from "@/app/lib/types/testing_types";
import { executeMcpTool } from "@/app/actions/mcp_actions";
function UserMessage({ content }: { content: string }) {
return <div className="self-end ml-[30%] flex flex-col">
@ -201,6 +200,17 @@ function ToolCall({
systemMessage={systemMessage}
/>;
}
if (matchingWorkflowTool?.isMcp) {
return <McpToolCall
toolCall={toolCall}
workflowTool={matchingWorkflowTool}
result={result}
handleResult={handleResult}
projectId={projectId}
messages={messages}
sender={sender}
/>;
}
return <ClientToolCall
toolCall={toolCall}
result={result}
@ -345,6 +355,78 @@ function TransferToAgentToolCall({
</div>;
}
function McpToolCall({
toolCall,
result: availableResult,
handleResult,
projectId,
messages,
sender,
workflowTool,
}: {
toolCall: z.infer<typeof apiV1.AssistantMessageWithToolCalls>['tool_calls'][number];
result: z.infer<typeof apiV1.ToolMessage> | undefined;
handleResult: (result: z.infer<typeof apiV1.ToolMessage>) => void;
projectId: string;
messages: z.infer<typeof apiV1.ChatMessage>[];
sender: string | null | undefined;
workflowTool: z.infer<typeof WorkflowTool>;
}) {
const [result, setResult] = useState<z.infer<typeof apiV1.ToolMessage> | undefined>(availableResult);
useEffect(() => {
if (result) {
return;
}
let ignore = false;
async function process() {
let response;
try {
response = await executeMcpTool(
projectId,
workflowTool.mcpServerName || '',
workflowTool.name,
JSON.parse(toolCall.function.arguments),
);
} catch (e) {
response = {
error: (e as Error).message,
};
}
if (ignore) {
return;
}
const result: z.infer<typeof apiV1.ToolMessage> = {
role: 'tool',
tool_call_id: toolCall.id,
tool_name: toolCall.function.name,
content: JSON.stringify(response),
};
setResult(result);
handleResult(result);
}
process();
return () => {
ignore = true;
};
}, [result, toolCall, projectId, messages, handleResult, workflowTool.mcpServerName, workflowTool.name]);
return <div className="flex flex-col gap-1">
{sender && <div className='text-gray-500 text-sm ml-3'>{sender}</div>}
<div className='border border-gray-300 p-2 pt-2 rounded-lg rounded-bl-none flex flex-col gap-2 mr-[30%]'>
<ToolCallHeader toolCall={toolCall} result={result} />
<div className='flex flex-col gap-2'>
<ExpandableContent label='Params' content={toolCall.function.arguments} expanded={false} />
{result && <ExpandableContent label='Result' content={result.content} expanded={false} />}
</div>
</div>
</div>;
}
function ClientToolCall({
toolCall,
result: availableResult,
@ -407,8 +489,8 @@ function ClientToolCall({
<ToolCallHeader toolCall={toolCall} result={result} />
<div className='flex flex-col gap-2'>
<ExpandableContent label='Params' content={toolCall.function.arguments} expanded={Boolean(!result)} />
{result && <ExpandableContent label='Result' content={result.content} expanded={true} />}
<ExpandableContent label='Params' content={toolCall.function.arguments} expanded={false} />
{result && <ExpandableContent label='Result' content={result.content} expanded={false} />}
</div>
</div>
</div>;

View file

@ -6,7 +6,7 @@ import { Dropdown, DropdownItem, DropdownTrigger, DropdownMenu } from "@heroui/r
import { useRef, useEffect } from "react";
import { ActionButton, StructuredPanel } from "../../../lib/components/structured-panel";
import clsx from "clsx";
import { EllipsisVerticalIcon } from "lucide-react";
import { EllipsisVerticalIcon, ImportIcon } from "lucide-react";
import { SectionHeader, ListItem } from "../../../lib/components/structured-list";
interface EntityListProps {
@ -100,6 +100,7 @@ export function EntityList({
onClick={() => onSelectTool(tool.name)}
selectedRef={selectedEntity?.type === "tool" && selectedEntity.name === tool.name ? selectedRef : undefined}
rightElement={<EntityDropdown name={tool.name} onDelete={onDeleteTool} />}
icon={tool.isMcp ? <ImportIcon className="w-4 h-4 text-blue-700" /> : <></>}
/>
))}

View file

@ -0,0 +1,151 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Button, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Checkbox } from "@heroui/react";
import { z } from "zod";
import { WorkflowTool } from "@/app/lib/types/workflow_types";
import { RefreshCwIcon } from "lucide-react";
import { fetchMcpTools } from "@/app/actions/mcp_actions";
interface McpImportToolsProps {
projectId: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onImport: (tools: z.infer<typeof WorkflowTool>[]) => void;
}
export function McpImportTools({ projectId, isOpen, onOpenChange, onImport }: McpImportToolsProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tools, setTools] = useState<z.infer<typeof WorkflowTool>[]>([]);
const [selectedTools, setSelectedTools] = useState<Set<number>>(new Set());
const process = useCallback(async () => {
setLoading(true);
setError(null);
setSelectedTools(new Set());
try {
const result = await fetchMcpTools(projectId);
setTools(result);
// Select all tools by default
setSelectedTools(new Set(result.map((_, index) => index)));
} catch (error) {
setError(`Unable to fetch tools: ${error}`);
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
console.log("mcp import tools useEffect", isOpen);
if (isOpen) {
process();
}
}, [isOpen, process]);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Import from MCP servers</ModalHeader>
<ModalBody>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Fetching tools...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => process()}>Retry</Button>
</div>}
{!loading && !error && <>
<div className="flex items-center justify-between mb-4">
<div className="text-gray-600">
{tools.length === 0 ? "No tools found" : `Found ${tools.length} tools:`}
</div>
<Button
size="sm"
variant="flat"
onPress={() => {
setTools([]);
process();
}}
startContent={<RefreshCwIcon className="w-4 h-4" />}
>
Refresh
</Button>
</div>
{tools.length > 0 && <div className="flex flex-col w-full mt-4">
<div className="flex items-center gap-4 px-4 py-2 bg-gray-50 rounded-t-lg border-b text-sm text-gray-700 font-medium">
<div className="w-8">
<Checkbox
size="sm"
isSelected={selectedTools.size === tools.length}
isIndeterminate={selectedTools.size > 0 && selectedTools.size < tools.length}
onValueChange={(checked) => {
if (checked) {
setSelectedTools(new Set(tools.map((_, i) => i)));
} else {
setSelectedTools(new Set());
}
}}
/>
</div>
<div className="w-36">Server</div>
<div className="flex-1">Tool Name</div>
</div>
<div className="border rounded-b-lg divide-y overflow-y-auto max-h-[300px]">
{tools.map((t, index) => (
<div
key={index}
className="flex items-center gap-4 px-4 py-2 hover:bg-gray-50 transition-colors"
>
<div className="w-8">
<Checkbox
size="sm"
isSelected={selectedTools.has(index)}
onValueChange={(checked) => {
const newSelected = new Set(selectedTools);
if (checked) {
newSelected.add(index);
} else {
newSelected.delete(index);
}
setSelectedTools(newSelected);
}}
/>
</div>
<div className="w-36">
<div className="bg-blue-50 px-2 py-1 rounded text-blue-700 text-sm font-medium border border-blue-100">
{t.mcpServerName}
</div>
</div>
<div className="flex-1 truncate text-gray-700">{t.name}</div>
</div>
))}
</div>
</div>}
{tools.length > 0 && (
<div className="mt-4 text-sm text-gray-600">
{selectedTools.size} of {tools.length} tools selected
</div>
)}
</>}
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
{tools.length > 0 && <Button size="sm" onPress={() => {
const selectedToolsList = tools.filter((_, index) => selectedTools.has(index));
onImport(selectedToolsList);
onClose();
}}>
Import
</Button>}
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}

View file

@ -6,16 +6,15 @@ import { ActionButton, StructuredPanel } from "../../../lib/components/structure
import { EditableField } from "../../../lib/components/editable-field";
import { Divider } from "@heroui/react";
import { Label } from "../../../lib/components/label";
import { TrashIcon, XIcon } from "lucide-react";
import { ImportIcon, XIcon } from "lucide-react";
import { useState } from "react";
import { Link as NextUILink } from "@heroui/react";
import Link from "next/link";
export function ParameterConfig({
param,
handleUpdate,
handleDelete,
handleRename
handleRename,
readOnly
}: {
param: {
name: string,
@ -29,11 +28,12 @@ export function ParameterConfig({
required: boolean
}) => void,
handleDelete: (name: string) => void,
handleRename: (oldName: string, newName: string) => void
handleRename: (oldName: string, newName: string) => void,
readOnly?: boolean
}) {
return <StructuredPanel
title={param.name}
actions={[
actions={!readOnly ? [
<ActionButton
key="delete"
onClick={() => handleDelete(param.name)}
@ -41,7 +41,7 @@ export function ParameterConfig({
>
Remove
</ActionButton>
]}
] : []}
>
<div className="flex flex-col gap-2">
<EditableField
@ -52,6 +52,7 @@ export function ParameterConfig({
handleRename(param.name, newName);
}
}}
locked={readOnly}
/>
<Divider />
@ -69,6 +70,7 @@ export function ParameterConfig({
type: Array.from(keys)[0] as string
});
}}
isDisabled={readOnly}
>
{['string', 'number', 'boolean', 'array', 'object'].map(type => (
<SelectItem key={type}>
@ -89,6 +91,7 @@ export function ParameterConfig({
description: desc
});
}}
locked={readOnly}
/>
<Divider />
@ -102,6 +105,7 @@ export function ParameterConfig({
required: !param.required
});
}}
isDisabled={readOnly}
>
Required
</Checkbox>
@ -121,6 +125,7 @@ export function ToolConfig({
handleClose: () => void
}) {
const [selectedParams, setSelectedParams] = useState(new Set([]));
const isReadOnly = tool.isMcp;
function handleParamRename(oldName: string, newName: string) {
const newProperties = { ...tool.parameters!.properties };
@ -193,6 +198,13 @@ export function ToolConfig({
</ActionButton>
]}>
<div className="flex flex-col gap-4">
{tool.isMcp && <div className="flex items-center gap-2">
<div className="flex items-center gap-2 text-sm font-normal bg-gray-100 px-2 py-1 rounded-md text-gray-700">
<ImportIcon className="w-4 h-4 text-blue-700" />
<div className="text-sm font-normal">Imported from MCP server: <span className="font-bold">{tool.mcpServerName}</span></div>
</div>
</div>}
<EditableField
label="Name"
value={tool.name}
@ -209,6 +221,7 @@ export function ToolConfig({
}
return { valid: true };
}}
locked={isReadOnly}
/>
<Divider />
@ -221,89 +234,92 @@ export function ToolConfig({
description: value
})}
placeholder="Describe what this tool does..."
locked={isReadOnly}
/>
<Divider />
<Label label="TOOL RESPONSES" />
{!isReadOnly && <>
<Label label="TOOL RESPONSES" />
<div className="ml-4 flex flex-col gap-2">
<RadioGroup
defaultValue="mock"
value={tool.mockTool ? "mock" : "api"}
onValueChange={(value) => handleUpdate({
...tool,
mockTool: value === "mock",
autoSubmitMockedResponse: value === "mock" ? true : undefined
})}
orientation="horizontal"
classNames={{
wrapper: "gap-8",
label: "text-sm"
}}
>
<Radio
value="mock"
size="sm"
<div className="ml-4 flex flex-col gap-2">
<RadioGroup
defaultValue="mock"
value={tool.mockTool ? "mock" : "api"}
onValueChange={(value) => handleUpdate({
...tool,
mockTool: value === "mock",
autoSubmitMockedResponse: value === "mock" ? true : undefined
})}
orientation="horizontal"
classNames={{
base: "max-w-[50%]",
label: "text-sm font-normal"
wrapper: "gap-8",
label: "text-sm"
}}
>
Mock tool responses
</Radio>
<Radio
value="api"
size="sm"
classNames={{
base: "max-w-[50%]",
label: "text-sm font-normal"
}}
>
Connect tool to your API
</Radio>
</RadioGroup>
{tool.mockTool && <>
<div className="ml-0">
<Checkbox
key="autoSubmitMockedResponse"
<Radio
value="mock"
size="sm"
classNames={{
label: "text-xs font-normal"
base: "max-w-[50%]",
label: "text-sm font-normal"
}}
isSelected={tool.autoSubmitMockedResponse ?? true}
onValueChange={(value) => handleUpdate({
...tool,
autoSubmitMockedResponse: value
})}
>
Auto-submit mocked response in playground
</Checkbox>
</div>
Mock tool responses
</Radio>
<Radio
value="api"
size="sm"
classNames={{
base: "max-w-[50%]",
label: "text-sm font-normal"
}}
>
Connect tool to your API
</Radio>
</RadioGroup>
<Divider />
{tool.mockTool && <>
<div className="ml-0">
<Checkbox
key="autoSubmitMockedResponse"
size="sm"
classNames={{
label: "text-xs font-normal"
}}
isSelected={tool.autoSubmitMockedResponse ?? true}
onValueChange={(value) => handleUpdate({
...tool,
autoSubmitMockedResponse: value
})}
>
Auto-submit mocked response in playground
</Checkbox>
</div>
<EditableField
label="Mock instructions"
value={tool.mockInstructions || ''}
onChange={(value) => handleUpdate({
...tool,
mockInstructions: value
})}
placeholder="Enter mock instructions..."
multiline
/>
</>}
<Divider />
{!tool.mockTool && (
<div className="ml-0 text-danger text-xs">
Please configure your webhook in the <strong>Integrate</strong> page if you haven&apos;t already.
</div>
)}
</div>
<EditableField
label="Mock instructions"
value={tool.mockInstructions || ''}
onChange={(value) => handleUpdate({
...tool,
mockInstructions: value
})}
placeholder="Enter mock instructions..."
multiline
/>
</>}
<Divider />
{!tool.mockTool && (
<div className="ml-0 text-danger text-xs">
Please configure your webhook in the <strong>Integrate</strong> page if you haven&apos;t already.
</div>
)}
</div>
<Divider />
</>}
<Label label="Parameters" />
@ -320,11 +336,12 @@ export function ToolConfig({
handleUpdate={handleParamUpdate}
handleDelete={handleParamDelete}
handleRename={handleParamRename}
readOnly={isReadOnly}
/>
))}
</div>
<Button
{!isReadOnly && <Button
className="self-start shrink-0"
variant="light"
size="sm"
@ -352,7 +369,7 @@ export function ToolConfig({
}}
>
Add Parameter
</Button>
</Button>}
</div>
</StructuredPanel>
);

View file

@ -26,10 +26,10 @@ import { apiV1 } from "rowboat-shared";
import { publishWorkflow, renameWorkflow, saveWorkflow } from "../../../actions/workflow_actions";
import { PublishedBadge } from "./published_badge";
import { BackIcon, HamburgerIcon, WorkflowIcon } from "../../../lib/components/icons";
import { CopyIcon, Layers2Icon, RadioIcon, RedoIcon, Sparkles, UndoIcon } from "lucide-react";
import { CopyIcon, ImportIcon, Layers2Icon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon } from "lucide-react";
import { EntityList } from "./entity_list";
import { CopilotMessage } from "../../../lib/types/copilot_types";
import { TestProfile } from "@/app/lib/types/testing_types";
import { McpImportTools } from "./mcp_imports";
enablePatches();
@ -132,6 +132,9 @@ export type Action = {
} | {
type: "restore_state";
state: StateItem;
} | {
type: "import_mcp_tools";
tools: z.infer<typeof WorkflowTool>[];
};
function reducer(state: State, action: Action): State {
@ -509,6 +512,26 @@ function reducer(state: State, action: Action): State {
draft.workflow.startAgent = action.name;
draft.chatKey++;
break;
case "import_mcp_tools":
if (isLive) {
break;
}
// Process each tool one by one
action.tools.forEach(newTool => {
const existingToolIndex = draft.workflow.tools.findIndex(
tool => tool.name === newTool.name
);
if (existingToolIndex !== -1) {
// Replace existing tool
draft.workflow.tools[existingToolIndex] = newTool;
} else {
// Add new tool
draft.workflow.tools.push(newTool);
}
});
draft.chatKey++;
break;
}
}
);
@ -575,6 +598,7 @@ export function WorkflowEditor({
const [loadingResponse, setLoadingResponse] = useState(false);
const [loadingMessage, setLoadingMessage] = useState("Thinking...");
const [responseError, setResponseError] = useState<string | null>(null);
const [isMcpImportModalOpen, setIsMcpImportModalOpen] = useState(false);
console.log(`workflow editor chat key: ${state.present.chatKey}`);
@ -697,6 +721,10 @@ export function WorkflowEditor({
}
}, [isLive]);
function handleImportMcpTools(tools: z.infer<typeof WorkflowTool>[]) {
dispatch({ type: "import_mcp_tools", tools });
}
useEffect(() => {
if (state.present.pendingChanges && state.present.workflow) {
saveQueue.current.push(state.present.workflow);
@ -732,7 +760,7 @@ export function WorkflowEditor({
<DropdownMenu
disabledKeys={[
...(state.present.pendingChanges ? ['switch', 'clone'] : []),
...(isLive ? ['publish'] : []),
...(isLive ? ['publish', 'mcp'] : []),
]}
onAction={(key) => {
if (key === 'switch') {
@ -747,6 +775,9 @@ export function WorkflowEditor({
if (key === 'clipboard') {
handleCopyJSON();
}
if (key === 'mcp') {
setIsMcpImportModalOpen(true);
}
}}
>
<DropdownItem
@ -774,6 +805,12 @@ export function WorkflowEditor({
>
Copy as JSON
</DropdownItem>
<DropdownItem
key="mcp"
startContent={<ImportIcon className="w-4 h-4 text-blue-700" />}
>
MCP: Import tools
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
@ -935,5 +972,11 @@ export function WorkflowEditor({
</ResizablePanel>
</>}
</ResizablePanelGroup>
<McpImportTools
projectId={state.present.workflow.projectId}
isOpen={isMcpImportModalOpen}
onOpenChange={setIsMcpImportModalOpen}
onImport={handleImportMcpTools}
/>
</div>;
}

File diff suppressed because it is too large Load diff

View file

@ -19,12 +19,13 @@
"@aws-sdk/s3-request-presigner": "^3.743.0",
"@google/generative-ai": "^0.21.0",
"@heroicons/react": "^2.2.0",
"@langchain/core": "^0.3.7",
"@langchain/textsplitters": "^0.1.0",
"@mendable/firecrawl-js": "^1.0.3",
"@heroui/react": "2.7.4",
"@heroui/system": "2.4.11",
"@heroui/theme": "2.4.11",
"@langchain/core": "^0.3.7",
"@langchain/textsplitters": "^0.1.0",
"@mendable/firecrawl-js": "^1.0.3",
"@modelcontextprotocol/sdk": "^1.7.0",
"@primer/react": "^36.27.0",
"@qdrant/js-client-rest": "^1.13.0",
"ai": "^3.3.28",
@ -72,4 +73,4 @@
"tsx": "^4.19.1",
"typescript": "^5"
}
}
}