mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1e879d981 |
No files matched your search
@@ -0,0 +1,183 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Button } from '../components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../components/ui/dialog'
|
||||
import { Badge } from '../components/ui/badge'
|
||||
import { Checkbox } from '../components/ui/checkbox'
|
||||
import { Textarea } from '../components/ui/textarea'
|
||||
|
||||
interface ApprovalRequest {
|
||||
id: string
|
||||
tool_name: string
|
||||
arguments: Record<string, any>
|
||||
risk_level: string
|
||||
agent_name?: string
|
||||
description?: string
|
||||
session_id?: string
|
||||
}
|
||||
|
||||
const riskColors: Record<string, string> = {
|
||||
low: 'bg-green-500',
|
||||
medium: 'bg-yellow-500',
|
||||
high: 'bg-orange-500',
|
||||
critical: 'bg-red-500',
|
||||
}
|
||||
|
||||
const riskIcons: Record<string, string> = {
|
||||
low: '✅',
|
||||
medium: '⚠️',
|
||||
high: '🟠',
|
||||
critical: '🔴',
|
||||
}
|
||||
|
||||
export function ApprovalModal() {
|
||||
const [approval, setApproval] = useState<ApprovalRequest | null>(null)
|
||||
const [reason, setReason] = useState('')
|
||||
const [alwaysAllow, setAlwaysAllow] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleApprovalRequired = (event: CustomEvent<ApprovalRequest>) => {
|
||||
setApproval(event.detail)
|
||||
setReason('')
|
||||
setAlwaysAllow(false)
|
||||
}
|
||||
|
||||
window.addEventListener('approval-required', handleApprovalRequired as EventListener)
|
||||
return () => {
|
||||
window.removeEventListener('approval-required', handleApprovalRequired as EventListener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!approval) return
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/approvals/${approval.id}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
reason,
|
||||
approver: 'user',
|
||||
always: alwaysAllow,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setApproval(null)
|
||||
setReason('')
|
||||
setAlwaysAllow(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to approve:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeny = async () => {
|
||||
if (!approval) return
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/approvals/${approval.id}/deny`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
reason,
|
||||
approver: 'user',
|
||||
always: alwaysAllow,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setApproval(null)
|
||||
setReason('')
|
||||
setAlwaysAllow(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to deny:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!approval) return null
|
||||
|
||||
return (
|
||||
<Dialog open={!!approval} onOpenChange={(open) => !open && !loading && setApproval(null)}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{riskIcons[approval.risk_level] || '⚠️'} Tool Execution Approval Required
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{approval.agent_name || 'Agent'} wants to execute: {approval.tool_name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="my-4 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={riskColors[approval.risk_level] || 'bg-yellow-500'}>
|
||||
Risk Level: {approval.risk_level}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{approval.description && (
|
||||
<p className="text-sm">{approval.description}</p>
|
||||
)}
|
||||
|
||||
{Object.keys(approval.arguments).length > 0 && (
|
||||
<div className="bg-gray-100 dark:bg-gray-800 p-3 rounded">
|
||||
<p className="text-sm font-semibold mb-2">Tool Arguments:</p>
|
||||
<pre className="text-xs overflow-auto max-h-40">
|
||||
{JSON.stringify(approval.arguments, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
placeholder="Optional: Provide a reason for your decision..."
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="min-h-[60px]"
|
||||
/>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="always"
|
||||
checked={alwaysAllow}
|
||||
onCheckedChange={(checked) => setAlwaysAllow(checked as boolean)}
|
||||
/>
|
||||
<label htmlFor="always" className="text-sm cursor-pointer">
|
||||
Always allow this tool for this session
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setApproval(null)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeny}
|
||||
disabled={loading}
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApprove}
|
||||
disabled={loading}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { ChatMessages } from './ChatMessages'
|
||||
import { ChatInput } from './ChatInput'
|
||||
import { StarterMessages } from './StarterMessages'
|
||||
import { TaskSidebar } from './TaskSidebar'
|
||||
import { ApprovalModal } from './ApprovalModal'
|
||||
|
||||
interface ChatAreaProps {
|
||||
config?: ChatConfig
|
||||
@@ -174,6 +175,9 @@ export function ChatArea({ config, className = '', sessionId: externalSessionId,
|
||||
sessionId={externalSessionId || 'default'}
|
||||
onTaskClick={handleTaskClick}
|
||||
/>
|
||||
|
||||
{/* Global approval modal */}
|
||||
<ApprovalModal />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -320,6 +320,22 @@ function handleStoreEvent(
|
||||
break
|
||||
}
|
||||
|
||||
case 'approval_required': {
|
||||
// Handle approval request - trigger modal via global event
|
||||
const approval = {
|
||||
id: event.id as string,
|
||||
tool_name: event.tool_name as string,
|
||||
arguments: event.arguments as Record<string, unknown> || {},
|
||||
risk_level: event.risk_level as string || 'medium',
|
||||
agent_name: event.agent_name as string,
|
||||
description: event.description as string,
|
||||
session_id: sessionId,
|
||||
}
|
||||
// Dispatch custom event for approval modal
|
||||
window.dispatchEvent(new CustomEvent('approval-required', { detail: approval }))
|
||||
break
|
||||
}
|
||||
|
||||
case 'end':
|
||||
case 'done':
|
||||
case 'run_cancelled':
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Button } from '../components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../components/ui/dialog'
|
||||
import { Badge } from '../components/ui/badge'
|
||||
import { Checkbox } from '../components/ui/checkbox'
|
||||
import { Textarea } from '../components/ui/textarea'
|
||||
|
||||
interface Approval {
|
||||
id: string
|
||||
tool_name: string
|
||||
arguments: Record<string, any>
|
||||
risk_level: 'low' | 'medium' | 'high' | 'critical'
|
||||
agent_name?: string
|
||||
description?: string
|
||||
status: 'pending' | 'approved' | 'denied'
|
||||
created_at: number
|
||||
risk_icon?: string
|
||||
}
|
||||
|
||||
const riskColors = {
|
||||
low: 'bg-green-500',
|
||||
medium: 'bg-yellow-500',
|
||||
high: 'bg-orange-500',
|
||||
critical: 'bg-red-500',
|
||||
}
|
||||
|
||||
export function ApprovalsView() {
|
||||
const [approvals, setApprovals] = useState<Approval[]>([])
|
||||
const [activeApproval, setActiveApproval] = useState<Approval | null>(null)
|
||||
const [reason, setReason] = useState('')
|
||||
const [alwaysAllow, setAlwaysAllow] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchApprovals()
|
||||
const eventSource = setupSSE()
|
||||
return () => eventSource?.close()
|
||||
}, [])
|
||||
|
||||
const fetchApprovals = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/approvals?status=pending')
|
||||
const data = await res.json()
|
||||
setApprovals(data.approvals || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch approvals:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const setupSSE = () => {
|
||||
try {
|
||||
const eventSource = new EventSource('/api/approvals/stream')
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data.event === 'new' || data.event === 'resolved') {
|
||||
fetchApprovals()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('SSE parse error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
console.error('SSE connection error')
|
||||
}
|
||||
|
||||
return eventSource
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!activeApproval) return
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/approvals/${activeApproval.id}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
reason,
|
||||
approver: 'user',
|
||||
always: alwaysAllow,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setActiveApproval(null)
|
||||
setReason('')
|
||||
setAlwaysAllow(false)
|
||||
fetchApprovals()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to approve:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeny = async () => {
|
||||
if (!activeApproval) return
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/approvals/${activeApproval.id}/deny`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
reason,
|
||||
approver: 'user',
|
||||
always: alwaysAllow,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setActiveApproval(null)
|
||||
setReason('')
|
||||
setAlwaysAllow(false)
|
||||
fetchApprovals()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to deny:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold mb-2">Tool Execution Approvals</h1>
|
||||
<p className="text-gray-600">Review and approve tool execution requests</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{approvals.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
<p className="text-gray-500">No pending approvals</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
approvals.map((approval) => (
|
||||
<Card key={approval.id} className="cursor-pointer hover:shadow-lg transition-shadow"
|
||||
onClick={() => setActiveApproval(approval)}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<span>{approval.risk_icon || '⚠️'}</span>
|
||||
{approval.tool_name}
|
||||
</CardTitle>
|
||||
<Badge className={riskColors[approval.risk_level]}>
|
||||
{approval.risk_level}
|
||||
</Badge>
|
||||
</div>
|
||||
{approval.agent_name && (
|
||||
<CardDescription>Agent: {approval.agent_name}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
{approval.description || 'Tool execution request'}
|
||||
</p>
|
||||
{Object.keys(approval.arguments).length > 0 && (
|
||||
<div className="text-xs bg-gray-100 p-2 rounded">
|
||||
<pre>{JSON.stringify(approval.arguments, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={!!activeApproval} onOpenChange={(open) => !open && setActiveApproval(null)}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{activeApproval?.risk_icon || '⚠️'} Approve Tool Execution?
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{activeApproval?.tool_name} requested by {activeApproval?.agent_name || 'unknown agent'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="my-4">
|
||||
<div className="mb-3">
|
||||
<Badge className={activeApproval ? riskColors[activeApproval.risk_level] : ''}>
|
||||
Risk Level: {activeApproval?.risk_level}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{activeApproval?.description && (
|
||||
<p className="text-sm mb-3">{activeApproval.description}</p>
|
||||
)}
|
||||
|
||||
{activeApproval && Object.keys(activeApproval.arguments).length > 0 && (
|
||||
<div className="bg-gray-100 p-3 rounded mb-3">
|
||||
<p className="text-sm font-semibold mb-1">Arguments:</p>
|
||||
<pre className="text-xs">{JSON.stringify(activeApproval.arguments, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
placeholder="Optional: Provide a reason for your decision..."
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="mb-3"
|
||||
/>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="always"
|
||||
checked={alwaysAllow}
|
||||
onCheckedChange={(checked) => setAlwaysAllow(checked as boolean)}
|
||||
/>
|
||||
<label htmlFor="always" className="text-sm">
|
||||
Always {activeApproval?.status === 'pending' ? 'allow' : 'deny'} this tool
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setActiveApproval(null)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeny} disabled={loading}>
|
||||
Deny
|
||||
</Button>
|
||||
<Button onClick={handleApprove} disabled={loading}>
|
||||
Approve
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Button } from '../components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../components/ui/dialog'
|
||||
import { Input } from '../components/ui/input'
|
||||
import { Label } from '../components/ui/label'
|
||||
import { Badge } from '../components/ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../components/ui/tabs'
|
||||
|
||||
interface MCPServer {
|
||||
name: string
|
||||
transport: 'stdio' | 'sse' | 'http'
|
||||
status: 'connecting' | 'connected' | 'error' | 'disconnected'
|
||||
tools: Array<{
|
||||
name: string
|
||||
description: string
|
||||
input_schema: Record<string, any>
|
||||
}>
|
||||
last_error?: string
|
||||
connection_data?: Record<string, any>
|
||||
}
|
||||
|
||||
interface NewServerForm {
|
||||
name: string
|
||||
transport: 'stdio' | 'sse' | 'http'
|
||||
command?: string
|
||||
args?: string
|
||||
url?: string
|
||||
headers?: string
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
connected: 'bg-green-500',
|
||||
connecting: 'bg-yellow-500',
|
||||
error: 'bg-red-500',
|
||||
disconnected: 'bg-gray-500',
|
||||
}
|
||||
|
||||
export function McpView() {
|
||||
const [servers, setServers] = useState<MCPServer[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||
const [selectedServer, setSelectedServer] = useState<MCPServer | null>(null)
|
||||
const [form, setForm] = useState<NewServerForm>({
|
||||
name: '',
|
||||
transport: 'stdio',
|
||||
command: '',
|
||||
args: '',
|
||||
url: '',
|
||||
headers: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers()
|
||||
const interval = setInterval(fetchServers, 5000) // Poll every 5s
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/mcp/servers')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setServers(data.servers || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch MCP servers:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConnect = async () => {
|
||||
setLoading(true)
|
||||
|
||||
const config: any = {
|
||||
name: form.name,
|
||||
}
|
||||
|
||||
if (form.transport === 'stdio') {
|
||||
config.command = form.command
|
||||
if (form.args) {
|
||||
config.args = form.args.split(' ').filter(Boolean)
|
||||
}
|
||||
} else if (form.transport === 'sse' || form.transport === 'http') {
|
||||
config.url = form.url
|
||||
if (form.headers) {
|
||||
try {
|
||||
config.headers = JSON.parse(form.headers)
|
||||
} catch {
|
||||
alert('Invalid JSON for headers')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/mcp/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setShowAddDialog(false)
|
||||
setForm({
|
||||
name: '',
|
||||
transport: 'stdio',
|
||||
command: '',
|
||||
args: '',
|
||||
url: '',
|
||||
headers: '',
|
||||
})
|
||||
fetchServers()
|
||||
} else {
|
||||
const error = await res.text()
|
||||
alert(`Failed to connect: ${error}`)
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Connection error: ${err}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisconnect = async (serverName: string) => {
|
||||
if (!confirm(`Disconnect from ${serverName}?`)) return
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/mcp/disconnect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: serverName }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
fetchServers()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to disconnect:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestTool = async (serverName: string, toolName: string) => {
|
||||
try {
|
||||
const res = await fetch('/api/mcp/test-tool', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ server: serverName, tool: toolName }),
|
||||
})
|
||||
|
||||
const result = await res.json()
|
||||
alert(`Tool test result:\n${JSON.stringify(result, null, 2)}`)
|
||||
} catch (err) {
|
||||
alert(`Failed to test tool: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-2">MCP Manager</h1>
|
||||
<p className="text-gray-600">Manage Model Context Protocol server connections</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddDialog(true)}>
|
||||
+ Connect Server
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{servers.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
<p className="text-gray-500">No MCP servers configured</p>
|
||||
<Button className="mt-4" onClick={() => setShowAddDialog(true)}>
|
||||
Connect Your First Server
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
servers.map((server) => (
|
||||
<Card key={server.name}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle>{server.name}</CardTitle>
|
||||
<Badge className={statusColors[server.status]}>
|
||||
{server.status}
|
||||
</Badge>
|
||||
<Badge variant="outline">{server.transport}</Badge>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={server.status === 'connected' ? 'destructive' : 'default'}
|
||||
onClick={() =>
|
||||
server.status === 'connected'
|
||||
? handleDisconnect(server.name)
|
||||
: handleConnect()
|
||||
}
|
||||
>
|
||||
{server.status === 'connected' ? 'Disconnect' : 'Reconnect'}
|
||||
</Button>
|
||||
</div>
|
||||
{server.last_error && (
|
||||
<CardDescription className="text-red-500 mt-2">
|
||||
Error: {server.last_error}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="tools">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tools">Tools ({server.tools.length})</TabsTrigger>
|
||||
<TabsTrigger value="config">Configuration</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="tools" className="mt-4">
|
||||
{server.tools.length === 0 ? (
|
||||
<p className="text-gray-500">No tools available</p>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{server.tools.map((tool) => (
|
||||
<div key={tool.name} className="border rounded p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold">{tool.name}</p>
|
||||
<p className="text-sm text-gray-600">{tool.description}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleTestTool(server.name, tool.name)}
|
||||
>
|
||||
Test
|
||||
</Button>
|
||||
</div>
|
||||
{Object.keys(tool.input_schema).length > 0 && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-sm text-gray-500 cursor-pointer">
|
||||
Schema
|
||||
</summary>
|
||||
<pre className="text-xs mt-1 bg-gray-100 p-2 rounded overflow-auto">
|
||||
{JSON.stringify(tool.input_schema, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="config" className="mt-4">
|
||||
<div className="bg-gray-100 p-3 rounded">
|
||||
<pre className="text-sm">
|
||||
{JSON.stringify(server.connection_data || {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect MCP Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure a new Model Context Protocol server connection
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Server Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="my-mcp-server"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="transport">Transport Type</Label>
|
||||
<Select
|
||||
value={form.transport}
|
||||
onValueChange={(value) => setForm({ ...form, transport: value as any })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stdio">STDIO (Subprocess)</SelectItem>
|
||||
<SelectItem value="sse">SSE (Server-Sent Events)</SelectItem>
|
||||
<SelectItem value="http">HTTP (REST API)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{form.transport === 'stdio' && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="command">Command</Label>
|
||||
<Input
|
||||
id="command"
|
||||
value={form.command}
|
||||
onChange={(e) => setForm({ ...form, command: e.target.value })}
|
||||
placeholder="npx"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="args">Arguments (space-separated)</Label>
|
||||
<Input
|
||||
id="args"
|
||||
value={form.args}
|
||||
onChange={(e) => setForm({ ...form, args: e.target.value })}
|
||||
placeholder="-y @modelcontextprotocol/server-filesystem"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(form.transport === 'sse' || form.transport === 'http') && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="url">Server URL</Label>
|
||||
<Input
|
||||
id="url"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
||||
placeholder="http://localhost:3000/sse"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="headers">Headers (JSON)</Label>
|
||||
<Input
|
||||
id="headers"
|
||||
value={form.headers}
|
||||
onChange={(e) => setForm({ ...form, headers: e.target.value })}
|
||||
placeholder='{"Authorization": "Bearer token"}'
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowAddDialog(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConnect} disabled={loading || !form.name}>
|
||||
{loading ? 'Connecting...' : 'Connect'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -63,6 +63,7 @@ def auto_register_defaults() -> None:
|
||||
(".kanban", "KanbanFeature"),
|
||||
(".logs", "LogsFeature"),
|
||||
(".marketplace", "MarketplaceFeature"),
|
||||
(".mcp_api", "MCPAPIFeature"),
|
||||
(".media_analysis", "MediaAnalysisFeature"),
|
||||
(".memory", "MemoryFeature"),
|
||||
(".knowledge", "KnowledgeFeature"),
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""MCP API endpoints for UI management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from ._base import BaseFeatureProtocol
|
||||
from .mcp import (
|
||||
MCPServer,
|
||||
MCPStatus,
|
||||
connect_mcp_server,
|
||||
disconnect_mcp_server,
|
||||
get_mcp_server,
|
||||
list_mcp_servers,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MCPAPIFeature(BaseFeatureProtocol):
|
||||
"""REST API endpoints for MCP server management."""
|
||||
|
||||
feature_name = "mcp_api"
|
||||
feature_description = "MCP server management API endpoints"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.feature_name
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return self.feature_description
|
||||
|
||||
def routes(self) -> List[Route]:
|
||||
return [
|
||||
Route("/api/mcp/servers", self._list_servers, methods=["GET"]),
|
||||
Route("/api/mcp/connect", self._connect_server, methods=["POST"]),
|
||||
Route("/api/mcp/disconnect", self._disconnect_server, methods=["POST"]),
|
||||
Route("/api/mcp/test-tool", self._test_tool, methods=["POST"]),
|
||||
Route("/api/mcp/server/{name}", self._get_server, methods=["GET"]),
|
||||
]
|
||||
|
||||
async def health(self) -> Dict[str, Any]:
|
||||
servers = await list_mcp_servers()
|
||||
connected = sum(1 for s in servers if s.status == MCPStatus.CONNECTED)
|
||||
return {
|
||||
"status": "ok",
|
||||
"feature": self.name,
|
||||
"total_servers": len(servers),
|
||||
"connected_servers": connected,
|
||||
}
|
||||
|
||||
async def _list_servers(self, request: Request) -> JSONResponse:
|
||||
"""List all MCP servers."""
|
||||
try:
|
||||
servers = await list_mcp_servers()
|
||||
return JSONResponse({
|
||||
"servers": [self._serialize_server(s) for s in servers],
|
||||
"count": len(servers),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list MCP servers: {e}")
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
async def _connect_server(self, request: Request) -> JSONResponse:
|
||||
"""Connect to an MCP server."""
|
||||
try:
|
||||
config = await request.json()
|
||||
|
||||
# Validate required fields
|
||||
if "name" not in config:
|
||||
return JSONResponse({"error": "Server name is required"}, status_code=400)
|
||||
|
||||
# Connect to the server
|
||||
server = await connect_mcp_server(config)
|
||||
|
||||
return JSONResponse({
|
||||
"server": self._serialize_server(server),
|
||||
"success": server.status == MCPStatus.CONNECTED,
|
||||
}, status_code=201 if server.status == MCPStatus.CONNECTED else 500)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect MCP server: {e}")
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
async def _disconnect_server(self, request: Request) -> JSONResponse:
|
||||
"""Disconnect an MCP server."""
|
||||
try:
|
||||
body = await request.json()
|
||||
name = body.get("name")
|
||||
|
||||
if not name:
|
||||
return JSONResponse({"error": "Server name is required"}, status_code=400)
|
||||
|
||||
success = await disconnect_mcp_server(name)
|
||||
|
||||
if success:
|
||||
return JSONResponse({"success": True, "message": f"Disconnected from {name}"})
|
||||
else:
|
||||
return JSONResponse({"error": f"Server {name} not found"}, status_code=404)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to disconnect MCP server: {e}")
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
async def _get_server(self, request: Request) -> JSONResponse:
|
||||
"""Get a specific MCP server by name."""
|
||||
try:
|
||||
name = request.path_params["name"]
|
||||
server = await get_mcp_server(name)
|
||||
|
||||
if server:
|
||||
return JSONResponse({"server": self._serialize_server(server)})
|
||||
else:
|
||||
return JSONResponse({"error": f"Server {name} not found"}, status_code=404)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get MCP server: {e}")
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
async def _test_tool(self, request: Request) -> JSONResponse:
|
||||
"""Test a tool from an MCP server."""
|
||||
try:
|
||||
body = await request.json()
|
||||
server_name = body.get("server")
|
||||
tool_name = body.get("tool")
|
||||
arguments = body.get("arguments", {})
|
||||
|
||||
if not server_name or not tool_name:
|
||||
return JSONResponse(
|
||||
{"error": "Server name and tool name are required"},
|
||||
status_code=400
|
||||
)
|
||||
|
||||
server = await get_mcp_server(server_name)
|
||||
if not server:
|
||||
return JSONResponse({"error": f"Server {server_name} not found"}, status_code=404)
|
||||
|
||||
if server.status != MCPStatus.CONNECTED:
|
||||
return JSONResponse(
|
||||
{"error": f"Server {server_name} is not connected"},
|
||||
status_code=400
|
||||
)
|
||||
|
||||
# Get the client from the server
|
||||
client = getattr(server, "_client", None)
|
||||
if not client:
|
||||
return JSONResponse({"error": "Server client not available"}, status_code=500)
|
||||
|
||||
# Call the tool
|
||||
try:
|
||||
result = await client.call_tool(tool_name, arguments)
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"result": result,
|
||||
"tool": tool_name,
|
||||
"server": server_name,
|
||||
})
|
||||
except Exception as tool_error:
|
||||
return JSONResponse({
|
||||
"error": f"Tool execution failed: {tool_error}",
|
||||
"tool": tool_name,
|
||||
"server": server_name,
|
||||
}, status_code=500)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to test MCP tool: {e}")
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
def _serialize_server(self, server: MCPServer) -> Dict[str, Any]:
|
||||
"""Serialize an MCP server for JSON response."""
|
||||
return {
|
||||
"name": server.name,
|
||||
"transport": server.transport.value,
|
||||
"status": server.status.value,
|
||||
"tools": [
|
||||
{
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"input_schema": tool.input_schema,
|
||||
}
|
||||
for tool in server.tools
|
||||
],
|
||||
"last_error": server.last_error,
|
||||
"connection_data": server.connection_data,
|
||||
}
|
||||
@@ -43,6 +43,7 @@ const BUILTIN_VIEWS = {
|
||||
tools: '/plugins/views/skills.js',
|
||||
nodes: '/plugins/views/nodes.js',
|
||||
instances: '/plugins/views/nodes.js',
|
||||
mcp: '/plugins/views/mcp.js',
|
||||
explorer: '/plugins/views/explorer.js',
|
||||
debug: '/plugins/views/debug.js',
|
||||
guardrails: '/plugins/views/guardrails.js',
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* MCP Manager View — Model Context Protocol server management
|
||||
* API: /api/mcp/*
|
||||
*/
|
||||
export async function render(container) {
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
|
||||
let servers = [];
|
||||
try {
|
||||
const r = await fetch('/api/mcp/servers');
|
||||
const data = await r.json();
|
||||
servers = data.servers || [];
|
||||
} catch(e) {
|
||||
container.innerHTML = '<div class="db-error">Failed to load MCP servers</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
connected: '#22c55e',
|
||||
connecting: '#eab308',
|
||||
error: '#ef4444',
|
||||
disconnected: '#6b7280'
|
||||
};
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="db-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:24px">
|
||||
<div>
|
||||
<h2 style="margin:0;font-size:20px;font-weight:600">MCP Manager</h2>
|
||||
<p style="margin:4px 0 0;font-size:13px;color:var(--db-text-dim)">Manage Model Context Protocol server connections</p>
|
||||
</div>
|
||||
<button id="add-mcp" class="db-btn" style="background:var(--db-primary);color:white">+ Connect Server</button>
|
||||
</div>
|
||||
|
||||
<div class="db-columns" style="grid-template-columns:repeat(3,1fr);margin-bottom:24px">
|
||||
<div class="db-card">
|
||||
<div class="db-card-title">Total Servers</div>
|
||||
<div class="db-card-value">${servers.length}</div>
|
||||
</div>
|
||||
<div class="db-card">
|
||||
<div class="db-card-title">Connected</div>
|
||||
<div class="db-card-value" style="color:#22c55e">${servers.filter(s => s.status === 'connected').length}</div>
|
||||
</div>
|
||||
<div class="db-card">
|
||||
<div class="db-card-title">Total Tools</div>
|
||||
<div class="db-card-value">${servers.reduce((sum, s) => sum + (s.tools?.length || 0), 0)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mcp-servers">
|
||||
${servers.length === 0 ? '<div class="db-card" style="text-align:center;padding:48px 24px"><p style="color:var(--db-text-dim);margin:0 0 16px">No MCP servers configured</p><button id="add-first" class="db-btn" style="background:var(--db-primary);color:white">Connect Your First Server</button></div>' : ''}
|
||||
</div>
|
||||
|
||||
<div id="mcp-modal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999">
|
||||
<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--db-card-bg);border-radius:12px;padding:24px;max-width:500px;width:90%">
|
||||
<h3 style="margin:0 0 20px;font-size:18px">Connect MCP Server</h3>
|
||||
<form id="mcp-form">
|
||||
<div style="margin-bottom:16px">
|
||||
<label style="display:block;font-size:13px;margin-bottom:6px">Server Name</label>
|
||||
<input name="name" required style="width:100%;padding:8px 12px;border:1px solid var(--db-border);border-radius:6px;background:var(--db-bg);color:var(--db-text)" placeholder="my-mcp-server">
|
||||
</div>
|
||||
<div style="margin-bottom:16px">
|
||||
<label style="display:block;font-size:13px;margin-bottom:6px">Transport Type</label>
|
||||
<select name="transport" id="transport-select" style="width:100%;padding:8px 12px;border:1px solid var(--db-border);border-radius:6px;background:var(--db-bg);color:var(--db-text)">
|
||||
<option value="stdio">STDIO (Subprocess)</option>
|
||||
<option value="sse">SSE (Server-Sent Events)</option>
|
||||
<option value="http">HTTP (REST API)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="stdio-fields">
|
||||
<div style="margin-bottom:16px">
|
||||
<label style="display:block;font-size:13px;margin-bottom:6px">Command</label>
|
||||
<input name="command" style="width:100%;padding:8px 12px;border:1px solid var(--db-border);border-radius:6px;background:var(--db-bg);color:var(--db-text)" placeholder="npx">
|
||||
</div>
|
||||
<div style="margin-bottom:16px">
|
||||
<label style="display:block;font-size:13px;margin-bottom:6px">Arguments (space-separated)</label>
|
||||
<input name="args" style="width:100%;padding:8px 12px;border:1px solid var(--db-border);border-radius:6px;background:var(--db-bg);color:var(--db-text)" placeholder="-y @modelcontextprotocol/server-filesystem">
|
||||
</div>
|
||||
</div>
|
||||
<div id="url-fields" style="display:none">
|
||||
<div style="margin-bottom:16px">
|
||||
<label style="display:block;font-size:13px;margin-bottom:6px">Server URL</label>
|
||||
<input name="url" style="width:100%;padding:8px 12px;border:1px solid var(--db-border);border-radius:6px;background:var(--db-bg);color:var(--db-text)" placeholder="http://localhost:3000/sse">
|
||||
</div>
|
||||
<div style="margin-bottom:16px">
|
||||
<label style="display:block;font-size:13px;margin-bottom:6px">Headers (JSON, optional)</label>
|
||||
<input name="headers" style="width:100%;padding:8px 12px;border:1px solid var(--db-border);border-radius:6px;background:var(--db-bg);color:var(--db-text)" placeholder='{"Authorization": "Bearer token"}'>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;justify-content:flex-end">
|
||||
<button type="button" id="cancel-modal" class="db-btn" style="background:var(--db-card-bg);border:1px solid var(--db-border)">Cancel</button>
|
||||
<button type="submit" class="db-btn" style="background:var(--db-primary);color:white">Connect</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Render servers
|
||||
const serversEl = container.querySelector('#mcp-servers');
|
||||
if (servers.length > 0) {
|
||||
servers.forEach(server => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'db-card';
|
||||
card.style.cssText = 'margin-bottom:16px;padding:20px';
|
||||
|
||||
const toolsHtml = server.tools?.length > 0
|
||||
? server.tools.map(t => `
|
||||
<div style="padding:8px 12px;background:var(--db-bg);border-radius:6px;margin-bottom:8px">
|
||||
<div style="font-weight:500;font-size:13px">${t.name}</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim);margin-top:2px">${t.description || 'No description'}</div>
|
||||
</div>
|
||||
`).join('')
|
||||
: '<div style="font-size:13px;color:var(--db-text-dim)">No tools available</div>';
|
||||
|
||||
card.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:16px">
|
||||
<div>
|
||||
<div style="font-size:16px;font-weight:600;margin-bottom:4px">${server.name}</div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;background:${statusColors[server.status]}20;color:${statusColors[server.status]};font-weight:500">${server.status.toUpperCase()}</span>
|
||||
<span style="font-size:12px;color:var(--db-text-dim)">${server.transport}</span>
|
||||
</div>
|
||||
${server.last_error ? `<div style="color:#ef4444;font-size:12px;margin-top:4px">Error: ${server.last_error}</div>` : ''}
|
||||
</div>
|
||||
<button class="mcp-disconnect db-btn" data-name="${server.name}" style="padding:6px 12px;font-size:12px;background:rgba(239,68,68,0.1);color:#ef4444;border:1px solid rgba(239,68,68,0.2)">Disconnect</button>
|
||||
</div>
|
||||
<details>
|
||||
<summary style="cursor:pointer;font-size:13px;font-weight:500;margin-bottom:8px">Tools (${server.tools?.length || 0})</summary>
|
||||
<div style="margin-top:8px">${toolsHtml}</div>
|
||||
</details>
|
||||
`;
|
||||
serversEl.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
// Modal handling
|
||||
const modal = container.querySelector('#mcp-modal');
|
||||
const form = container.querySelector('#mcp-form');
|
||||
const transportSelect = container.querySelector('#transport-select');
|
||||
const stdioFields = container.querySelector('#stdio-fields');
|
||||
const urlFields = container.querySelector('#url-fields');
|
||||
|
||||
const showModal = () => modal.style.display = 'block';
|
||||
const hideModal = () => {
|
||||
modal.style.display = 'none';
|
||||
form.reset();
|
||||
};
|
||||
|
||||
transportSelect.addEventListener('change', () => {
|
||||
if (transportSelect.value === 'stdio') {
|
||||
stdioFields.style.display = 'block';
|
||||
urlFields.style.display = 'none';
|
||||
} else {
|
||||
stdioFields.style.display = 'none';
|
||||
urlFields.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
container.querySelector('#add-mcp')?.addEventListener('click', showModal);
|
||||
container.querySelector('#add-first')?.addEventListener('click', showModal);
|
||||
container.querySelector('#cancel-modal').addEventListener('click', hideModal);
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(form);
|
||||
const config = { name: formData.get('name') };
|
||||
|
||||
if (formData.get('transport') === 'stdio') {
|
||||
config.command = formData.get('command');
|
||||
const argsStr = formData.get('args');
|
||||
if (argsStr) config.args = argsStr.split(' ').filter(Boolean);
|
||||
} else {
|
||||
config.url = formData.get('url');
|
||||
const headersStr = formData.get('headers');
|
||||
if (headersStr) {
|
||||
try {
|
||||
config.headers = JSON.parse(headersStr);
|
||||
} catch {
|
||||
alert('Invalid JSON for headers');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/mcp/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
hideModal();
|
||||
render(container);
|
||||
} else {
|
||||
const error = await res.text();
|
||||
alert(`Failed to connect: ${error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Connection error: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
container.querySelectorAll('.mcp-disconnect').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm(`Disconnect from ${btn.dataset.name}?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/mcp/disconnect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: btn.dataset.name })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
render(container);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Failed to disconnect: ${err.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Test approvals and MCP integration."""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiui.features.approvals import ApprovalsFeature, SimpleApprovalManager
|
||||
from praisonaiui.features.mcp_api import MCPAPIFeature
|
||||
|
||||
|
||||
def test_approvals_feature_init():
|
||||
"""Test that ApprovalsFeature can be initialized."""
|
||||
feature = ApprovalsFeature()
|
||||
assert feature.name == "approvals"
|
||||
assert feature.description == "Tool-execution approval management"
|
||||
|
||||
# Check routes are registered
|
||||
routes = feature.routes()
|
||||
assert len(routes) > 0
|
||||
route_paths = [r.path for r in routes]
|
||||
assert "/api/approvals" in route_paths
|
||||
assert "/api/approvals/pending" in route_paths
|
||||
assert "/api/approvals/stream" in route_paths
|
||||
|
||||
|
||||
def test_simple_approval_manager():
|
||||
"""Test SimpleApprovalManager functionality."""
|
||||
mgr = SimpleApprovalManager()
|
||||
|
||||
# Request approval with high risk to ensure it's pending
|
||||
entry = {
|
||||
"tool_name": "test_tool",
|
||||
"risk_level": "high",
|
||||
"arguments": {"test": "arg"},
|
||||
"agent_name": "test_agent",
|
||||
}
|
||||
result = mgr.request_approval(entry)
|
||||
|
||||
assert result["id"]
|
||||
assert result["status"] == "pending"
|
||||
assert result["tool_name"] == "test_tool"
|
||||
|
||||
# List pending
|
||||
pending = mgr.list_pending()
|
||||
assert len(pending) == 1
|
||||
|
||||
# Approve it
|
||||
approved = mgr.approve(result["id"], "test reason")
|
||||
assert approved is not None
|
||||
assert approved["status"] == "approved"
|
||||
assert approved["reason"] == "test reason"
|
||||
|
||||
# Check it's no longer pending
|
||||
pending = mgr.list_pending()
|
||||
assert len(pending) == 0
|
||||
|
||||
# Check history
|
||||
history = mgr.list_history()
|
||||
assert len(history) == 1
|
||||
|
||||
|
||||
def test_mcp_api_feature_init():
|
||||
"""Test that MCPAPIFeature can be initialized."""
|
||||
feature = MCPAPIFeature()
|
||||
assert feature.name == "mcp_api"
|
||||
assert feature.description == "MCP server management API endpoints"
|
||||
|
||||
# Check routes are registered
|
||||
routes = feature.routes()
|
||||
assert len(routes) > 0
|
||||
route_paths = [r.path for r in routes]
|
||||
assert "/api/mcp/servers" in route_paths
|
||||
assert "/api/mcp/connect" in route_paths
|
||||
assert "/api/mcp/disconnect" in route_paths
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_api_health():
|
||||
"""Test MCP API health check."""
|
||||
feature = MCPAPIFeature()
|
||||
health = await feature.health()
|
||||
|
||||
assert health["status"] == "ok"
|
||||
assert health["feature"] == "mcp_api"
|
||||
assert "total_servers" in health
|
||||
assert "connected_servers" in health
|
||||
|
||||
|
||||
def test_approval_risk_levels():
|
||||
"""Test approval risk level handling."""
|
||||
mgr = SimpleApprovalManager()
|
||||
|
||||
# Test auto-approve for low risk
|
||||
mgr.update_policies({"risk_threshold": "medium"})
|
||||
|
||||
low_risk = mgr.request_approval({
|
||||
"tool_name": "safe_tool",
|
||||
"risk_level": "low",
|
||||
"arguments": {},
|
||||
})
|
||||
|
||||
# Should be auto-approved
|
||||
assert low_risk["status"] == "approved"
|
||||
assert low_risk["approver"] == "auto-policy"
|
||||
|
||||
# High risk should require approval
|
||||
high_risk = mgr.request_approval({
|
||||
"tool_name": "dangerous_tool",
|
||||
"risk_level": "high",
|
||||
"arguments": {},
|
||||
})
|
||||
|
||||
assert high_risk["status"] == "pending"
|
||||
|
||||
|
||||
def test_approval_policies():
|
||||
"""Test approval policy management."""
|
||||
mgr = SimpleApprovalManager()
|
||||
|
||||
# Get default policies
|
||||
policies = mgr.get_policies()
|
||||
assert "auto_approve_tools" in policies
|
||||
assert "always_deny_tools" in policies
|
||||
assert "risk_threshold" in policies
|
||||
|
||||
# Update policies
|
||||
mgr.update_policies({
|
||||
"auto_approve_tools": ["safe_tool"],
|
||||
"always_deny_tools": ["dangerous_tool"],
|
||||
})
|
||||
|
||||
# Test auto-approve
|
||||
safe = mgr.request_approval({
|
||||
"tool_name": "safe_tool",
|
||||
"risk_level": "high",
|
||||
"arguments": {},
|
||||
})
|
||||
assert safe["status"] == "approved"
|
||||
|
||||
# Test auto-deny
|
||||
dangerous = mgr.request_approval({
|
||||
"tool_name": "dangerous_tool",
|
||||
"risk_level": "low",
|
||||
"arguments": {},
|
||||
})
|
||||
assert dangerous["status"] == "denied"
|
||||
Reference in new issue
Block a user