Compare commits

...
Author SHA1 Message Date
MervinPraisonandClaude d1e879d981 feat: implement exec approval modal and MCP manager UI (fixes #127)
- Add B-01: Exec approval modal for tool execution consent
  - Created ApprovalModal React component for live approval requests
  - Integrated with SSE streaming to handle approval_required events
  - Added dashboard view for approvals management

- Add B-08: MCP manager screen in dashboard
  - Created MCPView React component for server management
  - Added MCP API endpoints for connect/disconnect/list operations
  - Created dashboard view for MCP server visualization

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-06-17 09:47:09 +00:00
10 changed files with 1369 additions and 0 deletions

No files matched your search

+183
View File
@@ -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>
)
}
+4
View File
@@ -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>
)
}
+16
View File
@@ -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':
+242
View File
@@ -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>
)
}
+364
View File
@@ -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>
)
}
+1
View File
@@ -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"),
+191
View File
@@ -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"