mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-03 19:25:19 +02:00
housekeeping
This commit is contained in:
parent
775a64c5a8
commit
a298036b4b
77 changed files with 2 additions and 14090 deletions
|
|
@ -1,62 +1,13 @@
|
|||
'use server';
|
||||
import { WebpageCrawlResponse } from "../lib/types/tool_types";
|
||||
import { webpagesCollection } from "../lib/mongodb";
|
||||
import { z } from 'zod';
|
||||
import FirecrawlApp, { ScrapeResponse } from '@mendable/firecrawl-js';
|
||||
import { getAgenticResponseStreamId } from "../lib/utils";
|
||||
import { check_query_limit } from "../lib/rate_limiting";
|
||||
import { QueryLimitError } from "../lib/client_utils";
|
||||
import { projectAuthCheck } from "./project_actions";
|
||||
import { authorizeUserAction } from "./billing_actions";
|
||||
import { Workflow, WorkflowTool } from "../lib/types/workflow_types";
|
||||
import { Workflow } from "../lib/types/workflow_types";
|
||||
import { Message } from "@/app/lib/types/types";
|
||||
|
||||
const crawler = new FirecrawlApp({ apiKey: process.env.FIRECRAWL_API_KEY || '' });
|
||||
|
||||
export async function scrapeWebpage(url: string): Promise<z.infer<typeof WebpageCrawlResponse>> {
|
||||
const page = await webpagesCollection.findOne({
|
||||
"_id": url,
|
||||
lastUpdatedAt: {
|
||||
'$gte': new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(), // 24 hours
|
||||
},
|
||||
});
|
||||
if (page) {
|
||||
// console.log("found webpage in db", url);
|
||||
return {
|
||||
title: page.title,
|
||||
content: page.contentSimple,
|
||||
};
|
||||
}
|
||||
|
||||
// otherwise use firecrawl
|
||||
const scrapeResult = await crawler.scrapeUrl(
|
||||
url,
|
||||
{
|
||||
formats: ['markdown'],
|
||||
onlyMainContent: true
|
||||
}
|
||||
) as ScrapeResponse;
|
||||
|
||||
// save the webpage using upsert
|
||||
await webpagesCollection.updateOne(
|
||||
{ _id: url },
|
||||
{
|
||||
$set: {
|
||||
title: scrapeResult.metadata?.title || '',
|
||||
contentSimple: scrapeResult.markdown || '',
|
||||
lastUpdatedAt: (new Date()).toISOString(),
|
||||
}
|
||||
},
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
// console.log("crawled webpage", url);
|
||||
return {
|
||||
title: scrapeResult.metadata?.title || '',
|
||||
content: scrapeResult.markdown || '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAssistantResponseStreamId(
|
||||
projectId: string,
|
||||
workflow: z.infer<typeof Workflow>,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import { WithStringId } from "../lib/types/types";
|
|||
import { DataSourceDoc } from "../lib/types/datasource_types";
|
||||
import { DataSource } from "../lib/types/datasource_types";
|
||||
import { uploadsS3Client } from "../lib/uploads_s3_client";
|
||||
import { USE_RAG_S3_UPLOADS } from "../lib/feature_flags";
|
||||
|
||||
export async function getDataSource(projectId: string, sourceId: string): Promise<WithStringId<z.infer<typeof DataSource>>> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
|
|
|||
|
|
@ -1,906 +0,0 @@
|
|||
'use server';
|
||||
|
||||
import { projectAuthCheck } from './project_actions';
|
||||
import { z } from 'zod';
|
||||
import { MCPServer, McpTool, McpServerResponse, McpServerTool } from '../lib/types/types';
|
||||
import { projectsCollection } from '../lib/mongodb';
|
||||
import { fetchMcpTools, toggleMcpTool } from './mcp_actions';
|
||||
import { fetchMcpToolsForServer } from './mcp_actions';
|
||||
import { headers } from 'next/headers';
|
||||
import { authorizeUserAction } from './billing_actions';
|
||||
import { redisClient } from '../lib/redis';
|
||||
import { SERVER_URL_PARAMS, SERVER_CLIENT_ID_MAP } from '../lib/constants/klavis';
|
||||
|
||||
type McpServerType = z.infer<typeof MCPServer>;
|
||||
type McpToolType = z.infer<typeof McpTool>;
|
||||
type McpServerResponseType = z.infer<typeof McpServerResponse>;
|
||||
|
||||
// Internal API Response Types
|
||||
interface KlavisServerMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tools: {
|
||||
name: string;
|
||||
description: string;
|
||||
}[];
|
||||
authNeeded: boolean;
|
||||
}
|
||||
|
||||
interface GetAllServersResponse {
|
||||
servers: KlavisServerMetadata[];
|
||||
}
|
||||
|
||||
interface CreateServerInstanceResponse {
|
||||
serverUrl: string;
|
||||
instanceId: string;
|
||||
}
|
||||
|
||||
interface DeleteServerInstanceResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface UserInstance {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
tools: {
|
||||
name: string;
|
||||
description: string;
|
||||
authNeeded: boolean;
|
||||
isAuthenticated: boolean;
|
||||
}[] | null;
|
||||
authNeeded: boolean;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
interface GetUserInstancesResponse {
|
||||
instances: UserInstance[];
|
||||
}
|
||||
|
||||
// Add type for raw MCP tool response at the top with other types
|
||||
interface RawMcpTool {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: string | {
|
||||
type: string;
|
||||
properties: Record<string, any>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const KLAVIS_BASE_URL = 'https://api.klavis.ai';
|
||||
|
||||
interface KlavisApiCallOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
body?: Record<string, any>;
|
||||
additionalHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
async function klavisApiCall<T>(
|
||||
endpoint: string,
|
||||
options: KlavisApiCallOptions = {}
|
||||
): Promise<T> {
|
||||
const { method = 'GET', body, additionalHeaders = {} } = options;
|
||||
const startTime = performance.now();
|
||||
const url = `${KLAVIS_BASE_URL}${endpoint}`;
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${process.env.KLAVIS_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
...additionalHeaders
|
||||
};
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
...(body ? { body: JSON.stringify(body) } : {})
|
||||
};
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
const endTime = performance.now();
|
||||
|
||||
console.log('[Klavis API] Response time:', {
|
||||
url,
|
||||
method,
|
||||
durationMs: Math.round(endTime - startTime)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
return await response.json() as T;
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
console.error('[Klavis API] Failed call:', {
|
||||
url,
|
||||
method,
|
||||
durationMs: Math.round(endTime - startTime),
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Lists all active server instances for a given project
|
||||
export async function listActiveServerInstances(projectId: string): Promise<UserInstance[]> {
|
||||
try {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
user_id: projectId,
|
||||
platform_name: 'Rowboat'
|
||||
});
|
||||
|
||||
console.log('[Klavis API] Fetching active instances:', { projectId, platformName: 'Rowboat' });
|
||||
|
||||
const endpoint = `/user/instances?${queryParams}`;
|
||||
const data = await klavisApiCall<GetUserInstancesResponse>(endpoint);
|
||||
|
||||
// Only show instances that are authenticated or need auth
|
||||
const relevantInstances = data.instances.filter(i => i.isAuthenticated || i.authNeeded);
|
||||
console.log('[Klavis API] Active instances:', {
|
||||
count: relevantInstances.length,
|
||||
authenticated: relevantInstances.filter(i => i.isAuthenticated).map(i => i.name).join(', '),
|
||||
needsAuth: relevantInstances.filter(i => i.authNeeded && !i.isAuthenticated).map(i => i.name).join(', ')
|
||||
});
|
||||
|
||||
return data.instances;
|
||||
} catch (error) {
|
||||
console.error('[Klavis API] Error listing active instances:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichToolsWithParameters(
|
||||
projectId: string,
|
||||
serverName: string,
|
||||
basicTools: { name: string; description: string }[],
|
||||
isNewlyEnabled: boolean = false
|
||||
): Promise<McpToolType[]> {
|
||||
try {
|
||||
console.log(`[Klavis API] Starting tool enrichment for ${serverName}`);
|
||||
const enrichedTools = await fetchMcpToolsForServer(projectId, serverName);
|
||||
|
||||
if (enrichedTools.length === 0) {
|
||||
console.log(`[Klavis API] No tools enriched for ${serverName}`);
|
||||
return basicTools.map(tool => ({
|
||||
id: tool.name,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
console.log(`[Klavis API] Processing ${enrichedTools.length} tools for ${serverName}`);
|
||||
|
||||
// Create a map of enriched tools for this server
|
||||
const enrichedToolMap = new Map(
|
||||
enrichedTools.map(tool => [tool.name, {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: {
|
||||
type: 'object' as const,
|
||||
properties: tool.parameters?.properties || {},
|
||||
required: tool.parameters?.required || []
|
||||
}
|
||||
}])
|
||||
);
|
||||
|
||||
// Find tools that couldn't be enriched
|
||||
const unenrichedTools = basicTools
|
||||
.filter(tool => !enrichedToolMap.has(tool.name))
|
||||
.map(tool => tool.name);
|
||||
|
||||
if (unenrichedTools.length > 0) {
|
||||
console.log('[Klavis API] Tools that could not be enriched:', {
|
||||
serverName,
|
||||
unenrichedTools: unenrichedTools.join(', ')
|
||||
});
|
||||
}
|
||||
|
||||
// Enrich the basic tools with parameters and descriptions
|
||||
const result = basicTools.map(basicTool => {
|
||||
const enrichedTool = enrichedToolMap.get(basicTool.name);
|
||||
|
||||
const tool: McpToolType = {
|
||||
id: basicTool.name,
|
||||
name: basicTool.name,
|
||||
description: enrichedTool?.description || basicTool.description || '',
|
||||
parameters: enrichedTool?.parameters || {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
}
|
||||
};
|
||||
|
||||
return tool;
|
||||
});
|
||||
|
||||
console.log('[Klavis API] Tools processed:', {
|
||||
serverName,
|
||||
toolCount: result.length,
|
||||
tools: result.map(t => ({
|
||||
name: t.name,
|
||||
paramCount: Object.keys(t.parameters?.properties || {}).length,
|
||||
hasParams: t.parameters && Object.keys(t.parameters.properties).length > 0
|
||||
}))
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[Klavis API] Error enriching tools with parameters:', {
|
||||
serverName,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
basicToolCount: basicTools.length
|
||||
});
|
||||
// Return basic tools with empty parameters if enrichment fails
|
||||
return basicTools.map(tool => ({
|
||||
id: tool.name,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Modify listAvailableMcpServers to use enriched tools
|
||||
export async function listAvailableMcpServers(projectId: string): Promise<McpServerResponseType> {
|
||||
try {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
console.log('[Klavis API] Starting server list fetch:', { projectId });
|
||||
|
||||
// Get MongoDB project data first
|
||||
const project = await projectsCollection.findOne({ _id: projectId });
|
||||
const mongodbServers = project?.mcpServers || [];
|
||||
const mongodbServerMap = new Map(mongodbServers.map(server => [server.name, server]));
|
||||
|
||||
console.log('[Klavis API] Found ', mongodbServers.length, ' MongoDB servers');
|
||||
|
||||
const serversEndpoint = '/mcp-server/servers';
|
||||
const rawData = await klavisApiCall<GetAllServersResponse>(serversEndpoint, {
|
||||
additionalHeaders: { 'Accept': 'application/json' }
|
||||
});
|
||||
|
||||
console.log('[Klavis API] Raw server response:', {
|
||||
serverCount: rawData.servers.length,
|
||||
servers: rawData.servers.map(s => s.name).join(', ')
|
||||
});
|
||||
|
||||
if (!rawData || !rawData.servers || !Array.isArray(rawData.servers)) {
|
||||
console.error('[Klavis API] Invalid response format:', rawData);
|
||||
return { data: null, error: 'Invalid response format from server' };
|
||||
}
|
||||
|
||||
// Get active instances for comparison
|
||||
const queryParams = new URLSearchParams({
|
||||
user_id: projectId,
|
||||
platform_name: 'Rowboat'
|
||||
});
|
||||
|
||||
const instancesEndpoint = `/user/instances?${queryParams}`;
|
||||
let activeInstances: UserInstance[] = [];
|
||||
|
||||
try {
|
||||
const instancesData = await klavisApiCall<GetUserInstancesResponse>(instancesEndpoint);
|
||||
activeInstances = instancesData.instances;
|
||||
console.log('[Klavis API] Active instances:', {
|
||||
count: activeInstances.length,
|
||||
authenticated: activeInstances.filter(i => i.isAuthenticated).map(i => i.name).join(', '),
|
||||
needsAuth: activeInstances.filter(i => i.authNeeded && !i.isAuthenticated).map(i => i.name).join(', ')
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Klavis API] Failed to fetch user instances:', error);
|
||||
}
|
||||
|
||||
const activeInstanceMap = new Map(activeInstances.map(instance => [instance.name, instance]));
|
||||
|
||||
// Transform and enrich the data
|
||||
const transformedServers = [];
|
||||
let eligibleCount = 0;
|
||||
let serversWithToolsCount = 0;
|
||||
|
||||
for (const server of rawData.servers) {
|
||||
const activeInstance = activeInstanceMap.get(server.name);
|
||||
const mongodbServer = mongodbServerMap.get(server.name);
|
||||
|
||||
// Determine server eligibility
|
||||
const isActive = !!activeInstance;
|
||||
const authNeeded = activeInstance ? activeInstance.authNeeded : (server.authNeeded || false);
|
||||
const isAuthenticated = activeInstance ? activeInstance.isAuthenticated : false;
|
||||
const isEligible = isActive && (!authNeeded || isAuthenticated);
|
||||
|
||||
// Get basic tools data first
|
||||
const basicTools = (server.tools || []).map(tool => ({
|
||||
id: tool.name || '',
|
||||
name: tool.name || '',
|
||||
description: tool.description || '',
|
||||
}));
|
||||
|
||||
let availableTools: McpToolType[];
|
||||
let selectedTools: McpToolType[];
|
||||
|
||||
// Only use MongoDB data for eligible servers
|
||||
if (isEligible) {
|
||||
eligibleCount++;
|
||||
console.log('[Klavis API] Processing server:', server.name);
|
||||
|
||||
// Use MongoDB data if available
|
||||
availableTools = mongodbServer?.availableTools || basicTools;
|
||||
selectedTools = mongodbServer?.tools || [];
|
||||
|
||||
if (selectedTools.length > 0) {
|
||||
serversWithToolsCount++;
|
||||
}
|
||||
} else {
|
||||
// For non-eligible servers, just use basic data
|
||||
availableTools = basicTools;
|
||||
selectedTools = [];
|
||||
}
|
||||
|
||||
transformedServers.push({
|
||||
...server,
|
||||
instanceId: activeInstance?.id || server.id,
|
||||
serverName: server.name,
|
||||
tools: selectedTools,
|
||||
availableTools,
|
||||
isActive,
|
||||
authNeeded,
|
||||
isAuthenticated,
|
||||
requiresAuth: server.authNeeded || false,
|
||||
serverUrl: mongodbServer?.serverUrl
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[Klavis API] Server processing complete:', {
|
||||
totalServers: transformedServers.length,
|
||||
eligibleServers: eligibleCount,
|
||||
serversWithTools: serversWithToolsCount
|
||||
});
|
||||
|
||||
return { data: transformedServers, error: null };
|
||||
} catch (error: any) {
|
||||
console.error('[Klavis API] Server list error:', error.message);
|
||||
return { data: null, error: error.message || 'An unexpected error occurred' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMcpServerInstance(
|
||||
serverName: string,
|
||||
projectId: string,
|
||||
platformName: string,
|
||||
): Promise<CreateServerInstanceResponse> {
|
||||
try {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const requestBody = {
|
||||
serverName,
|
||||
userId: projectId,
|
||||
platformName,
|
||||
connectionType: "StreamableHttp",
|
||||
};
|
||||
console.log('[Klavis API] Creating server instance:', requestBody);
|
||||
|
||||
const endpoint = '/mcp-server/instance/create';
|
||||
const result = await klavisApiCall<CreateServerInstanceResponse>(endpoint, {
|
||||
method: 'POST',
|
||||
body: requestBody
|
||||
});
|
||||
|
||||
console.log('[Klavis API] Created server instance:', result);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
console.error('[Klavis API] Error creating instance:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to filter eligible servers
|
||||
function getEligibleServers(servers: McpServerType[]): McpServerType[] {
|
||||
return servers.filter(server =>
|
||||
server.isActive && (!server.authNeeded || server.isAuthenticated)
|
||||
);
|
||||
}
|
||||
|
||||
async function getServerInstance(instanceId: string): Promise<{
|
||||
instanceId: string;
|
||||
authNeeded: boolean;
|
||||
isAuthenticated: boolean;
|
||||
serverName: string;
|
||||
serverUrl?: string;
|
||||
}> {
|
||||
const endpoint = `/mcp-server/instance/get/${instanceId}`;
|
||||
return await klavisApiCall(endpoint);
|
||||
}
|
||||
|
||||
export async function updateProjectServers(projectId: string, targetServerName?: string): Promise<void> {
|
||||
try {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
console.log('[Auth] Starting server data update:', { projectId, targetServerName });
|
||||
|
||||
// Get current MongoDB data
|
||||
const project = await projectsCollection.findOne({ _id: projectId });
|
||||
if (!project) {
|
||||
console.error('[Auth] Project not found in MongoDB:', { projectId });
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
const mcpServers = project.mcpServers || [];
|
||||
|
||||
// Get active instances to find auth status
|
||||
const instances = await listActiveServerInstances(projectId);
|
||||
|
||||
// If targetServerName is provided, only process that server
|
||||
const instancesToProcess = targetServerName
|
||||
? instances.filter(i => i.name === targetServerName)
|
||||
: instances;
|
||||
|
||||
// For each active instance, get its current status
|
||||
for (const instance of instancesToProcess) {
|
||||
if (!instance.id) continue;
|
||||
|
||||
try {
|
||||
// Get fresh instance data
|
||||
const serverInstance = await getServerInstance(instance.id);
|
||||
|
||||
// Find this server in MongoDB
|
||||
const serverIndex = mcpServers.findIndex(s => s.name === instance.name);
|
||||
if (serverIndex === -1) continue;
|
||||
|
||||
// Update server readiness based on auth status
|
||||
const isReady = !serverInstance.authNeeded || serverInstance.isAuthenticated;
|
||||
|
||||
// Update existing server
|
||||
const updatedServer = {
|
||||
...mcpServers[serverIndex],
|
||||
isAuthenticated: serverInstance.isAuthenticated,
|
||||
isReady
|
||||
};
|
||||
mcpServers[serverIndex] = updatedServer;
|
||||
|
||||
// If server is now ready and has no tools, try to enrich them
|
||||
if (isReady && (!updatedServer.tools || updatedServer.tools.length === 0)) {
|
||||
try {
|
||||
console.log(`[Auth] Enriching tools for ${instance.name}`);
|
||||
const enrichedTools = await enrichToolsWithParameters(
|
||||
projectId,
|
||||
instance.name,
|
||||
updatedServer.availableTools || [],
|
||||
true
|
||||
);
|
||||
|
||||
if (enrichedTools.length > 0) {
|
||||
console.log(`[Auth] Writing ${enrichedTools.length} tools to DB for ${instance.name}`);
|
||||
updatedServer.availableTools = enrichedTools;
|
||||
await batchAddTools(projectId, instance.name, enrichedTools);
|
||||
}
|
||||
} catch (enrichError) {
|
||||
console.error(`[Auth] Tool enrichment failed for ${instance.name}:`, enrichError);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Auth] Error updating ${instance.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update MongoDB with new server data
|
||||
await projectsCollection.updateOne(
|
||||
{ _id: projectId },
|
||||
{ $set: { mcpServers } }
|
||||
);
|
||||
console.log('[Auth] MongoDB update completed');
|
||||
} catch (error) {
|
||||
console.error('[Auth] Error updating server data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function batchAddTools(projectId: string, serverName: string, tools: McpToolType[]): Promise<void> {
|
||||
console.log(`[Klavis API] Writing ${tools.length} tools to ${serverName}`);
|
||||
|
||||
const toolsToWrite = tools.map(tool => ({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters || {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
}
|
||||
}));
|
||||
|
||||
console.log('[Klavis API] DB Write - batchAddTools:', {
|
||||
serverName,
|
||||
toolCount: tools.length,
|
||||
tools: tools.map(t => t.name).join(', ')
|
||||
});
|
||||
|
||||
// Update MongoDB in a single operation
|
||||
await projectsCollection.updateOne(
|
||||
{ _id: projectId, "mcpServers.name": serverName },
|
||||
{
|
||||
$set: {
|
||||
"mcpServers.$.tools": toolsToWrite
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`[Klavis API] Tools written to ${serverName}`);
|
||||
}
|
||||
|
||||
export async function enableServer(
|
||||
serverName: string,
|
||||
projectId: string,
|
||||
enabled: boolean
|
||||
): Promise<CreateServerInstanceResponse | {} | { billingError: string }> {
|
||||
try {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
console.log('[Klavis API] Toggle server request:', { serverName, projectId, enabled });
|
||||
|
||||
if (enabled) {
|
||||
// get count of enabled hosted mcp servers for this project
|
||||
const existingInstances = await listActiveServerInstances(projectId);
|
||||
// billing limit check
|
||||
const authResponse = await authorizeUserAction({
|
||||
type: 'enable_hosted_tool_server',
|
||||
data: {
|
||||
existingServerCount: existingInstances.length,
|
||||
},
|
||||
});
|
||||
if (!authResponse.success) {
|
||||
return { billingError: authResponse.error || 'Billing error' };
|
||||
}
|
||||
|
||||
// set key in redis to indicate that a server is being enabled on this project
|
||||
// the key set should only succeed if the key does not already exist
|
||||
const setResult = await redisClient.set(`klavis_enabling_server:${projectId}`, 'true', 'EX', 60 * 60, 'NX');
|
||||
console.log('[redis] Set result here:', setResult);
|
||||
if (setResult !== 'OK') {
|
||||
throw new Error("A server is already being enabled on this project");
|
||||
}
|
||||
|
||||
console.log(`[Klavis API] Creating server instance for ${serverName}...`);
|
||||
const result = await createMcpServerInstance(serverName, projectId, "Rowboat");
|
||||
console.log('[Klavis API] Server instance created:', {
|
||||
serverName,
|
||||
instanceId: result.instanceId,
|
||||
serverUrl: result.serverUrl
|
||||
});
|
||||
|
||||
// Get the current server list from MongoDB
|
||||
const project = await projectsCollection.findOne({ _id: projectId });
|
||||
if (!project) throw new Error("Project not found");
|
||||
|
||||
const mcpServers = project.mcpServers || [];
|
||||
|
||||
// Find the server we're enabling
|
||||
const serverIndex = mcpServers.findIndex(s => s.name === serverName);
|
||||
const rawServerData = (await klavisApiCall<GetAllServersResponse>('/mcp-server/servers')).servers
|
||||
.find(s => s.name === serverName);
|
||||
|
||||
if (!rawServerData) throw new Error("Server data not found");
|
||||
|
||||
// Get basic tools data
|
||||
const basicTools = (rawServerData.tools || []).map(tool => ({
|
||||
id: tool.name || '',
|
||||
name: tool.name || '',
|
||||
description: tool.description || '',
|
||||
}));
|
||||
|
||||
// Update server status in MongoDB
|
||||
const updatedServer = {
|
||||
...rawServerData,
|
||||
instanceId: result.instanceId,
|
||||
serverName: serverName,
|
||||
serverUrl: result.serverUrl,
|
||||
tools: basicTools, // Select all tools by default
|
||||
availableTools: basicTools, // Use basic tools initially
|
||||
isActive: true, // Keep isActive true to indicate server is enabled
|
||||
isReady: !rawServerData.authNeeded, // Use isReady to indicate eligibility
|
||||
authNeeded: rawServerData.authNeeded || false,
|
||||
isAuthenticated: false,
|
||||
requiresAuth: rawServerData.authNeeded || false
|
||||
};
|
||||
|
||||
if (serverIndex === -1) {
|
||||
mcpServers.push(updatedServer);
|
||||
} else {
|
||||
mcpServers[serverIndex] = updatedServer;
|
||||
}
|
||||
|
||||
// Update MongoDB with server status
|
||||
await projectsCollection.updateOne(
|
||||
{ _id: projectId },
|
||||
{ $set: { mcpServers } }
|
||||
);
|
||||
|
||||
// Wait for server warm-up (increased from 2s to 5s)
|
||||
console.log(`[Klavis API] New server detected, waiting 5s for ${serverName} to initialize...`);
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
console.log(`[Klavis API] Warm-up period complete for ${serverName}`);
|
||||
|
||||
// Try to enrich tools regardless of auth status
|
||||
try {
|
||||
console.log(`[Klavis API] Enriching tools for ${serverName}`);
|
||||
const enrichedTools = await enrichToolsWithParameters(
|
||||
projectId,
|
||||
serverName,
|
||||
basicTools,
|
||||
true
|
||||
);
|
||||
|
||||
if (enrichedTools.length > 0) {
|
||||
console.log(`[Klavis API] Writing ${enrichedTools.length} tools to DB for ${serverName}`);
|
||||
// First update availableTools
|
||||
await projectsCollection.updateOne(
|
||||
{ _id: projectId, "mcpServers.name": serverName },
|
||||
{
|
||||
$set: {
|
||||
"mcpServers.$.availableTools": enrichedTools,
|
||||
"mcpServers.$.isReady": true // Mark server as ready after successful enrichment
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Then write the tools
|
||||
await batchAddTools(projectId, serverName, enrichedTools);
|
||||
console.log(`[Klavis API] Successfully wrote tools for ${serverName}`);
|
||||
}
|
||||
} catch (enrichError) {
|
||||
console.error(`[Klavis API] Tool enrichment failed for ${serverName}:`, enrichError);
|
||||
}
|
||||
|
||||
// remove key from redis
|
||||
await redisClient.del(`klavis_enabling_server:${projectId}`);
|
||||
|
||||
return result;
|
||||
} else {
|
||||
// Get active instances to find the one to delete
|
||||
const instances = await listActiveServerInstances(projectId);
|
||||
const instance = instances.find(i => i.name === serverName);
|
||||
|
||||
if (instance?.id) {
|
||||
// Check if this server uses auth token (authNeeded but no OAuth)
|
||||
const usesAuthToken = instance.authNeeded && !SERVER_URL_PARAMS[serverName];
|
||||
|
||||
if (usesAuthToken) {
|
||||
// Delete auth data first
|
||||
await deleteServerAuthData(instance.id);
|
||||
}
|
||||
|
||||
await deleteMcpServerInstance(instance.id, projectId);
|
||||
console.log('[Klavis API] Disabled server:', { serverName, instanceId: instance.id });
|
||||
|
||||
// Remove from MongoDB
|
||||
await projectsCollection.updateOne(
|
||||
{ _id: projectId },
|
||||
{ $pull: { mcpServers: { name: serverName } } }
|
||||
);
|
||||
} else {
|
||||
console.log('[Klavis API] No instance found to disable:', { serverName });
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[Klavis API] Toggle error:', {
|
||||
server: serverName,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMcpServerInstance(
|
||||
instanceId: string,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
console.log('[Klavis API] Deleting instance:', { instanceId });
|
||||
|
||||
const endpoint = `/mcp-server/instance/delete/${instanceId}`;
|
||||
try {
|
||||
await klavisApiCall<DeleteServerInstanceResponse>(endpoint, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
console.log('[Klavis API] Instance deleted successfully:', { instanceId });
|
||||
|
||||
// Get the server info from MongoDB to find its name
|
||||
const project = await projectsCollection.findOne({ _id: projectId });
|
||||
const server = project?.mcpServers?.find(s => s.instanceId === instanceId);
|
||||
|
||||
if (server) {
|
||||
// Update just this server's status in MongoDB
|
||||
await projectsCollection.updateOne(
|
||||
{ _id: projectId, "mcpServers.name": server.name },
|
||||
{
|
||||
$set: {
|
||||
"mcpServers.$.isActive": false,
|
||||
"mcpServers.$.serverUrl": null,
|
||||
"mcpServers.$.tools": [],
|
||||
"mcpServers.$.availableTools": [],
|
||||
"mcpServers.$.instanceId": null
|
||||
}
|
||||
}
|
||||
);
|
||||
console.log('[MongoDB] Server status updated:', { serverName: server.name });
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.message.includes('404')) {
|
||||
console.log('[Klavis API] Instance already deleted:', { instanceId });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[Klavis API] Error deleting instance:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateServerAuthUrl(
|
||||
serverName: string,
|
||||
projectId: string,
|
||||
instanceId: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// Get the origin from request headers
|
||||
const headersList = await headers();
|
||||
const host = headersList.get('host') || '';
|
||||
const protocol = headersList.get('x-forwarded-proto') || 'http';
|
||||
const origin = `${protocol}://${host}`;
|
||||
|
||||
// Get the URL parameter for this server
|
||||
const serverUrlParam = SERVER_URL_PARAMS[serverName] || serverName.toLowerCase();
|
||||
|
||||
// Build base params
|
||||
const params: Record<string, string> = {
|
||||
instance_id: instanceId,
|
||||
redirect_url: `${origin}/projects/${projectId}/tools/oauth/callback`
|
||||
};
|
||||
|
||||
// Add client_id if available for this server
|
||||
const clientId = SERVER_CLIENT_ID_MAP[serverName];
|
||||
if (clientId) {
|
||||
params.client_id = clientId;
|
||||
}
|
||||
|
||||
let authUrl = `${KLAVIS_BASE_URL}/oauth/${serverUrlParam}/authorize?${new URLSearchParams(params).toString()}`
|
||||
console.log('authUrl', authUrl);
|
||||
|
||||
return authUrl;
|
||||
} catch (error) {
|
||||
console.error('[Klavis API] Error generating auth URL:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncServerTools(projectId: string, serverName: string): Promise<void> {
|
||||
try {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
console.log('[Klavis API] Starting server tool sync:', { projectId, serverName });
|
||||
|
||||
// Get enriched tools from MCP
|
||||
const enrichedTools = await fetchMcpToolsForServer(projectId, serverName);
|
||||
console.log('[Klavis API] Received enriched tools:', {
|
||||
serverName,
|
||||
toolCount: enrichedTools.length
|
||||
});
|
||||
|
||||
// Convert enriched tools to the correct format
|
||||
const formattedTools = enrichedTools.map(tool => {
|
||||
return {
|
||||
id: tool.name,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: {
|
||||
type: 'object' as const,
|
||||
properties: tool.parameters?.properties || {},
|
||||
required: tool.parameters?.required || []
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// First verify the server exists
|
||||
const project = await projectsCollection.findOne({ _id: projectId });
|
||||
if (!project) {
|
||||
throw new Error(`Project ${projectId} not found`);
|
||||
}
|
||||
const server = project.mcpServers?.find(s => s.name === serverName);
|
||||
if (!server) {
|
||||
throw new Error(`Server ${serverName} not found in project ${projectId}`);
|
||||
}
|
||||
|
||||
// Update MongoDB with enriched tools
|
||||
const updateResult = await projectsCollection.updateOne(
|
||||
{ _id: projectId, "mcpServers.name": serverName },
|
||||
{
|
||||
$set: {
|
||||
"mcpServers.$.availableTools": formattedTools,
|
||||
"mcpServers.$.tools": formattedTools // Also update selected tools to match
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log('[Klavis API] Tools synced:', {
|
||||
serverName,
|
||||
toolCount: formattedTools.length,
|
||||
success: updateResult.modifiedCount > 0
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Klavis API] Error syncing server tools:', {
|
||||
serverName,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Auth Token Management Functions
|
||||
export async function setServerAuthToken(
|
||||
instanceId: string,
|
||||
authToken: string
|
||||
): Promise<{ success: boolean; message?: string; error?: string }> {
|
||||
try {
|
||||
const response = await klavisApiCall<{ success: boolean; message: string }>(
|
||||
`/mcp-server/instance/set-auth-token`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { instanceId, authToken }
|
||||
}
|
||||
);
|
||||
|
||||
return { success: true, message: response.message };
|
||||
} catch (error: any) {
|
||||
// Handle 422 validation errors
|
||||
if (error.message.includes('422')) {
|
||||
try {
|
||||
const errorData = JSON.parse(error.message);
|
||||
const validationErrors = errorData.detail?.map((err: any) => err.msg).join(', ');
|
||||
return { success: false, error: validationErrors || 'Invalid auth token' };
|
||||
} catch {
|
||||
return { success: false, error: 'Invalid auth token format' };
|
||||
}
|
||||
}
|
||||
|
||||
// Handle other errors
|
||||
return { success: false, error: 'Failed to set auth token. Please try again.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteServerAuthData(instanceId: string): Promise<void> {
|
||||
try {
|
||||
await klavisApiCall<{ success: boolean; message: string }>(
|
||||
`/mcp-server/instance/delete-auth/${instanceId}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
console.log('[Klavis API] Auth data deleted for instance:', instanceId);
|
||||
} catch (error: any) {
|
||||
// Log error but don't fail the deletion process
|
||||
console.error('[Klavis API] Failed to delete auth data:', error);
|
||||
// Don't throw - auth cleanup failure shouldn't prevent server deletion
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ 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 { MCPServer, McpServerTool, convertMcpServerToolToWorkflowTool } from "../lib/types/types";
|
||||
import { McpServerTool, convertMcpServerToolToWorkflowTool } from "../lib/types/types";
|
||||
import { getMcpClient } from "../lib/mcp";
|
||||
|
||||
export async function fetchMcpTools(projectId: string): Promise<z.infer<typeof WorkflowTool>[]> {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { User, WithStringId } from "../lib/types/types";
|
|||
import { ApiKey } from "../lib/types/project_types";
|
||||
import { Project } from "../lib/types/project_types";
|
||||
import { USE_AUTH } from "../lib/feature_flags";
|
||||
import { deleteMcpServerInstance, listActiveServerInstances } from "./klavis_actions";
|
||||
import { authorizeUserAction } from "./billing_actions";
|
||||
import { Workflow } from "../lib/types/workflow_types";
|
||||
|
||||
|
|
@ -188,53 +187,9 @@ interface McpServerDeletionError {
|
|||
error: string;
|
||||
}
|
||||
|
||||
async function cleanupMcpServers(projectId: string): Promise<McpServerDeletionError[]> {
|
||||
// Get all active instances directly from Klavis
|
||||
const activeInstances = await listActiveServerInstances(projectId);
|
||||
if (activeInstances.length === 0) return [];
|
||||
|
||||
console.log(`[Project Cleanup] Found ${activeInstances.length} active Klavis instances`);
|
||||
|
||||
// Track deletion errors
|
||||
const deletionErrors: McpServerDeletionError[] = [];
|
||||
|
||||
// Delete each instance
|
||||
const deletionPromises = activeInstances.map(async (instance) => {
|
||||
if (!instance.id) return; // Skip if no instance ID
|
||||
|
||||
try {
|
||||
await deleteMcpServerInstance(instance.id, projectId);
|
||||
console.log(`[Project Cleanup] Deleted Klavis instance: ${instance.name} (${instance.id})`);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error(`[Project Cleanup] Failed to delete Klavis instance: ${instance.name}`, error);
|
||||
deletionErrors.push({
|
||||
serverName: instance.name,
|
||||
error: errorMessage
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all deletions to complete
|
||||
await Promise.all(deletionPromises);
|
||||
|
||||
return deletionErrors;
|
||||
}
|
||||
|
||||
export async function deleteProject(projectId: string) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// First cleanup any Klavis instances
|
||||
if (KLAVIS_API_KEY) {
|
||||
const deletionErrors = await cleanupMcpServers(projectId);
|
||||
|
||||
// If there were any errors deleting instances, throw an error
|
||||
if (deletionErrors.length > 0) {
|
||||
const failedServers = deletionErrors.map(e => `${e.serverName} (${e.error})`).join(', ');
|
||||
throw new Error(`Cannot delete project because the following Klavis instances could not be deleted: ${failedServers}. Please try again or contact support if the issue persists.`);
|
||||
}
|
||||
}
|
||||
|
||||
// delete api keys
|
||||
await apiKeysCollection.deleteMany({
|
||||
projectId,
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
import { z } from "zod";
|
||||
import { apiV1 } from "rowboat-shared"
|
||||
|
||||
export const GetInformationToolResultItem = z.object({
|
||||
title: z.string(),
|
||||
name: z.string(),
|
||||
content: z.string(),
|
||||
docId: z.string(),
|
||||
sourceId: z.string(),
|
||||
});export const GetInformationToolResult = z.object({
|
||||
results: z.array(GetInformationToolResultItem)
|
||||
});
|
||||
export const WebpageCrawlResponse = z.object({
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
});
|
||||
export const ClientToolCallRequestBody = z.object({
|
||||
toolCall: apiV1.AssistantMessageWithToolCalls.shape.tool_calls.element,
|
||||
});
|
||||
export const ClientToolCallJwt = z.object({
|
||||
requestId: z.string().uuid(),
|
||||
projectId: z.string(),
|
||||
bodyHash: z.string(),
|
||||
iat: z.number(),
|
||||
exp: z.number(),
|
||||
});
|
||||
export const ClientToolCallRequest = z.object({
|
||||
requestId: z.string().uuid(),
|
||||
content: z.string(), // json stringified ClientToolCallRequestBody
|
||||
});
|
||||
export const ClientToolCallResponse = z.unknown();
|
||||
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { z } from 'zod';
|
||||
import { WithId } from 'mongodb';
|
||||
import { Message } from './types';
|
||||
|
||||
export const TwilioConfigParams = z.object({
|
||||
|
|
|
|||
|
|
@ -1,175 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Spinner } from "@heroui/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Key, AlertCircle, Eye, EyeOff } from "lucide-react";
|
||||
import { setServerAuthToken } from '@/app/actions/klavis_actions';
|
||||
import { MCPServer } from '@/app/lib/types/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
type McpServerType = z.infer<typeof MCPServer>;
|
||||
|
||||
interface AuthTokenModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
server: McpServerType | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function AuthTokenModal({ isOpen, onClose, server, onSuccess }: AuthTokenModalProps) {
|
||||
const [authToken, setAuthToken] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!server?.instanceId || !authToken.trim()) {
|
||||
setError('Please enter a valid auth token');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await setServerAuthToken(server.instanceId, authToken.trim());
|
||||
|
||||
if (result.success) {
|
||||
// Success - close modal and refresh data
|
||||
setAuthToken('');
|
||||
setError(null);
|
||||
onSuccess();
|
||||
onClose();
|
||||
} else {
|
||||
// Show validation error
|
||||
setError(result.error || 'Failed to set auth token');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Network error. Please check your connection and try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAuthToken('');
|
||||
setError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!server) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={handleClose}
|
||||
size="lg"
|
||||
classNames={{
|
||||
base: "bg-white dark:bg-gray-900",
|
||||
header: "border-b border-gray-200 dark:border-gray-800",
|
||||
footer: "border-t border-gray-200 dark:border-gray-800",
|
||||
}}
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex gap-2 items-center">
|
||||
<Key className="w-5 h-5 text-blue-500" />
|
||||
<span>Authenticate {server.name}</span>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-lg p-3">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
You'll need to obtain an authentication token from {server.name}. Please refer to their documentation or settings page to find your API key or access token.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="auth-token" className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Auth Token
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="auth-token"
|
||||
type={showToken ? 'text' : 'password'}
|
||||
placeholder="Enter your auth token..."
|
||||
value={authToken}
|
||||
onChange={(e) => setAuthToken(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !isSubmitting) {
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
className="w-full pr-10 pl-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-800 text-base text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-900 border-0 shadow-none"
|
||||
disabled={isSubmitting}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
|
||||
onClick={() => setShowToken((v) => !v)}
|
||||
aria-label={showToken ? 'Hide token' : 'Show token'}
|
||||
>
|
||||
{showToken ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex gap-2 items-start p-3 bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<style jsx>{`
|
||||
#auth-token {
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
border: none !important;
|
||||
background: #f3f4f6 !important;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
#auth-token:focus {
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
border: none !important;
|
||||
background: #e0e7ef !important;
|
||||
}
|
||||
.dark #auth-token {
|
||||
background: #23272f !important;
|
||||
color: #f3f4f6 !important;
|
||||
}
|
||||
.dark #auth-token:focus {
|
||||
background: #1a1d23 !important;
|
||||
}
|
||||
`}</style>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !authToken.trim()}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Authenticating...
|
||||
</>
|
||||
) : (
|
||||
'Authenticate'
|
||||
)}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,786 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Info, RefreshCw, Search, AlertTriangle } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import {
|
||||
listAvailableMcpServers,
|
||||
enableServer,
|
||||
updateProjectServers,
|
||||
generateServerAuthUrl,
|
||||
syncServerTools
|
||||
} from '@/app/actions/klavis_actions';
|
||||
import { toggleMcpTool, fetchMcpToolsForServer } from '@/app/actions/mcp_actions';
|
||||
import { z } from 'zod';
|
||||
import { MCPServer } from '@/app/lib/types/types';
|
||||
import { Checkbox } from '@heroui/react';
|
||||
import {
|
||||
ServerCard,
|
||||
ToolManagementPanel,
|
||||
} from './MCPServersCommon';
|
||||
import { BillingUpgradeModal } from '@/components/common/billing-upgrade-modal';
|
||||
import { AuthTokenModal } from './AuthTokenModal';
|
||||
import { SERVER_URL_PARAMS } from '@/app/lib/constants/klavis';
|
||||
|
||||
type McpServerType = z.infer<typeof MCPServer>;
|
||||
type McpToolType = z.infer<typeof MCPServer>['tools'][number];
|
||||
|
||||
function sortServers(servers: McpServerType[]): McpServerType[] {
|
||||
return [...servers].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
const fadeInAnimation = {
|
||||
'@keyframes fadeIn': {
|
||||
'0%': { opacity: 0, transform: 'translateY(-5px)' },
|
||||
'100%': { opacity: 1, transform: 'translateY(0)' }
|
||||
},
|
||||
'.animate-fadeIn': {
|
||||
animation: 'fadeIn 0.2s ease-out'
|
||||
}
|
||||
} as const;
|
||||
|
||||
const toolCardStyles = {
|
||||
base: clsx(
|
||||
"group p-4 rounded-lg transition-all duration-200",
|
||||
"bg-gray-50/50 dark:bg-gray-800/50",
|
||||
"hover:bg-gray-100/50 dark:hover:bg-gray-700/50",
|
||||
"border border-transparent",
|
||||
"hover:border-gray-200 dark:hover:border-gray-600"
|
||||
),
|
||||
};
|
||||
|
||||
const ToolCard = ({
|
||||
tool,
|
||||
server,
|
||||
isSelected,
|
||||
onSelect,
|
||||
showCheckbox = false
|
||||
}: {
|
||||
tool: McpToolType;
|
||||
server: McpServerType;
|
||||
isSelected?: boolean;
|
||||
onSelect?: (selected: boolean) => void;
|
||||
showCheckbox?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<div className={toolCardStyles.base}>
|
||||
<div className="flex items-start gap-3">
|
||||
{showCheckbox && (
|
||||
<Checkbox
|
||||
isSelected={isSelected}
|
||||
onValueChange={onSelect}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
|
||||
{tool.name}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{tool.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorBanner = ({ onRetry }: { onRetry: () => void }) => (
|
||||
<div className="mb-6 bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-800 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||
<p className="text-sm text-red-700 dark:text-red-300">
|
||||
Unable to load hosted tools. Please check your connection and try again. If the problem persists, contact us on <a href={DISCORD_LINK} target="_blank" rel="noopener noreferrer" className="underline hover:text-red-600 dark:hover:text-red-300">Discord</a>.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={onRetry}
|
||||
className="shrink-0"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ERROR_MESSAGE = {
|
||||
NO_HOSTED_TOOLS: 'No hosted tools found. Make sure to set your <a href="https://www.klavis.ai/" target="_blank" rel="noopener noreferrer" class="underline hover:text-red-600 dark:hover:text-red-300">Klavis</a> API key. Contact us on <a href="https://discord.com/invite/rxB8pzHxaS" target="_blank" rel="noopener noreferrer" class="underline hover:text-red-600 dark:hover:text-red-300">discord</a> if you\'re still unable to see hosted tools.'
|
||||
};
|
||||
|
||||
const DISCORD_LINK = 'https://discord.com/invite/rxB8pzHxaS';
|
||||
const DOCS_LINK = 'https://docs.rowboatlabs.com/add_tools/';
|
||||
|
||||
type HostedServersProps = {
|
||||
onSwitchTab?: (tab: string) => void;
|
||||
};
|
||||
|
||||
export function HostedServers({ onSwitchTab }: HostedServersProps) {
|
||||
const params = useParams();
|
||||
const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId?.[0];
|
||||
if (!projectId) throw new Error('Project ID is required');
|
||||
|
||||
const [servers, setServers] = useState<McpServerType[]>([]);
|
||||
const [selectedServer, setSelectedServer] = useState<McpServerType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showOnlyEnabled, setShowOnlyEnabled] = useState(false);
|
||||
const [showOnlyReady, setShowOnlyReady] = useState(false);
|
||||
const [toggleError, setToggleError] = useState<{serverId: string; message: string} | null>(null);
|
||||
const [enabledServers, setEnabledServers] = useState<Set<string>>(new Set());
|
||||
const [togglingServers, setTogglingServers] = useState<Set<string>>(new Set());
|
||||
const [serverOperations, setServerOperations] = useState<Map<string, 'setup' | 'delete' | 'checking-auth'>>(new Map());
|
||||
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
|
||||
const [hasToolChanges, setHasToolChanges] = useState(false);
|
||||
const [savingTools, setSavingTools] = useState(false);
|
||||
const [serverToolCounts, setServerToolCounts] = useState<Map<string, number>>(new Map());
|
||||
const [syncingServers, setSyncingServers] = useState<Set<string>>(new Set());
|
||||
const [billingError, setBillingError] = useState<string | null>(null);
|
||||
const [showAuthTokenModal, setShowAuthTokenModal] = useState(false);
|
||||
const [selectedServerForAuth, setSelectedServerForAuth] = useState<McpServerType | null>(null);
|
||||
|
||||
const fetchServers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await listAvailableMcpServers(projectId || "");
|
||||
|
||||
if (response.error || !response.data) {
|
||||
setError(ERROR_MESSAGE.NO_HOSTED_TOOLS);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark all servers as hosted type
|
||||
const serversWithType = response.data.map(server => ({
|
||||
...server,
|
||||
serverType: 'hosted' as const
|
||||
}));
|
||||
|
||||
setServers(serversWithType);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(ERROR_MESSAGE.NO_HOSTED_TOOLS);
|
||||
console.error('Error fetching servers:', err);
|
||||
setServers([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers();
|
||||
}, [fetchServers]);
|
||||
|
||||
// Initialize enabled servers on load and keep it updated
|
||||
useEffect(() => {
|
||||
if (servers) {
|
||||
console.log('Updating enabled servers from server data:', servers);
|
||||
const enabled = new Set(
|
||||
servers
|
||||
.filter(server => server.isActive)
|
||||
.map(server => server.name)
|
||||
);
|
||||
console.log('New enabled servers state:', Array.from(enabled));
|
||||
setEnabledServers(enabled);
|
||||
}
|
||||
}, [servers]);
|
||||
|
||||
// Initialize tool counts when servers are loaded
|
||||
useEffect(() => {
|
||||
const newCounts = new Map<string, number>();
|
||||
servers.forEach(server => {
|
||||
if (isServerEligible(server)) {
|
||||
newCounts.set(server.name, server.tools.length);
|
||||
}
|
||||
});
|
||||
setServerToolCounts(newCounts);
|
||||
}, [servers]);
|
||||
|
||||
// Initialize selected tools when opening the panel
|
||||
useEffect(() => {
|
||||
if (selectedServer) {
|
||||
setSelectedTools(new Set(selectedServer.tools.map(t => t.id)));
|
||||
setHasToolChanges(false);
|
||||
}
|
||||
}, [selectedServer]);
|
||||
|
||||
const isServerEligible = (server: McpServerType) => {
|
||||
return server.isActive && (!server.authNeeded || server.isAuthenticated);
|
||||
};
|
||||
|
||||
const handleToggleTool = async (server: McpServerType) => {
|
||||
try {
|
||||
const serverKey = server.name;
|
||||
const isCurrentlyEnabled = enabledServers.has(serverKey);
|
||||
const newState = !isCurrentlyEnabled;
|
||||
|
||||
// Immediately update UI state
|
||||
setServers(prevServers => {
|
||||
return prevServers.map(s => {
|
||||
if (s.name === serverKey) {
|
||||
return {
|
||||
...s,
|
||||
isActive: newState,
|
||||
// If turning off, reset these states
|
||||
...(newState ? {} : {
|
||||
serverUrl: undefined,
|
||||
tools: [],
|
||||
isAuthenticated: false
|
||||
})
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
|
||||
setTogglingServers(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(serverKey);
|
||||
return next;
|
||||
});
|
||||
setToggleError(null);
|
||||
setBillingError(null);
|
||||
|
||||
setServerOperations(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(serverKey, newState ? 'setup' : 'delete');
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await enableServer(server.name, projectId || "", newState);
|
||||
|
||||
// Check for billing error
|
||||
if ('billingError' in result) {
|
||||
setBillingError(result.billingError);
|
||||
// Revert UI state
|
||||
setServers(prevServers => {
|
||||
return prevServers.map(s => {
|
||||
if (s.name === serverKey) {
|
||||
return {
|
||||
...s,
|
||||
isActive: isCurrentlyEnabled
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setEnabledServers(prev => {
|
||||
const next = new Set(prev);
|
||||
if (!newState) {
|
||||
next.delete(serverKey);
|
||||
} else if ('instanceId' in result) {
|
||||
next.add(serverKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (newState) {
|
||||
const response = await listAvailableMcpServers(projectId || "");
|
||||
if (response.data) {
|
||||
const updatedServer = response.data.find(s => s.name === serverKey);
|
||||
if (updatedServer) {
|
||||
setServers(prevServers => {
|
||||
return prevServers.map(s => {
|
||||
if (s.name === serverKey) {
|
||||
return { ...updatedServer, serverType: 'hosted' as const };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
|
||||
setServerToolCounts(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(serverKey, updatedServer.tools.length);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setServerToolCounts(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(serverKey, 0);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Toggle failed:', { server: serverKey, error: err });
|
||||
// Revert the UI state on error
|
||||
setServers(prevServers => {
|
||||
return prevServers.map(s => {
|
||||
if (s.name === serverKey) {
|
||||
return {
|
||||
...s,
|
||||
isActive: isCurrentlyEnabled,
|
||||
// Restore previous state if the toggle failed
|
||||
...(isCurrentlyEnabled ? {} : {
|
||||
serverUrl: undefined,
|
||||
tools: [],
|
||||
isAuthenticated: false
|
||||
})
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
|
||||
setEnabledServers(prev => {
|
||||
const next = new Set(prev);
|
||||
if (newState) {
|
||||
next.delete(serverKey);
|
||||
} else {
|
||||
next.add(serverKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setToggleError({
|
||||
serverId: serverKey,
|
||||
message: "We're having trouble setting up this server. Please reach out on <a href=\"" + DISCORD_LINK + "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-red-600 dark:hover:text-red-300\">discord</a>."
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
const serverKey = server.name;
|
||||
setTogglingServers(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(serverKey);
|
||||
return next;
|
||||
});
|
||||
setServerOperations(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(serverKey);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthenticate = async (server: McpServerType) => {
|
||||
try {
|
||||
if (!server.instanceId) {
|
||||
throw new Error('Server instance ID not found');
|
||||
}
|
||||
|
||||
// Check if this server uses OAuth (in SERVER_URL_PARAMS) or auth token
|
||||
const usesOAuth = SERVER_URL_PARAMS[server.name];
|
||||
|
||||
if (usesOAuth) {
|
||||
// Use existing OAuth flow
|
||||
const authUrl = await generateServerAuthUrl(server.name, projectId, server.instanceId);
|
||||
const authWindow = window.open(
|
||||
authUrl,
|
||||
'_blank',
|
||||
'width=600,height=700'
|
||||
);
|
||||
|
||||
if (authWindow) {
|
||||
const checkInterval = setInterval(async () => {
|
||||
if (authWindow.closed) {
|
||||
clearInterval(checkInterval);
|
||||
|
||||
try {
|
||||
setServerOperations(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(server.name, 'checking-auth');
|
||||
return next;
|
||||
});
|
||||
|
||||
await updateProjectServers(projectId, server.name);
|
||||
|
||||
const response = await listAvailableMcpServers(projectId);
|
||||
if (response.data) {
|
||||
const updatedServer = response.data.find(us => us.name === server.name);
|
||||
if (updatedServer) {
|
||||
setServers(prevServers => {
|
||||
return prevServers.map(s => {
|
||||
if (s.name === server.name) {
|
||||
return { ...updatedServer, serverType: 'hosted' as const };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
|
||||
if (selectedServer?.name === server.name) {
|
||||
setSelectedServer({ ...updatedServer, serverType: 'hosted' as const });
|
||||
}
|
||||
|
||||
if (!server.authNeeded || updatedServer.isAuthenticated) {
|
||||
await handleSyncServer(updatedServer);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setServerOperations(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(server.name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
} else {
|
||||
window.alert('Failed to open authentication window. Please check your popup blocker settings.');
|
||||
}
|
||||
} else {
|
||||
// Use auth token modal
|
||||
setSelectedServerForAuth(server);
|
||||
setShowAuthTokenModal(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Auth] Error initiating authentication:', error);
|
||||
window.alert('Failed to setup authentication');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveToolSelection = async () => {
|
||||
if (!selectedServer || !projectId) return;
|
||||
|
||||
setSavingTools(true);
|
||||
try {
|
||||
const availableTools = selectedServer.availableTools || [];
|
||||
const previousTools = new Set(selectedServer.tools.map(t => t.id));
|
||||
const updatedTools = new Set<string>();
|
||||
|
||||
for (const tool of availableTools) {
|
||||
const isSelected = selectedTools.has(tool.id);
|
||||
await toggleMcpTool(projectId, selectedServer.name, tool.id, isSelected);
|
||||
if (isSelected) {
|
||||
updatedTools.add(tool.id);
|
||||
}
|
||||
}
|
||||
|
||||
setServers(prevServers => {
|
||||
return prevServers.map(s => {
|
||||
if (s.name === selectedServer.name) {
|
||||
return {
|
||||
...s,
|
||||
tools: availableTools.filter(tool => selectedTools.has(tool.id))
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
|
||||
setSelectedServer(prev => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
tools: availableTools.filter(tool => selectedTools.has(tool.id))
|
||||
};
|
||||
});
|
||||
|
||||
setServerToolCounts(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(selectedServer.name, selectedTools.size);
|
||||
return next;
|
||||
});
|
||||
|
||||
setHasToolChanges(false);
|
||||
} catch (error) {
|
||||
console.error('Error saving tool selection:', error);
|
||||
} finally {
|
||||
setSavingTools(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncServer = async (server: McpServerType) => {
|
||||
if (!projectId || !isServerEligible(server)) return;
|
||||
|
||||
try {
|
||||
setSyncingServers(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(server.name);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Call the server action to sync and update DB
|
||||
await syncServerTools(projectId, server.name);
|
||||
|
||||
// Refresh the server list to get updated data
|
||||
const response = await listAvailableMcpServers(projectId);
|
||||
if (response.data) {
|
||||
const updatedServer = response.data.find(s => s.name === server.name);
|
||||
if (updatedServer) {
|
||||
setServers(prevServers => {
|
||||
return prevServers.map(s => {
|
||||
if (s.name === server.name) {
|
||||
return { ...updatedServer, serverType: 'hosted' as const };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
|
||||
if (selectedServer?.name === server.name) {
|
||||
setSelectedServer({ ...updatedServer, serverType: 'hosted' as const });
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setSyncingServers(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(server.name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthTokenSuccess = async () => {
|
||||
if (!selectedServerForAuth) return;
|
||||
|
||||
try {
|
||||
setServerOperations(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(selectedServerForAuth.name, 'checking-auth');
|
||||
return next;
|
||||
});
|
||||
|
||||
await updateProjectServers(projectId, selectedServerForAuth.name);
|
||||
|
||||
const response = await listAvailableMcpServers(projectId);
|
||||
if (response.data) {
|
||||
const updatedServer = response.data.find(us => us.name === selectedServerForAuth.name);
|
||||
if (updatedServer) {
|
||||
setServers(prevServers => {
|
||||
return prevServers.map(s => {
|
||||
if (s.name === selectedServerForAuth.name) {
|
||||
return { ...updatedServer, serverType: 'hosted' as const };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
|
||||
if (selectedServer?.name === selectedServerForAuth.name) {
|
||||
setSelectedServer({ ...updatedServer, serverType: 'hosted' as const });
|
||||
}
|
||||
|
||||
if (!selectedServerForAuth.authNeeded || updatedServer.isAuthenticated) {
|
||||
await handleSyncServer(updatedServer);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setServerOperations(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(selectedServerForAuth.name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const filteredServers = sortServers(servers.filter(server => {
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
const serverTools = server.tools || [];
|
||||
|
||||
// Search text filter
|
||||
const matchesSearch =
|
||||
server.name.toLowerCase().includes(searchLower) ||
|
||||
server.description.toLowerCase().includes(searchLower) ||
|
||||
serverTools.some(tool =>
|
||||
tool.name.toLowerCase().includes(searchLower) ||
|
||||
tool.description.toLowerCase().includes(searchLower)
|
||||
);
|
||||
|
||||
// Enabled servers filter
|
||||
const matchesEnabled = !showOnlyEnabled || server.isActive;
|
||||
|
||||
// Ready to use filter (server is active and either doesn't need auth or is already authenticated)
|
||||
const isReady = server.isActive && (!server.authNeeded || server.isAuthenticated);
|
||||
const matchesReady = !showOnlyReady || isReady;
|
||||
|
||||
return matchesSearch && matchesEnabled && matchesReady;
|
||||
}));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-800 dark:border-gray-200 mx-auto"></div>
|
||||
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading tools...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[50vh] space-y-6 px-4">
|
||||
<p
|
||||
className="text-center text-red-500 dark:text-red-400 max-w-[600px]"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: error
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<a href={DOCS_LINK} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="secondary" className="w-full sm:w-auto">
|
||||
Read our documentation
|
||||
</Button>
|
||||
</a>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onSwitchTab?.('custom')}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Set up a custom server instead
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="shrink-0">
|
||||
<Info className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
To make hosted MCP tools available to agents in the Build view, first toggle the servers ON here. Some tools may require authentication after enabling.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute inset-y-0 left-2 flex items-center pointer-events-none">
|
||||
<Search className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search servers or tools..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-8 pr-4 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
|
||||
placeholder-gray-400 dark:placeholder-gray-500
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
|
||||
hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{filteredServers.length} {filteredServers.length === 1 ? 'server' : 'servers'} • {
|
||||
filteredServers.reduce((total, server) => total + (server.availableTools?.length || 0), 0)
|
||||
} tools
|
||||
</div>
|
||||
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="group relative flex items-center gap-1">
|
||||
<label className="flex items-center gap-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
<Checkbox
|
||||
isSelected={showOnlyEnabled}
|
||||
onValueChange={setShowOnlyEnabled}
|
||||
size="sm"
|
||||
/>
|
||||
Enabled Only
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Info className="h-3.5 w-3.5 text-gray-400 dark:text-gray-500 cursor-help ml-1" />
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 dark:bg-gray-800 text-white text-xs rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 whitespace-nowrap shadow-lg">
|
||||
Shows only servers that are currently toggled ON
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group relative flex items-center gap-1">
|
||||
<label className="flex items-center gap-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
<Checkbox
|
||||
isSelected={showOnlyReady}
|
||||
onValueChange={setShowOnlyReady}
|
||||
size="sm"
|
||||
/>
|
||||
Ready to Use
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Info className="h-3.5 w-3.5 text-gray-400 dark:text-gray-500 cursor-help ml-1" />
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 dark:bg-gray-800 text-white text-xs rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 whitespace-nowrap shadow-lg">
|
||||
Shows only servers that are enabled and fully authenticated
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={fetchServers}
|
||||
disabled={loading}
|
||||
>
|
||||
<div className="inline-flex items-center">
|
||||
<RefreshCw className={clsx("h-4 w-4", loading && "animate-spin")} />
|
||||
<span className="ml-2">Refresh</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredServers.map((server) => (
|
||||
<ServerCard
|
||||
key={server.instanceId}
|
||||
server={server}
|
||||
onToggle={() => handleToggleTool(server)}
|
||||
onManageTools={() => setSelectedServer(server)}
|
||||
onSync={() => handleSyncServer(server)}
|
||||
onAuth={() => handleAuthenticate(server)}
|
||||
isToggling={togglingServers.has(server.name)}
|
||||
isSyncing={syncingServers.has(server.name)}
|
||||
operation={serverOperations.get(server.name)}
|
||||
error={toggleError?.serverId === server.name ? toggleError : undefined}
|
||||
showAuth={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ToolManagementPanel
|
||||
server={selectedServer}
|
||||
onClose={() => {
|
||||
setSelectedServer(null);
|
||||
setSelectedTools(new Set());
|
||||
setHasToolChanges(false);
|
||||
}}
|
||||
selectedTools={selectedTools}
|
||||
onToolSelectionChange={(toolId, selected) => {
|
||||
setSelectedTools(prev => {
|
||||
const next = new Set(prev);
|
||||
if (selected) {
|
||||
next.add(toolId);
|
||||
} else {
|
||||
next.delete(toolId);
|
||||
}
|
||||
setHasToolChanges(true);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onSaveTools={handleSaveToolSelection}
|
||||
onSyncTools={selectedServer ? () => handleSyncServer(selectedServer) : undefined}
|
||||
hasChanges={hasToolChanges}
|
||||
isSaving={savingTools}
|
||||
isSyncing={selectedServer ? syncingServers.has(selectedServer.name) : false}
|
||||
/>
|
||||
|
||||
<BillingUpgradeModal
|
||||
isOpen={!!billingError}
|
||||
onClose={() => setBillingError(null)}
|
||||
errorMessage={billingError || ''}
|
||||
/>
|
||||
|
||||
<AuthTokenModal
|
||||
isOpen={showAuthTokenModal}
|
||||
onClose={() => {
|
||||
setShowAuthTokenModal(false);
|
||||
setSelectedServerForAuth(null);
|
||||
}}
|
||||
server={selectedServerForAuth}
|
||||
onSuccess={handleAuthTokenSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue