mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
housekeeping
This commit is contained in:
parent
3755e76474
commit
cc8e9210d7
4 changed files with 0 additions and 1130 deletions
|
|
@ -1,295 +0,0 @@
|
|||
"use server";
|
||||
import { z } from "zod";
|
||||
import { WorkflowTool } from "../lib/types/workflow_types";
|
||||
import { projectAuthCheck } from "./project.actions";
|
||||
import { projectsCollection } from "../lib/mongodb";
|
||||
import { Project } from "../lib/types/project_types";
|
||||
import { McpServerTool, convertMcpServerToolToWorkflowTool } from "../lib/types/types";
|
||||
import { getMcpClient } from "../lib/mcp";
|
||||
|
||||
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) {
|
||||
if (!mcpServer.isActive) continue;
|
||||
|
||||
try {
|
||||
const client = await getMcpClient(mcpServer.serverUrl!, mcpServer.name);
|
||||
|
||||
// List tools
|
||||
const result = await client.listTools();
|
||||
|
||||
// Validate and parse each tool
|
||||
const validTools = await Promise.all(
|
||||
result.tools.map(async (tool) => {
|
||||
try {
|
||||
return McpServerTool.parse(tool);
|
||||
} catch (error) {
|
||||
console.error(`Invalid tool response from ${mcpServer.name}:`, {
|
||||
tool: tool.name,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Filter out invalid tools and convert valid ones
|
||||
tools.push(...validTools
|
||||
.filter((tool): tool is z.infer<typeof McpServerTool> =>
|
||||
tool !== null &&
|
||||
mcpServer.tools.some(t => t.id === tool.name)
|
||||
)
|
||||
.map(mcpTool => convertMcpServerToolToWorkflowTool(mcpTool, mcpServer))
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(`Error fetching MCP tools from ${mcpServer.name}:`, {
|
||||
error: e instanceof Error ? e.message : 'Unknown error',
|
||||
serverUrl: mcpServer.serverUrl
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
export async function fetchMcpToolsForServer(projectId: string, serverName: string): Promise<z.infer<typeof WorkflowTool>[]> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
console.log('[Klavis API] Fetching tools for specific server:', { projectId, serverName });
|
||||
|
||||
const project = await projectsCollection.findOne({
|
||||
_id: projectId,
|
||||
});
|
||||
|
||||
const mcpServer = project?.mcpServers?.find(server => server.name === serverName);
|
||||
if (!mcpServer) {
|
||||
console.error('[Klavis API] Server not found:', { serverName });
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!mcpServer.isActive || !mcpServer.serverUrl) {
|
||||
console.log('[Klavis API] Server is not active or missing URL:', {
|
||||
serverName,
|
||||
isActive: mcpServer.isActive,
|
||||
hasUrl: !!mcpServer.serverUrl
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const tools: z.infer<typeof WorkflowTool>[] = [];
|
||||
|
||||
try {
|
||||
console.log('[Klavis API] Attempting MCP connection:', {
|
||||
serverName,
|
||||
url: mcpServer.serverUrl
|
||||
});
|
||||
|
||||
const client = await getMcpClient(mcpServer.serverUrl, mcpServer.name);
|
||||
|
||||
// List tools
|
||||
const result = await client.listTools();
|
||||
|
||||
// Log just essential info about tools
|
||||
console.log('[Klavis API] Received tools from server:', {
|
||||
serverName,
|
||||
toolCount: result.tools.length,
|
||||
tools: result.tools.map(tool => tool.name).join(', ')
|
||||
});
|
||||
|
||||
// Get all available tools from the server
|
||||
const availableToolNames = new Set(mcpServer.availableTools?.map(t => t.name) || []);
|
||||
|
||||
// Validate and parse each tool
|
||||
const validTools = await Promise.all(
|
||||
result.tools.map(async (tool) => {
|
||||
try {
|
||||
const parsedTool = McpServerTool.parse(tool);
|
||||
return parsedTool;
|
||||
} catch (error) {
|
||||
console.error(`Invalid tool response from ${mcpServer.name}:`, {
|
||||
tool: tool.name,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Filter out invalid tools and convert valid ones
|
||||
const convertedTools = validTools
|
||||
.filter((tool): tool is z.infer<typeof McpServerTool> => tool !== null)
|
||||
.map(mcpTool => {
|
||||
const converted = convertMcpServerToolToWorkflowTool(mcpTool, mcpServer);
|
||||
return converted;
|
||||
});
|
||||
|
||||
tools.push(...convertedTools);
|
||||
|
||||
// Find tools that weren't enriched
|
||||
const enrichedToolNames = new Set(convertedTools.map(t => t.name));
|
||||
const unenrichedTools = Array.from(availableToolNames).filter(name => !enrichedToolNames.has(name));
|
||||
|
||||
if (unenrichedTools.length > 0) {
|
||||
console.log('[Klavis API] Tools that could not be enriched:', {
|
||||
serverName,
|
||||
unenrichedTools,
|
||||
totalAvailable: availableToolNames.size,
|
||||
totalEnriched: enrichedToolNames.size
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[Klavis API] Successfully fetched tools for server:', {
|
||||
serverName,
|
||||
toolCount: tools.length,
|
||||
availableToolCount: availableToolNames.size,
|
||||
tools: tools.map(t => t.name).join(', ')
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[Klavis API] Error fetching MCP tools from ${mcpServer.name}:`, {
|
||||
error: e instanceof Error ? e.message : 'Unknown error',
|
||||
serverUrl: mcpServer.serverUrl
|
||||
});
|
||||
}
|
||||
|
||||
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 toggleMcpTool(
|
||||
projectId: string,
|
||||
serverName: string,
|
||||
toolId: string,
|
||||
shouldAdd: boolean
|
||||
): Promise<void> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// 1. Get the project and find the server
|
||||
const project = await projectsCollection.findOne({ _id: projectId });
|
||||
if (!project) throw new Error("Project not found");
|
||||
|
||||
const mcpServers = project.mcpServers || [];
|
||||
const serverIndex = mcpServers.findIndex(s => s.serverName === serverName);
|
||||
if (serverIndex === -1) throw new Error("Server not found");
|
||||
|
||||
const server = mcpServers[serverIndex];
|
||||
|
||||
if (shouldAdd) {
|
||||
// Add tool if it doesn't exist
|
||||
const toolExists = server.tools.some(t => t.id === toolId);
|
||||
if (!toolExists) {
|
||||
// Find the tool in availableTools to get its parameters
|
||||
const availableTool = server.availableTools?.find(t => t.name === toolId);
|
||||
|
||||
// Create a new tool with the parameters from availableTools
|
||||
const newTool = {
|
||||
id: toolId,
|
||||
name: toolId,
|
||||
description: availableTool?.description || '',
|
||||
parameters: availableTool?.parameters || {
|
||||
type: 'object' as const,
|
||||
properties: {},
|
||||
required: []
|
||||
}
|
||||
};
|
||||
server.tools.push(newTool);
|
||||
}
|
||||
} else {
|
||||
// Remove tool if it exists
|
||||
server.tools = server.tools.filter(t => t.id !== toolId);
|
||||
}
|
||||
|
||||
// Update the project
|
||||
await projectsCollection.updateOne(
|
||||
{ _id: projectId },
|
||||
{ $set: { mcpServers } }
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSelectedMcpTools(projectId: string, serverName: string): Promise<string[]> {
|
||||
await projectAuthCheck(projectId);
|
||||
const project = await projectsCollection.findOne({ _id: projectId });
|
||||
if (!project) return [];
|
||||
|
||||
const server = project.mcpServers?.find(s => s.serverName === serverName);
|
||||
if (!server) return [];
|
||||
|
||||
return server.tools.map(t => t.id);
|
||||
}
|
||||
|
||||
export async function testMcpTool(
|
||||
projectId: string,
|
||||
serverName: string,
|
||||
toolId: string,
|
||||
parameters: Record<string, any>
|
||||
): Promise<any> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const project = await projectsCollection.findOne({
|
||||
_id: projectId,
|
||||
});
|
||||
|
||||
// Find the server by name in mcpServers array
|
||||
const mcpServer = project?.mcpServers?.find(server => server.name === serverName);
|
||||
if (!mcpServer) {
|
||||
throw new Error(`Server ${serverName} not found`);
|
||||
}
|
||||
|
||||
if (!mcpServer.isActive) {
|
||||
throw new Error(`Server ${serverName} is not active`);
|
||||
}
|
||||
|
||||
if (!mcpServer.serverUrl) {
|
||||
throw new Error(`Server ${serverName} has no URL configured`);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[MCP Test] Attempting to test tool:', {
|
||||
serverName,
|
||||
serverUrl: mcpServer.serverUrl,
|
||||
toolId
|
||||
});
|
||||
|
||||
const client = await getMcpClient(mcpServer.serverUrl, mcpServer.name);
|
||||
|
||||
console.log('[MCP Test] Connected to server, calling tool:', {
|
||||
toolId,
|
||||
parameters
|
||||
});
|
||||
|
||||
// Execute the tool with the correct parameter format
|
||||
const result = await client.callTool({
|
||||
name: toolId,
|
||||
arguments: parameters
|
||||
});
|
||||
|
||||
console.log('[MCP Test] Tool execution completed:', {
|
||||
toolId,
|
||||
success: true
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
} catch (e) {
|
||||
console.error(`[MCP Test] Error testing tool from ${mcpServer.name}:`, {
|
||||
error: e instanceof Error ? e.message : 'Unknown error',
|
||||
serverUrl: mcpServer.serverUrl,
|
||||
toolId,
|
||||
parameters
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,6 @@ import { Info, RefreshCw, RefreshCcw, Lock, Wrench } from 'lucide-react';
|
|||
import { clsx } from 'clsx';
|
||||
import { MCPServer, McpTool } from '@/app/lib/types/types';
|
||||
import type { z } from 'zod';
|
||||
import { TestToolModal } from './TestToolModal';
|
||||
|
||||
type McpServerType = z.infer<typeof MCPServer>;
|
||||
type McpToolType = z.infer<typeof McpTool>;
|
||||
|
|
@ -484,15 +483,6 @@ export function ToolManagementPanel({
|
|||
</div>
|
||||
</div>
|
||||
</SlidePanel>
|
||||
|
||||
{testingTool && (
|
||||
<TestToolModal
|
||||
isOpen={!!testingTool}
|
||||
onClose={() => setTestingTool(null)}
|
||||
tool={testingTool}
|
||||
server={server}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,674 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { MCPServer, McpTool } from '@/app/lib/types/types';
|
||||
import { testMcpTool } from '@/app/actions/mcp.actions';
|
||||
import { Copy, ChevronDown, ChevronRight, X, Trash2 } from 'lucide-react';
|
||||
import type { z } from 'zod';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type McpServerType = z.infer<typeof MCPServer>;
|
||||
type McpToolType = z.infer<typeof McpTool>;
|
||||
|
||||
interface TestToolModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tool: McpToolType;
|
||||
server: McpServerType;
|
||||
}
|
||||
|
||||
export function TestToolModal({ isOpen, onClose, tool, server }: TestToolModalProps) {
|
||||
const params = useParams();
|
||||
const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId?.[0];
|
||||
if (!projectId) throw new Error('Project ID is required');
|
||||
|
||||
// Prevent body scroll when modal is open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [onClose]);
|
||||
|
||||
const [parameters, setParameters] = useState<Record<string, any>>({});
|
||||
const [response, setResponse] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showRequest, setShowRequest] = useState(false);
|
||||
const [showInputs, setShowInputs] = useState(true);
|
||||
const [copySuccess, setCopySuccess] = useState<'request' | 'response' | null>(null);
|
||||
const [showOnlyRequired, setShowOnlyRequired] = useState(false);
|
||||
const [showDescriptions, setShowDescriptions] = useState(true);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [showRawResponse, setShowRawResponse] = useState(false);
|
||||
|
||||
const handleReset = () => {
|
||||
setParameters({});
|
||||
setResponse(null);
|
||||
setError(null);
|
||||
setShowRequest(false);
|
||||
setShowInputs(true);
|
||||
setValidationError(null);
|
||||
setShowRawResponse(false);
|
||||
};
|
||||
|
||||
const handleParameterChange = (name: string, value: any) => {
|
||||
// Handle nested object updates
|
||||
if (name.includes('.')) {
|
||||
const parts = name.split('.');
|
||||
const topLevel = parts[0];
|
||||
const rest = parts.slice(1);
|
||||
|
||||
setParameters(prev => {
|
||||
const current = prev[topLevel] || {};
|
||||
let temp = current;
|
||||
for (let i = 0; i < rest.length - 1; i++) {
|
||||
temp[rest[i]] = temp[rest[i]] || {};
|
||||
temp = temp[rest[i]];
|
||||
}
|
||||
temp[rest[rest.length - 1]] = value;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[topLevel]: current
|
||||
};
|
||||
});
|
||||
}
|
||||
// Handle array index updates
|
||||
else if (name.includes('[') && name.includes(']')) {
|
||||
const matches = name.match(/^([^\[]+)\[(\d+)\]$/);
|
||||
if (matches) {
|
||||
const [_, arrayName, index] = matches;
|
||||
setParameters(prev => {
|
||||
const array = Array.isArray(prev[arrayName]) ? [...prev[arrayName]] : [];
|
||||
array[parseInt(index, 10)] = value;
|
||||
return {
|
||||
...prev,
|
||||
[arrayName]: array
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
// Handle regular updates
|
||||
else {
|
||||
setParameters(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
}
|
||||
setValidationError(null);
|
||||
};
|
||||
|
||||
const validateRequiredParameters = () => {
|
||||
const missingParams = tool.parameters?.required?.filter(param => {
|
||||
const value = parameters[param];
|
||||
return value === undefined || value === null || value === '';
|
||||
}) || [];
|
||||
|
||||
if (missingParams.length > 0) {
|
||||
setValidationError(`Please fill in all required parameters: ${missingParams.join(', ')}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCopy = async (type: 'request' | 'response') => {
|
||||
const textToCopy = type === 'request'
|
||||
? JSON.stringify({ name: tool.id, arguments: parameters }, null, 2)
|
||||
: (typeof response === 'string' ? response : JSON.stringify(response, null, 2));
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(textToCopy);
|
||||
setCopySuccess(type);
|
||||
setTimeout(() => setCopySuccess(null), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
setValidationError(null);
|
||||
if (!validateRequiredParameters()) return;
|
||||
|
||||
// Collapse both sections
|
||||
setShowInputs(false);
|
||||
setShowRequest(false);
|
||||
|
||||
setResponse(null);
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const result = await testMcpTool(projectId, server.name, tool.id, parameters);
|
||||
setResponse(result);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'An error occurred while testing the tool');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderParameterInput = (paramName: string, schema: any) => {
|
||||
const value = parameters[paramName] ?? (schema.type === 'array' ? [] : schema.type === 'object' ? {} : '');
|
||||
|
||||
switch (schema.type) {
|
||||
case 'array':
|
||||
const arrayValue = Array.isArray(value) ? value : value ? [value] : [];
|
||||
const itemSchema = schema.items || { type: 'string' };
|
||||
|
||||
const handleArrayItemChange = (index: number, itemValue: any) => {
|
||||
const newArray = [...arrayValue];
|
||||
newArray[index] = itemValue;
|
||||
handleParameterChange(paramName, newArray);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{arrayValue.map((item: any, index: number) => (
|
||||
<div key={index} className="flex gap-2 items-start">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 pt-2 min-w-[24px]">
|
||||
{index + 1}:
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{itemSchema.type === 'string' ? (
|
||||
<Input
|
||||
type="text"
|
||||
value={item || ''}
|
||||
onChange={(e) => handleArrayItemChange(index, e.target.value)}
|
||||
placeholder="Enter value"
|
||||
className="focus:ring-0 focus:ring-offset-0 ring-0! ring-offset-0! focus:outline-none"
|
||||
/>
|
||||
) : itemSchema.type === 'number' || itemSchema.type === 'integer' ? (
|
||||
<Input
|
||||
type="number"
|
||||
value={item || ''}
|
||||
step={itemSchema.type === 'integer' ? '1' : 'any'}
|
||||
min={itemSchema.minimum}
|
||||
max={itemSchema.maximum}
|
||||
onChange={(e) => {
|
||||
const val = itemSchema.type === 'integer' ?
|
||||
parseInt(e.target.value, 10) :
|
||||
parseFloat(e.target.value);
|
||||
handleArrayItemChange(index, isNaN(val) ? '' : val);
|
||||
}}
|
||||
placeholder="Enter value"
|
||||
className="focus:ring-0 focus:ring-offset-0 ring-0! ring-offset-0! focus:outline-none"
|
||||
/>
|
||||
) : itemSchema.type === 'boolean' ? (
|
||||
<div className="scale-75 origin-left">
|
||||
<Switch
|
||||
checked={!!item}
|
||||
onCheckedChange={(checked) => handleArrayItemChange(index, checked)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
{renderParameterInput(paramName, {
|
||||
...itemSchema,
|
||||
value: item,
|
||||
onChange: (newValue: any) => handleArrayItemChange(index, newValue)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const newArray = arrayValue.filter((_, i) => i !== index);
|
||||
handleParameterChange(paramName, newArray);
|
||||
}}
|
||||
className="px-2 h-9 hover:bg-transparent border-transparent"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-gray-500 hover:text-red-500 transition-colors" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const defaultValue = itemSchema.type === 'object' ? {} :
|
||||
itemSchema.type === 'array' ? [] :
|
||||
itemSchema.type === 'boolean' ? false : '';
|
||||
handleParameterChange(paramName, [...arrayValue, defaultValue]);
|
||||
}}
|
||||
className="text-xs text-gray-500 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-transparent border-transparent"
|
||||
>
|
||||
Add item
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'object':
|
||||
if (!schema.properties) return null;
|
||||
const objectValue = typeof value === 'object' ? value : {};
|
||||
return (
|
||||
<div className="space-y-4 border-l-2 border-gray-200 dark:border-gray-700 pl-4 mt-2">
|
||||
{Object.entries(schema.properties).map(([key, propSchema]: [string, any]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{key}
|
||||
{schema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
{renderParameterInput(
|
||||
`${paramName}.${key}`,
|
||||
propSchema
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'string':
|
||||
if (schema.enum) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => handleParameterChange(paramName, e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-md
|
||||
bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100
|
||||
focus:outline-none hover:border-gray-300 dark:hover:border-gray-600
|
||||
focus:ring-0 focus:ring-offset-0 ring-0! ring-offset-0!"
|
||||
>
|
||||
<option value="" disabled>Select {paramName}</option>
|
||||
{schema.enum.map((opt: string) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
if (schema.format === 'date-time') {
|
||||
return (
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={value}
|
||||
onChange={(e) => handleParameterChange(paramName, e.target.value)}
|
||||
className="focus:ring-0 focus:ring-offset-0 ring-0! ring-offset-0! focus:outline-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (schema.format === 'date') {
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={(e) => handleParameterChange(paramName, e.target.value)}
|
||||
className="focus:ring-0 focus:ring-offset-0 ring-0! ring-offset-0! focus:outline-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (schema.format === 'time') {
|
||||
return (
|
||||
<Input
|
||||
type="time"
|
||||
value={value}
|
||||
onChange={(e) => handleParameterChange(paramName, e.target.value)}
|
||||
className="focus:ring-0 focus:ring-offset-0 ring-0! ring-offset-0! focus:outline-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => handleParameterChange(paramName, e.target.value)}
|
||||
placeholder={`Enter ${paramName}`}
|
||||
className="focus:ring-0 focus:ring-offset-0 ring-0! ring-offset-0! focus:outline-none"
|
||||
/>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
case 'integer':
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={value}
|
||||
step={schema.type === 'integer' ? '1' : 'any'}
|
||||
min={schema.minimum}
|
||||
max={schema.maximum}
|
||||
onChange={(e) => {
|
||||
const val = schema.type === 'integer' ?
|
||||
parseInt(e.target.value, 10) :
|
||||
parseFloat(e.target.value);
|
||||
handleParameterChange(paramName, isNaN(val) ? '' : val);
|
||||
}}
|
||||
placeholder={`Enter ${paramName}`}
|
||||
className="focus:ring-0 focus:ring-offset-0 ring-0! ring-offset-0! focus:outline-none"
|
||||
/>
|
||||
);
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<div className="scale-75 origin-left">
|
||||
<Switch
|
||||
checked={!!value}
|
||||
onCheckedChange={(checked) => handleParameterChange(paramName, checked)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'null':
|
||||
return (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
Null value
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => handleParameterChange(paramName, e.target.value)}
|
||||
placeholder={`Enter ${paramName}`}
|
||||
className="focus:ring-0 focus:ring-offset-0 ring-0! ring-offset-0! focus:outline-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getFilteredParameters = () => {
|
||||
if (!tool.parameters?.properties) return [];
|
||||
|
||||
return Object.entries(tool.parameters.properties).filter(([name]) => {
|
||||
if (showOnlyRequired) {
|
||||
return tool.parameters?.required?.includes(name);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const formatResponse = (response: any): string => {
|
||||
try {
|
||||
if (showRawResponse) {
|
||||
return typeof response === 'string' ? response : JSON.stringify(response);
|
||||
}
|
||||
|
||||
// Convert to object if it's a string
|
||||
const obj = typeof response === 'string' ? JSON.parse(response) : response;
|
||||
|
||||
// Handle nested structures and attempt to parse JSON strings
|
||||
const processValue = (value: any): any => {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
// Try to parse string as JSON if it looks like JSON
|
||||
if ((value.startsWith('{') && value.endsWith('}')) ||
|
||||
(value.startsWith('[') && value.endsWith(']'))) {
|
||||
const parsed = JSON.parse(value);
|
||||
return processValue(parsed); // Recursively process the parsed JSON
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, treat as regular string
|
||||
}
|
||||
// Preserve explicit newlines in regular strings
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(processValue);
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const processed: any = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
processed[k] = processValue(v);
|
||||
}
|
||||
return processed;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
// Process and stringify with proper indentation
|
||||
const processed = processValue(obj);
|
||||
const stringified = JSON.stringify(processed, null, 2);
|
||||
|
||||
// Replace escaped newlines and handle nested JSON formatting
|
||||
return stringified
|
||||
.replace(/\\n/g, '\n') // Convert escaped newlines to actual newlines
|
||||
.replace(/"\{/g, '{') // Remove quotes around nested JSON objects
|
||||
.replace(/\}"/g, '}') // Remove quotes around nested JSON objects
|
||||
.replace(/"\[/g, '[') // Remove quotes around nested JSON arrays
|
||||
.replace(/\]"/g, ']'); // Remove quotes around nested JSON arrays
|
||||
} catch (e) {
|
||||
// If JSON parsing fails, return as is
|
||||
return String(response);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-hidden">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-lg shadow-xl w-[900px] max-w-[90vw] max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Test {tool.name}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5 text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 p-6 space-y-6">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{tool.description}
|
||||
</div>
|
||||
|
||||
{validationError && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 p-3 rounded-md mb-6">
|
||||
{validationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowInputs(!showInputs)}
|
||||
className="flex items-center gap-1 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
>
|
||||
{showInputs ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
Inputs
|
||||
</button>
|
||||
|
||||
{showInputs && (
|
||||
<div className="space-y-6 pl-5 mt-4">
|
||||
<div className="flex flex-col gap-2 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="scale-75 origin-left">
|
||||
<Switch
|
||||
checked={showOnlyRequired}
|
||||
onCheckedChange={setShowOnlyRequired}
|
||||
/>
|
||||
</div>
|
||||
<label className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Show only required parameters
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="scale-75 origin-left">
|
||||
<Switch
|
||||
checked={showDescriptions}
|
||||
onCheckedChange={setShowDescriptions}
|
||||
/>
|
||||
</div>
|
||||
<label className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Show parameter descriptions
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{getFilteredParameters().map(([name, schema]) => (
|
||||
<div key={name} className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{name}
|
||||
{tool.parameters?.required?.includes(name) && (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
{showDescriptions && schema.description && (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{schema.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{renderParameterInput(name, schema)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 p-3 rounded-md mt-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setShowRequest(!showRequest)}
|
||||
className="flex items-center gap-1 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
>
|
||||
{showRequest ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
Request
|
||||
</button>
|
||||
{showRequest && (
|
||||
<button
|
||||
onClick={() => handleCopy('request')}
|
||||
className="p-1 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 flex items-center gap-1.5"
|
||||
title="Copy request"
|
||||
>
|
||||
<Copy className="h-4 w-4 text-gray-500 dark:text-gray-400" />
|
||||
{copySuccess === 'request' && (
|
||||
<span className="text-xs text-green-600 dark:text-green-400">
|
||||
Copied!
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRequest && (
|
||||
<div className="pl-5 mt-4">
|
||||
<pre className="text-sm bg-gray-50 dark:bg-gray-800 p-3 rounded-md overflow-auto max-h-60">
|
||||
{JSON.stringify({ name: tool.id, arguments: parameters }, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Response section - shown when loading or when there's a response */}
|
||||
{(isLoading || response) && (
|
||||
<div className="flex flex-col flex-1 min-h-0 mt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">Response</h4>
|
||||
{response && (
|
||||
<>
|
||||
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Raw
|
||||
</label>
|
||||
<div className="scale-75 origin-right">
|
||||
<Switch
|
||||
checked={showRawResponse}
|
||||
onCheckedChange={setShowRawResponse}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{response && (
|
||||
<button
|
||||
onClick={() => handleCopy('response')}
|
||||
className="p-1 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 flex items-center gap-1.5"
|
||||
title="Copy response"
|
||||
>
|
||||
<Copy className="h-4 w-4 text-gray-500 dark:text-gray-400" />
|
||||
{copySuccess === 'response' && (
|
||||
<span className="text-xs text-green-600 dark:text-green-400">
|
||||
Copied!
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="pl-5 mt-4 flex-1 min-h-0">
|
||||
{isLoading ? (
|
||||
<div className="h-full bg-gray-50 dark:bg-gray-800 rounded-md p-3 flex items-start">
|
||||
<div className="flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-gray-300 dark:border-gray-600 border-t-blue-600 dark:border-t-blue-400" />
|
||||
Awaiting response...
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<pre
|
||||
className={clsx(
|
||||
"text-sm bg-gray-50 dark:bg-gray-800 p-3 rounded-md overflow-auto h-full",
|
||||
!showRawResponse && "whitespace-pre-wrap break-all"
|
||||
)}
|
||||
>
|
||||
{formatResponse(response)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 px-6 py-4 border-t border-gray-200 dark:border-gray-800">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleReset}
|
||||
disabled={isLoading}
|
||||
className="text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20
|
||||
border-red-200 dark:border-red-800 hover:border-red-300 dark:hover:border-red-700"
|
||||
>
|
||||
<span className="text-sm">Reset</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleTest}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span className="text-sm">{isLoading ? 'Awaiting...' : 'Test'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue