chore(dev): improve ux of credentials manager

This commit is contained in:
Soumyadas15 2025-06-08 14:01:38 +05:30
parent 4ce617364f
commit d5fb7e1c8d
7 changed files with 725 additions and 378 deletions

View File

@ -47,6 +47,7 @@ class MCPRequirement:
display_name: str
enabled_tools: List[str]
required_config: List[str]
custom_type: Optional[str] = None # 'sse' or 'http' for custom MCP servers
class CredentialManager:
@ -381,8 +382,18 @@ class CredentialManager:
missing = []
for req in requirements:
if req.qualified_name not in user_mcp_names:
missing.append(req)
if req.custom_type:
custom_pattern = f"custom_{req.custom_type}_"
found = any(
cred_name.startswith(custom_pattern) and
req.display_name.lower().replace(' ', '_') in cred_name
for cred_name in user_mcp_names
)
if not found:
missing.append(req)
else:
if req.qualified_name not in user_mcp_names:
missing.append(req)
return missing
@ -395,9 +406,19 @@ class CredentialManager:
mappings = {}
for req in requirements:
credential = await self.get_credential(account_id, req.qualified_name)
if credential:
mappings[req.qualified_name] = credential.credential_id
if req.custom_type:
user_credentials = await self.get_user_credentials(account_id)
custom_pattern = f"custom_{req.custom_type}_"
for cred in user_credentials:
if (cred.mcp_qualified_name.startswith(custom_pattern) and
req.display_name.lower().replace(' ', '_') in cred.mcp_qualified_name):
mappings[req.qualified_name] = cred.credential_id
break
else:
credential = await self.get_credential(account_id, req.qualified_name)
if credential:
mappings[req.qualified_name] = credential.credential_id
return mappings

View File

@ -64,6 +64,7 @@ class InstallTemplateRequest(BaseModel):
template_id: str
instance_name: Optional[str] = None
custom_system_prompt: Optional[str] = None
custom_mcp_configs: Optional[Dict[str, Dict[str, Any]]] = None
class PublishTemplateRequest(BaseModel):
"""Request model for publishing template"""
@ -87,9 +88,10 @@ class TemplateResponse(BaseModel):
class InstallationResponse(BaseModel):
"""Response model for template installation"""
status: str # 'installed' or 'credentials_required'
status: str # 'installed', 'configs_required'
instance_id: Optional[str] = None
missing_credentials: Optional[List[Dict[str, Any]]] = None
missing_regular_credentials: Optional[List[Dict[str, Any]]] = None
missing_custom_configs: Optional[List[Dict[str, Any]]] = None
template: Optional[Dict[str, Any]] = None
# =====================================================
@ -301,7 +303,8 @@ async def install_template(
template_id=request.template_id,
account_id=user_id,
instance_name=request.instance_name,
custom_system_prompt=request.custom_system_prompt
custom_system_prompt=request.custom_system_prompt,
custom_mcp_configs=request.custom_mcp_configs
)
return InstallationResponse(**result)

View File

@ -119,6 +119,7 @@ class TemplateManager:
'required_config': list(custom_mcp.get('config', {}).keys()),
'custom_type': custom_mcp['type']
}
logger.info(f"Created custom MCP requirement: {requirement}")
mcp_requirements.append(requirement)
# Create template
@ -172,7 +173,8 @@ class TemplateManager:
qualified_name=req_data.get('qualified_name') or req_data.get('qualifiedName'),
display_name=req_data.get('display_name') or req_data.get('name'),
enabled_tools=req_data.get('enabled_tools') or req_data.get('enabledTools', []),
required_config=req_data.get('required_config') or req_data.get('requiredConfig', [])
required_config=req_data.get('required_config') or req_data.get('requiredConfig', []),
custom_type=req_data.get('custom_type')
))
return AgentTemplate(
@ -202,7 +204,8 @@ class TemplateManager:
template_id: str,
account_id: str,
instance_name: Optional[str] = None,
custom_system_prompt: Optional[str] = None
custom_system_prompt: Optional[str] = None,
custom_mcp_configs: Optional[Dict[str, Dict[str, Any]]] = None
) -> Dict[str, Any]:
"""
Install a template as an agent instance for a user
@ -212,6 +215,7 @@ class TemplateManager:
account_id: ID of the user installing the template
instance_name: Optional custom name for the instance
custom_system_prompt: Optional custom system prompt override
custom_mcp_configs: Optional dict mapping qualified_name to config for custom MCPs
Returns:
Dictionary with installation result and any missing credentials
@ -230,22 +234,44 @@ class TemplateManager:
if template.creator_id != account_id:
raise ValueError("Access denied to private template")
# Check for missing credentials
missing_credentials = await credential_manager.get_missing_credentials_for_requirements(
account_id, template.mcp_requirements
# Debug: Log template requirements
logger.info(f"Template MCP requirements: {[(req.qualified_name, req.display_name, getattr(req, 'custom_type', None)) for req in template.mcp_requirements]}")
# Separate custom and regular MCP requirements
custom_requirements = [req for req in template.mcp_requirements if getattr(req, 'custom_type', None)]
regular_requirements = [req for req in template.mcp_requirements if not getattr(req, 'custom_type', None)]
# Check for missing regular credentials
missing_regular_credentials = await credential_manager.get_missing_credentials_for_requirements(
account_id, regular_requirements
)
if missing_credentials:
# Check for missing custom MCP configs
missing_custom_configs = []
if custom_requirements:
provided_custom_configs = custom_mcp_configs or {}
for req in custom_requirements:
if req.qualified_name not in provided_custom_configs:
missing_custom_configs.append({
'qualified_name': req.qualified_name,
'display_name': req.display_name,
'custom_type': req.custom_type,
'required_config': req.required_config
})
# If we have any missing credentials or configs, return them
if missing_regular_credentials or missing_custom_configs:
return {
'status': 'credentials_required',
'missing_credentials': [
'status': 'configs_required',
'missing_regular_credentials': [
{
'qualified_name': req.qualified_name,
'display_name': req.display_name,
'required_config': req.required_config
}
for req in missing_credentials
for req in missing_regular_credentials
],
'missing_custom_configs': missing_custom_configs,
'template': {
'template_id': template.template_id,
'name': template.name,
@ -261,42 +287,58 @@ class TemplateManager:
# Create regular agent with secure credentials
client = await db.client
# Build configured_mcps with user's credentials
# Build configured_mcps and custom_mcps with user's credentials
configured_mcps = []
custom_mcps = []
for req in template.mcp_requirements:
credential_id = credential_mappings.get(req.qualified_name)
if not credential_id:
logger.warning(f"No credential mapping for {req.qualified_name}")
continue
logger.info(f"Processing requirement: {req.qualified_name}, custom_type: {getattr(req, 'custom_type', None)}")
# Get the credential
credential = await credential_manager.get_credential(
account_id, req.qualified_name
)
if not credential:
logger.warning(f"Credential not found for {req.qualified_name}")
continue
# Build MCP config with actual credentials
mcp_config = {
'name': req.display_name,
'qualifiedName': req.qualified_name,
'config': credential.config,
'enabledTools': req.enabled_tools
}
configured_mcps.append(mcp_config)
if hasattr(req, 'custom_type') and req.custom_type:
# For custom MCP servers, use the provided config from installation
if custom_mcp_configs and req.qualified_name in custom_mcp_configs:
provided_config = custom_mcp_configs[req.qualified_name]
custom_mcp_config = {
'name': req.display_name,
'type': req.custom_type,
'config': provided_config,
'enabledTools': req.enabled_tools
}
custom_mcps.append(custom_mcp_config)
logger.info(f"Added custom MCP with provided config: {custom_mcp_config}")
else:
logger.warning(f"No custom config provided for {req.qualified_name}")
continue
else:
# For regular MCP servers, use stored credentials
credential = await credential_manager.get_credential(
account_id, req.qualified_name
)
if not credential:
logger.warning(f"Credential not found for {req.qualified_name}")
continue
mcp_config = {
'name': req.display_name,
'qualifiedName': req.qualified_name,
'config': credential.config,
'enabledTools': req.enabled_tools
}
configured_mcps.append(mcp_config)
logger.info(f"Added regular MCP: {mcp_config}")
logger.info(f"Final configured_mcps: {configured_mcps}")
logger.info(f"Final custom_mcps: {custom_mcps}")
# Create regular agent that will show up in agent playground
agent_data = {
'account_id': account_id,
'name': instance_name or f"{template.name} (from marketplace)",
'description': template.description,
'system_prompt': custom_system_prompt or template.system_prompt,
'configured_mcps': configured_mcps,
'custom_mcps': [], # TODO: Handle custom MCPs from templates
'custom_mcps': custom_mcps,
'agentpress_tools': template.agentpress_tools,
'is_default': False,
'avatar': template.avatar,
@ -388,8 +430,9 @@ class TemplateManager:
if not template:
raise ValueError("Template not found")
# Build configured_mcps with user's credentials
# Build configured_mcps and custom_mcps with user's credentials
configured_mcps = []
custom_mcps = []
for req in template.mcp_requirements:
credential_id = instance.credential_mappings.get(req.qualified_name)
@ -406,15 +449,25 @@ class TemplateManager:
logger.warning(f"Credential not found for {req.qualified_name}")
continue
# Build MCP config
mcp_config = {
'name': req.display_name,
'qualifiedName': req.qualified_name,
'config': credential.config,
'enabledTools': req.enabled_tools
}
configured_mcps.append(mcp_config)
# Check if this is a custom MCP server
if req.custom_type:
# Build custom MCP config
custom_mcp_config = {
'name': req.display_name,
'type': req.custom_type,
'config': credential.config,
'enabledTools': req.enabled_tools
}
custom_mcps.append(custom_mcp_config)
else:
# Build regular MCP config
mcp_config = {
'name': req.display_name,
'qualifiedName': req.qualified_name,
'config': credential.config,
'enabledTools': req.enabled_tools
}
configured_mcps.append(mcp_config)
# Build complete agent config
agent_config = {
@ -424,7 +477,7 @@ class TemplateManager:
'description': instance.description,
'system_prompt': instance.custom_system_prompt or template.system_prompt,
'configured_mcps': configured_mcps,
'custom_mcps': [], # TODO: Handle custom MCPs from templates
'custom_mcps': custom_mcps,
'agentpress_tools': template.agentpress_tools,
'is_default': instance.is_default,
'avatar': instance.avatar,

View File

@ -1,15 +1,13 @@
'use client';
import React, { useState, useMemo } from 'react';
import { Search, Download, Star, Calendar, User, Tags, TrendingUp, Globe, Shield, AlertTriangle, CheckCircle, Loader2, Settings, X, Wrench, Zap } from 'lucide-react';
import { Search, Download, Star, Calendar, User, Tags, TrendingUp, Globe, Shield, AlertTriangle, CheckCircle, Loader2, Settings, X, Wrench, Zap, Info } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
import { Card, CardContent } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { toast } from 'sonner';
@ -42,28 +40,40 @@ interface UnifiedMarketplaceItem {
avatar?: string;
avatar_color?: string;
type: 'legacy' | 'secure';
// Legacy agent fields
agent_id?: string;
// Secure template fields
template_id?: string;
mcp_requirements?: Array<{
qualified_name: string;
display_name: string;
enabled_tools?: string[];
required_config: string[];
custom_type?: 'sse' | 'http';
}>;
}
interface SetupStep {
id: string;
title: string;
description: string;
type: 'credential' | 'custom_server';
service_name: string;
qualified_name: string;
required_fields: Array<{
key: string;
label: string;
type: 'text' | 'url' | 'password';
placeholder: string;
description?: string;
}>;
custom_type?: 'sse' | 'http';
}
interface InstallDialogProps {
item: UnifiedMarketplaceItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onInstall: (item: UnifiedMarketplaceItem, instanceName?: string) => void;
onInstall: (item: UnifiedMarketplaceItem, instanceName?: string, customMcpConfigs?: Record<string, Record<string, any>>) => void;
isInstalling: boolean;
credentialStatus?: {
hasAllCredentials: boolean;
missingCount: number;
totalRequired: number;
};
}
const InstallDialog: React.FC<InstallDialogProps> = ({
@ -71,130 +81,372 @@ const InstallDialog: React.FC<InstallDialogProps> = ({
open,
onOpenChange,
onInstall,
isInstalling,
credentialStatus
isInstalling
}) => {
const [currentStep, setCurrentStep] = useState(0);
const [instanceName, setInstanceName] = useState('');
const [setupData, setSetupData] = useState<Record<string, Record<string, string>>>({});
const [isCheckingRequirements, setIsCheckingRequirements] = useState(false);
const [setupSteps, setSetupSteps] = useState<SetupStep[]>([]);
const [missingCredentials, setMissingCredentials] = useState<string[]>([]);
const { data: userCredentials } = useUserCredentials();
React.useEffect(() => {
if (item) {
if (item && open) {
if (item.type === 'secure') {
setInstanceName(`${item.name} (My Copy)`);
setInstanceName(`${item.name}`);
checkRequirementsAndSetupSteps();
} else {
setInstanceName('');
setSetupSteps([]);
setMissingCredentials([]);
}
}
}, [item]);
}, [item, open, userCredentials]);
const checkRequirementsAndSetupSteps = async () => {
if (!item?.mcp_requirements) return;
setIsCheckingRequirements(true);
const userCredNames = new Set(userCredentials?.map(cred => cred.mcp_qualified_name) || []);
const steps: SetupStep[] = [];
const missing: string[] = [];
const customServers = item.mcp_requirements.filter(req => req.custom_type);
const regularServices = item.mcp_requirements.filter(req => !req.custom_type);
for (const req of regularServices) {
if (!userCredNames.has(req.qualified_name)) {
missing.push(req.display_name);
}
}
for (const req of customServers) {
steps.push({
id: req.qualified_name,
title: `Configure ${req.display_name}`,
description: `Enter your ${req.display_name} server URL to connect this agent.`,
type: 'custom_server',
service_name: req.display_name,
qualified_name: req.qualified_name,
custom_type: req.custom_type,
required_fields: req.required_config.map(key => ({
key,
label: key === 'url' ? `${req.display_name} Server URL` : key,
type: key === 'url' ? 'url' : 'text',
placeholder: key === 'url' ? `https://your-${req.display_name.toLowerCase()}-server.com` : `Enter your ${key}`,
description: key === 'url' ? `Your personal ${req.display_name} server endpoint` : undefined
}))
});
}
setSetupSteps(steps);
setMissingCredentials(missing);
setIsCheckingRequirements(false);
};
const handleFieldChange = (stepId: string, fieldKey: string, value: string) => {
setSetupData(prev => ({
...prev,
[stepId]: {
...prev[stepId],
[fieldKey]: value
}
}));
};
const isCurrentStepComplete = (): boolean => {
if (setupSteps.length === 0) return true;
if (currentStep >= setupSteps.length) return true;
const step = setupSteps[currentStep];
const stepData = setupData[step.id] || {};
return step.required_fields.every(field => {
const value = stepData[field.key];
return value && value.trim().length > 0;
});
};
const handleNext = () => {
if (currentStep < setupSteps.length - 1) {
setCurrentStep(currentStep + 1);
} else {
handleInstall();
}
};
const handleBack = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
const handleInstall = async () => {
if (!item) return;
if (item.type === 'legacy' && item.agent_id) {
onInstall(item, instanceName);
return;
}
const customMcpConfigs: Record<string, Record<string, any>> = {};
setupSteps.forEach(step => {
if (step.type === 'custom_server') {
customMcpConfigs[step.qualified_name] = setupData[step.id] || {};
}
});
onInstall(item, instanceName, customMcpConfigs);
};
const canInstall = () => {
if (!item || item.type !== 'secure') return true;
if (!instanceName.trim()) return false;
if (missingCredentials.length > 0) return false;
if (setupSteps.length > 0 && currentStep < setupSteps.length) return false;
return true;
};
if (!item) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{item.type === 'secure' ? 'Install Secure Template' : 'Add Agent to Library'}
{item.type === 'secure' && (
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
<Shield className="h-3 w-3 mr-1" />
Secure
</Badge>
)}
</DialogTitle>
<DialogDescription>
{item.type === 'secure'
? `Install "${item.name}" as a secure agent instance`
: `Add "${item.name}" to your agent library`
}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{item.type === 'secure' && (
<div className="space-y-2">
<label className="text-sm font-medium">Agent Name</label>
<Input
value={instanceName}
onChange={(e) => setInstanceName(e.target.value)}
placeholder="Enter a name for your agent"
/>
</div>
)}
{item.type === 'secure' && item.mcp_requirements && item.mcp_requirements.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium">Required MCP Services</label>
<div className="space-y-2">
{item.mcp_requirements.map((req) => (
<div key={req.qualified_name} className="flex items-center justify-between p-2 border rounded">
<div>
<p className="text-sm font-medium">{req.display_name}</p>
<p className="text-xs text-muted-foreground">{req.qualified_name}</p>
</div>
<Badge variant="outline" className="text-xs">
{req.enabled_tools?.length || 0} tools
</Badge>
</div>
))}
</div>
{credentialStatus && !credentialStatus.hasAllCredentials && (
<Alert className='border-destructive/20 bg-destructive/5 text-destructive'>
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-xs text-destructive">
You're missing credentials for {credentialStatus.missingCount} of {credentialStatus.totalRequired} required services.
<Link href="/settings/credentials" className="ml-1 underline">
Set them up first
</Link>
</AlertDescription>
</Alert>
)}
{credentialStatus && credentialStatus.hasAllCredentials && (
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
All required credentials are configured. This agent will use your encrypted API keys.
</AlertDescription>
</Alert>
)}
</div>
)}
{item.type === 'legacy' && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-xs">
This is a legacy agent that may contain embedded API keys. Consider using secure templates for better security.
</AlertDescription>
</Alert>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => onInstall(item, instanceName)}
disabled={
isInstalling ||
(item.type === 'secure' && !instanceName.trim()) ||
(item.type === 'secure' && credentialStatus && !credentialStatus.hasAllCredentials)
}
>
{isInstalling ? (
<DialogContent className="max-w-lg">
<DialogHeader className="space-y-3">
<DialogTitle className="flex items-center gap-3 text-xl">
{item.type === 'secure' ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
{item.type === 'secure' ? 'Installing...' : 'Adding...'}
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/20">
<Shield className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
Install {item.name}
</>
) : (
<>
<Download className="h-4 w-4 mr-2" />
{item.type === 'secure' ? 'Install Agent' : 'Add to Library'}
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/20">
<Download className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
Add {item.name}
</>
)}
</Button>
</DialogTitle>
</DialogHeader>
<div className="space-y-6 py-4">
{isCheckingRequirements ? (
<div className="flex flex-col items-center justify-center py-12 space-y-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Checking what you need...</p>
</div>
) : missingCredentials.length > 0 ? (
<div className="space-y-6">
<Alert className="border-destructive/50 bg-destructive/10 dark:bg-destructive/5 text-destructive">
<AlertTriangle className="h-4 w-4 text-destructive" />
<AlertTitle className="text-destructive">Missing API Credentials</AlertTitle>
<AlertDescription className="text-destructive/80">
This agent requires API credentials for the following services:
</AlertDescription>
</Alert>
<div className="space-y-3">
{missingCredentials.map((serviceName) => (
<Card key={serviceName} className="border-destructive/20 shadow-none bg-transparent">
<CardContent className="flex items-center justify-between p-4 py-0">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="h-4 w-4 text-destructive" />
</div>
<span className="font-medium">{serviceName}</span>
</div>
<Badge variant="destructive">Missing</Badge>
</CardContent>
</Card>
))}
</div>
</div>
) : setupSteps.length === 0 ? (
<div className="space-y-6">
{item.type === 'secure' && (
<div className="space-y-3">
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Agent Name
</label>
<Input
value={instanceName}
onChange={(e) => setInstanceName(e.target.value)}
placeholder="Enter a name for this agent"
className="h-11"
/>
</div>
)}
<Alert className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/50">
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
<AlertDescription className="text-green-800 dark:text-green-200">
{item.type === 'secure'
? "All requirements are configured. Ready to install!"
: "This agent will be added to your library."
}
</AlertDescription>
</Alert>
</div>
) : currentStep < setupSteps.length ? (
<div className="space-y-6">
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="space-y-2 flex-1">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground text-sm font-semibold">
{currentStep + 1}
</div>
<h3 className="font-semibold text-base">{setupSteps[currentStep].title}</h3>
{setupSteps[currentStep].custom_type && (
<Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-800">
{setupSteps[currentStep].custom_type?.toUpperCase()}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{setupSteps[currentStep].description}
</p>
</div>
</div>
<div className="space-y-4">
{setupSteps[currentStep].required_fields.map((field) => (
<div key={field.key} className="space-y-2">
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{field.label}
</label>
<Input
type={field.type}
placeholder={field.placeholder}
value={setupData[setupSteps[currentStep].id]?.[field.key] || ''}
onChange={(e) => handleFieldChange(setupSteps[currentStep].id, field.key, e.target.value)}
className="h-11"
/>
{field.description && (
<p className="text-xs text-muted-foreground">{field.description}</p>
)}
</div>
))}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
{setupSteps.map((_, index) => (
<div
key={index}
className={`h-1 flex-1 rounded-full transition-colors ${
index <= currentStep ? 'bg-primary' : 'bg-muted'
}`}
/>
))}
</div>
<p className="text-xs text-muted-foreground">
Step {currentStep + 1} of {setupSteps.length}
</p>
</div>
</div>
) : (
<div className="space-y-6">
<div className="flex items-start gap-4">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/20">
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="space-y-1 flex-1">
<h3 className="font-semibold text-base">Almost done!</h3>
<p className="text-sm text-muted-foreground">
Give your agent a name and we'll set everything up.
</p>
</div>
</div>
<div className="ml-12 space-y-3">
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Agent Name
</label>
<Input
value={instanceName}
onChange={(e) => setInstanceName(e.target.value)}
placeholder="Enter a name for this agent"
className="h-11"
/>
</div>
</div>
)}
</div>
<DialogFooter className="flex-col gap-3">
{missingCredentials.length > 0 ? (
<div className="flex gap-3 w-full justify-end">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
onClick={() => window.open('/settings/credentials', '_blank')}
>
<Settings className="h-4 w-4" />
Set Up Credentials
</Button>
</div>
) : (
<div className="flex gap-3 w-full justify-end">
{currentStep > 0 && (
<Button variant="outline" onClick={handleBack} className="flex-1">
Back
</Button>
)}
{setupSteps.length === 0 ? (
<Button
onClick={handleInstall}
disabled={isInstalling || !canInstall()}
>
{isInstalling ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{item.type === 'secure' ? 'Installing...' : 'Adding...'}
</>
) : (
<>
<Download className="h-4 w-4" />
{item.type === 'secure' ? 'Install Agent' : 'Add to Library'}
</>
)}
</Button>
) : currentStep < setupSteps.length ? (
<Button
onClick={handleNext}
disabled={!isCurrentStepComplete()}
>
Continue
</Button>
) : (
<Button
onClick={handleInstall}
disabled={isInstalling || !canInstall()}
>
{isInstalling ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Installing...
</>
) : (
<>
<Download className="h-4 w-4" />
Install Agent
</>
)}
</Button>
)}
</div>
)}
</DialogFooter>
</DialogContent>
</Dialog>
@ -367,34 +619,46 @@ const AgentDetailsContent: React.FC<{
<h3 className="text-md font-medium">Required MCP Services</h3>
</div>
<div className="space-y-3">
{item.mcp_requirements.map((req) => (
<Card key={req.qualified_name} className="border-border/50">
<CardContent className="p-4 py-0">
<div className="flex items-start justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-primary/10">
<Zap className="h-4 w-4 text-primary" />
</div>
<h4 className="font-medium">{req.display_name}</h4>
</div>
{req.enabled_tools && req.enabled_tools.length > 0 && (
<div className="flex flex-wrap gap-1">
{req.enabled_tools.map(tool => (
<Badge key={tool} variant="outline" className="text-xs">
<Wrench className="h-3 w-3 mr-1" />
{tool}
<div className="space-y-3">
{item.mcp_requirements.map((req) => (
<Card key={req.qualified_name} className="border-border/50">
<CardContent className="p-4 py-0">
<div className="flex items-start justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-primary/10">
<Zap className="h-4 w-4 text-primary" />
</div>
<h4 className="font-medium">{req.display_name}</h4>
{req.custom_type && (
<Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200">
Custom {req.custom_type.toUpperCase()}
</Badge>
))}
)}
</div>
)}
<div className="text-xs text-muted-foreground">
{req.custom_type ? (
<span>Requires your own {req.custom_type.toUpperCase()} server URL</span>
) : (
<span>Requires: {req.required_config.join(', ')}</span>
)}
</div>
{req.enabled_tools && req.enabled_tools.length > 0 && (
<div className="flex flex-wrap gap-1">
{req.enabled_tools.map(tool => (
<Badge key={tool} variant="outline" className="text-xs">
<Wrench className="h-3 w-3 mr-1" />
{tool}
</Badge>
))}
</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
@ -423,141 +687,6 @@ const AgentDetailsContent: React.FC<{
);
};
const AgentDetailsSheet: React.FC<AgentDetailsProps> = ({
item,
open,
onOpenChange,
onInstall,
isInstalling,
credentialStatus
}) => {
const isDesktop = useMediaQuery("(min-width: 768px)");
const [instanceName, setInstanceName] = useState('');
React.useEffect(() => {
if (item) {
if (item.type === 'secure') {
setInstanceName(`${item.name} (My Copy)`);
} else {
setInstanceName('');
}
}
}, [item]);
if (!item) return null;
if (isDesktop) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-[600px] sm:max-w-[600px] overflow-y-auto flex flex-col">
<SheetHeader>
<SheetTitle>Agent Details</SheetTitle>
<SheetDescription>
View details and install this {item.type === 'secure' ? 'secure template' : 'agent'}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4 mt-6">
<AgentDetailsContent
item={item}
instanceName={instanceName}
setInstanceName={setInstanceName}
/>
</div>
<div className="border-t p-4 mt-4">
{credentialStatus && !credentialStatus.hasAllCredentials && (
<Alert className="border-destructive/20 bg-destructive/5 text-destructive mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-sm text-destructive">
You're missing credentials for {credentialStatus.missingCount} of {credentialStatus.totalRequired} required services.
<Link href="/settings/credentials" className="ml-1 underline">
Set them up first
</Link>
</AlertDescription>
</Alert>
)}
<Button
onClick={() => onInstall(item, instanceName)}
disabled={
isInstalling ||
(item.type === 'secure' && !instanceName.trim()) ||
(item.type === 'secure' && credentialStatus && !credentialStatus.hasAllCredentials)
}
className="w-full"
size="lg"
>
{isInstalling ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
{item.type === 'secure' ? 'Installing...' : 'Adding...'}
</>
) : (
<>
<Download className="h-4 w-4 mr-2" />
{item.type === 'secure' ? 'Install Agent' : 'Add to Library'}
</>
)}
</Button>
</div>
</SheetContent>
</Sheet>
);
}
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="h-screen flex flex-col">
<DrawerHeader>
<DrawerTitle>Agent Details</DrawerTitle>
<DrawerDescription>
View details and install this {item.type === 'secure' ? 'secure template' : 'agent'}
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 overflow-y-auto px-4">
<AgentDetailsContent
item={item}
instanceName={instanceName}
setInstanceName={setInstanceName}
/>
</div>
<div className="border-t p-4 mt-4">
{credentialStatus && !credentialStatus.hasAllCredentials && (
<Alert className="border-destructive/20 bg-destructive/5 text-destructive mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-sm text-destructive">
You're missing credentials for {credentialStatus.missingCount} of {credentialStatus.totalRequired} required services.
<Link href="/settings/credentials" className="ml-1 underline">
Set them up first
</Link>
</AlertDescription>
</Alert>
)}
<Button
onClick={() => onInstall(item, instanceName)}
disabled={
isInstalling ||
(item.type === 'secure' && !instanceName.trim()) ||
(item.type === 'secure' && credentialStatus && !credentialStatus.hasAllCredentials)
}
className="w-full"
size="lg"
>
{isInstalling ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
{item.type === 'secure' ? 'Installing...' : 'Adding...'}
</>
) : (
<>
<Download className="h-4 w-4 mr-2" />
{item.type === 'secure' ? 'Install Agent' : 'Add to Library'}
</>
)}
</Button>
</div>
</DrawerContent>
</Drawer>
);
};
export default function UnifiedMarketplacePage() {
const [page, setPage] = useState(1);
const [searchQuery, setSearchQuery] = useState('');
@ -678,13 +807,21 @@ export default function UnifiedMarketplacePage() {
}
const userCredNames = getUserCredentialNames();
const requiredCreds = item.mcp_requirements.map(req => req.qualified_name);
const missingCreds = requiredCreds.filter(cred => !userCredNames.has(cred));
const missingCreds = item.mcp_requirements.filter(req => {
if (req.custom_type) {
const customPattern = `custom_${req.custom_type}_`;
return !Array.from(userCredNames).some(credName =>
credName.startsWith(customPattern) &&
credName.includes(req.display_name.toLowerCase().replace(/\s+/g, '_'))
);
}
return !userCredNames.has(req.qualified_name);
});
return {
hasAllCredentials: missingCreds.length === 0,
missingCount: missingCreds.length,
totalRequired: requiredCreds.length
totalRequired: item.mcp_requirements.length
};
};
@ -701,7 +838,7 @@ export default function UnifiedMarketplacePage() {
setShowInstallDialog(true);
};
const handleInstall = async (item: UnifiedMarketplaceItem, instanceName?: string) => {
const handleInstall = async (item: UnifiedMarketplaceItem, instanceName?: string, customMcpConfigs?: Record<string, Record<string, any>>) => {
setInstallingItemId(item.id);
try {
@ -713,18 +850,29 @@ export default function UnifiedMarketplacePage() {
} else if (item.type === 'secure' && item.template_id) {
const result = await installTemplateMutation.mutateAsync({
template_id: item.template_id,
instance_name: instanceName
instance_name: instanceName,
custom_mcp_configs: customMcpConfigs
});
if (result.status === 'installed') {
toast.success('Agent installed successfully!');
setShowInstallDialog(false);
setShowDetailsSheet(false);
} else if (result.status === 'credentials_required') {
setMissingCredentials(result.missing_credentials || []);
setShowInstallDialog(false);
setShowDetailsSheet(false);
setShowMissingCredsDialog(true);
} else if (result.status === 'configs_required') {
// Handle missing regular credentials
if (result.missing_regular_credentials && result.missing_regular_credentials.length > 0) {
setMissingCredentials(result.missing_regular_credentials);
setShowInstallDialog(false);
setShowDetailsSheet(false);
setShowMissingCredsDialog(true);
return;
}
// Handle missing custom configs - this should be handled by the install dialog
if (result.missing_custom_configs && result.missing_custom_configs.length > 0) {
toast.error('Please provide all required custom MCP server configurations');
return;
}
}
}
} catch (error: any) {
@ -1042,22 +1190,12 @@ export default function UnifiedMarketplacePage() {
)}
</div>
<AgentDetailsSheet
item={selectedItem}
open={showDetailsSheet}
onOpenChange={setShowDetailsSheet}
onInstall={handleInstall}
isInstalling={installingItemId === selectedItem?.id}
credentialStatus={selectedItem ? getItemCredentialStatus(selectedItem) : undefined}
/>
<InstallDialog
item={selectedItem}
open={showInstallDialog}
onOpenChange={setShowInstallDialog}
onInstall={handleInstall}
isInstalling={installingItemId === selectedItem?.id}
credentialStatus={selectedItem ? getItemCredentialStatus(selectedItem) : undefined}
/>
<MissingCredentialsDialog

View File

@ -6,7 +6,8 @@ import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Search, ChevronLeft, ChevronRight, Shield, Loader2, ArrowLeft, Key, ExternalLink } from 'lucide-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Search, ChevronLeft, ChevronRight, Shield, Loader2, ArrowLeft, Key, ExternalLink, Plus, Globe } from 'lucide-react';
import { usePopularMCPServersV2, useMCPServers, useMCPServerDetails } from '@/hooks/react-query/mcp/use-mcp-servers';
import { useStoreCredential, type StoreCredentialRequest } from '@/hooks/react-query/secure-mcp/use-secure-mcp';
import { toast } from 'sonner';
@ -119,12 +120,13 @@ export const EnhancedAddCredentialDialog: React.FC<EnhancedAddCredentialDialogPr
onOpenChange,
onSuccess
}) => {
const [step, setStep] = useState<'browse' | 'configure'>('browse');
const [step, setStep] = useState<'browse' | 'configure' | 'custom'>('browse');
const [selectedServer, setSelectedServer] = useState<any>(null);
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(50);
const [customServerType, setCustomServerType] = useState<'sse' | 'http'>('sse');
const [formData, setFormData] = useState<{
display_name: string;
config: Record<string, string>;
@ -156,6 +158,7 @@ export const EnhancedAddCredentialDialog: React.FC<EnhancedAddCredentialDialogPr
setSearchQuery('');
setStep('browse');
setSelectedServer(null);
setCustomServerType('sse');
setFormData({ display_name: '', config: {} });
}
}, [open]);
@ -188,14 +191,23 @@ export const EnhancedAddCredentialDialog: React.FC<EnhancedAddCredentialDialogPr
};
const handleSubmit = async () => {
if (!selectedServer) return;
try {
const request: StoreCredentialRequest = {
mcp_qualified_name: selectedServer.qualifiedName,
display_name: formData.display_name,
config: formData.config
};
let request: StoreCredentialRequest;
if (step === 'custom') {
const qualifiedName = `custom_${customServerType}_${formData.display_name.toLowerCase().replace(/\s+/g, '_')}`;
request = {
mcp_qualified_name: qualifiedName,
display_name: formData.display_name,
config: formData.config
};
} else {
if (!selectedServer) return;
request = {
mcp_qualified_name: selectedServer.qualifiedName,
display_name: formData.display_name,
config: formData.config
};
}
await storeCredentialMutation.mutateAsync(request);
toast.success('Credential stored successfully!');
@ -211,6 +223,11 @@ export const EnhancedAddCredentialDialog: React.FC<EnhancedAddCredentialDialogPr
setSelectedServer(null);
};
const handleCustomServerSetup = () => {
setStep('custom');
setFormData({ display_name: '', config: {} });
};
const getConfigSchema = () => {
return serverDetails?.connections?.[0]?.configSchema;
};
@ -251,11 +268,15 @@ export const EnhancedAddCredentialDialog: React.FC<EnhancedAddCredentialDialogPr
<DialogContent className="max-w-6xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>
{step === 'browse' ? 'Add MCP Credential' : `Configure ${selectedServer?.displayName}`}
{step === 'browse' ? 'Add MCP Credential' :
step === 'custom' ? 'Add Custom MCP Server' :
`Configure ${selectedServer?.displayName}`}
</DialogTitle>
<DialogDescription>
{step === 'browse'
? 'Select an MCP server to configure credentials for'
? 'Select an MCP server to configure credentials for, or add a custom server'
: step === 'custom'
? 'Configure your own custom MCP server connection'
: 'Enter your API credentials for this MCP server'
}
</DialogDescription>
@ -263,14 +284,27 @@ export const EnhancedAddCredentialDialog: React.FC<EnhancedAddCredentialDialogPr
{step === 'browse' && (
<>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search MCP servers..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
<div className="space-y-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search MCP servers..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex justify-center">
<Button
variant="outline"
onClick={handleCustomServerSetup}
className="gap-2"
>
<Plus className="h-4 w-4" />
Add Custom MCP Server
</Button>
</div>
</div>
<div className="flex flex-1 gap-4 overflow-hidden">
@ -431,8 +465,79 @@ export const EnhancedAddCredentialDialog: React.FC<EnhancedAddCredentialDialogPr
</div>
)}
{step === 'custom' && (
<div className="space-y-6">
<div className="flex items-center gap-3 p-4 bg-muted/50 rounded-lg">
<div className="w-12 h-12 rounded-md bg-primary/10 flex items-center justify-center">
<Globe className="h-6 w-6 text-primary" />
</div>
<div>
<h3 className="font-medium">Custom MCP Server</h3>
<p className="text-sm text-muted-foreground">Configure your own MCP server connection</p>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="custom_display_name">Display Name *</Label>
<Input
id="custom_display_name"
value={formData.display_name}
onChange={(e) => setFormData(prev => ({ ...prev, display_name: e.target.value }))}
placeholder="Enter a name for this custom server (e.g., 'My Tavily Server')"
/>
</div>
<div className="space-y-2">
<Label htmlFor="server_type">Server Type *</Label>
<Select value={customServerType} onValueChange={(value: 'sse' | 'http') => setCustomServerType(value)}>
<SelectTrigger>
<SelectValue placeholder="Select server type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sse">SSE (Server-Sent Events)</SelectItem>
<SelectItem value="http">HTTP</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Choose the connection type for your MCP server
</p>
</div>
<div className="space-y-2">
<Label htmlFor="server_url">Server URL *</Label>
<Input
id="server_url"
type="url"
value={formData.config.url || ''}
onChange={(e) => handleConfigChange('url', e.target.value)}
placeholder={`Enter your ${customServerType.toUpperCase()} server URL`}
/>
<p className="text-xs text-muted-foreground">
The URL to your custom MCP server endpoint
</p>
</div>
<Alert>
<Globe className="h-4 w-4" />
<AlertDescription>
This will create a custom MCP server configuration that you can use in your agents.
Make sure your server URL is accessible and properly configured.
</AlertDescription>
</Alert>
<Alert>
<Shield className="h-4 w-4" />
<AlertDescription>
Your server URL will be encrypted and stored securely. It will only be used when you run agents that require this custom MCP server.
</AlertDescription>
</Alert>
</div>
</div>
)}
<DialogFooter>
{step === 'configure' && (
{(step === 'configure' || step === 'custom') && (
<Button variant="outline" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
@ -440,12 +545,19 @@ export const EnhancedAddCredentialDialog: React.FC<EnhancedAddCredentialDialogPr
)}
<Button
onClick={step === 'browse' ? () => onOpenChange(false) : handleSubmit}
disabled={step === 'configure' && (
!formData.display_name ||
(getRequiredFields().length > 0 &&
getRequiredFields().some(field => !formData.config[field])) ||
storeCredentialMutation.isPending
)}
disabled={
(step === 'configure' && (
!formData.display_name ||
(getRequiredFields().length > 0 &&
getRequiredFields().some(field => !formData.config[field])) ||
storeCredentialMutation.isPending
)) ||
(step === 'custom' && (
!formData.display_name ||
!formData.config.url ||
storeCredentialMutation.isPending
))
}
>
{step === 'browse' ? 'Cancel' :
storeCredentialMutation.isPending ? (

View File

@ -33,6 +33,13 @@ const CredentialCard: React.FC<CredentialCardProps> = ({
}) => {
const isTesting = isTestingId === credential.mcp_qualified_name;
const isDeleting = isDeletingId === credential.mcp_qualified_name;
const isCustomServer = credential.mcp_qualified_name.startsWith('custom_');
const getCustomServerType = () => {
if (credential.mcp_qualified_name.startsWith('custom_sse_')) return 'SSE';
if (credential.mcp_qualified_name.startsWith('custom_http_')) return 'HTTP';
return null;
};
return (
<Card className="border-border/50 hover:border-border transition-colors">
@ -44,6 +51,11 @@ const CredentialCard: React.FC<CredentialCardProps> = ({
<Key className="h-4 w-4 text-primary" />
</div>
<h3 className="text-md font-medium text-foreground">{credential.display_name}</h3>
{isCustomServer && (
<Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200">
Custom {getCustomServerType()}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-mono">
{credential.mcp_qualified_name}
@ -51,7 +63,7 @@ const CredentialCard: React.FC<CredentialCardProps> = ({
<div className="flex flex-wrap gap-1">
{credential.config_keys.map((key) => (
<Badge key={key} variant="outline">
{key}
{key === 'url' && isCustomServer ? 'Server URL' : key}
</Badge>
))}
</div>

View File

@ -51,22 +51,30 @@ export interface MCPRequirement {
display_name: string;
enabled_tools: string[];
required_config: string[];
custom_type?: 'sse' | 'http'; // For custom MCP servers
}
export interface InstallTemplateRequest {
template_id: string;
instance_name?: string;
custom_system_prompt?: string;
custom_mcp_configs?: Record<string, Record<string, any>>;
}
export interface InstallationResponse {
status: 'installed' | 'credentials_required';
status: 'installed' | 'configs_required';
instance_id?: string;
missing_credentials?: {
missing_regular_credentials?: {
qualified_name: string;
display_name: string;
required_config: string[];
}[];
missing_custom_configs?: {
qualified_name: string;
display_name: string;
custom_type: string;
required_config: string[];
}[];
template?: {
template_id: string;
name: string;