feat: Add MCP server approval workflow UI (mock)

This commit implements a mock/demo version of the MCP server approval
workflow UI to demonstrate the admin approval process.

Changes:
- Created approvals page with pending/approved/rejected tabs
- Added MCPApprovalTable component with view/approve/reject actions
- Added ApprovalDetailModal for viewing full request details
- Created mock data with 10 sample MCP approval requests
- Updated navigation to add "Approvals" menu item with badge count

Features:
- Admin-only access control
- Tab-based view of requests by status
- Approve/reject actions with reason capture
- Detailed view of all request metadata
- Badge showing pending request count in navigation

Tech stack:
- Next.js App Router (dashboard route)
- Ant Design components (Table, Modal, Tabs, Badge)
- TypeScript with proper type definitions
- Client-side state management (mock data)

Note: This is a prototype using mock data. Backend integration
and database schema will be added in future PRs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Krrish Dholakia 2026-02-18 23:36:07 -08:00
parent f8ef5a80e7
commit 393a9472e7
5 changed files with 797 additions and 1 deletions

View file

@ -0,0 +1,149 @@
"use client";
import { useEffect, useState } from "react";
import { Card, Tabs, Badge, message } from "antd";
import { CheckCircleOutlined, CloseCircleOutlined, ClockCircleOutlined } from "@ant-design/icons";
import MCPApprovalTable from "@/components/approvals/MCPApprovalTable";
import { isAdminRole } from "@/components/utils/roles";
import { mockMCPApprovalRequests, MCPApprovalRequest } from "@/components/approvals/mockData";
export default function ApprovalsPage() {
const [userRole, setUserRole] = useState<string | null>(null);
const [approvalRequests, setApprovalRequests] = useState<MCPApprovalRequest[]>(mockMCPApprovalRequests);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Get user role from localStorage/session
const role = localStorage.getItem("user_role") || null;
setUserRole(role);
setLoading(false);
}, []);
// Check if user is admin
if (loading) {
return (
<div style={{ padding: "24px", textAlign: "center" }}>
Loading...
</div>
);
}
if (!isAdminRole(userRole)) {
return (
<div style={{ padding: "24px" }}>
<Card>
<div style={{ textAlign: "center", padding: "40px" }}>
<CloseCircleOutlined style={{ fontSize: 48, color: "#ff4d4f", marginBottom: 16 }} />
<h2>Access Denied</h2>
<p>Admin role required to access this page.</p>
</div>
</Card>
</div>
);
}
const pendingCount = approvalRequests.filter(r => r.status === "pending").length;
const approvedCount = approvalRequests.filter(r => r.status === "approved").length;
const rejectedCount = approvalRequests.filter(r => r.status === "rejected").length;
const handleApprove = (approvalId: string) => {
setApprovalRequests(prev =>
prev.map(req =>
req.approval_id === approvalId
? {
...req,
status: "approved" as const,
reviewed_at: new Date().toISOString(),
reviewed_by: "current-admin"
}
: req
)
);
message.success("MCP server request approved successfully");
};
const handleReject = (approvalId: string, reason: string) => {
setApprovalRequests(prev =>
prev.map(req =>
req.approval_id === approvalId
? {
...req,
status: "rejected" as const,
rejection_reason: reason,
reviewed_at: new Date().toISOString(),
reviewed_by: "current-admin"
}
: req
)
);
message.success("MCP server request rejected");
};
const tabItems = [
{
key: "pending",
label: (
<span>
<ClockCircleOutlined /> Pending{" "}
{pendingCount > 0 && <Badge count={pendingCount} style={{ marginLeft: 8 }} />}
</span>
),
children: (
<MCPApprovalTable
requests={approvalRequests.filter(r => r.status === "pending")}
onApprove={handleApprove}
onReject={handleReject}
/>
),
},
{
key: "approved",
label: (
<span>
<CheckCircleOutlined /> Approved{" "}
{approvedCount > 0 && <Badge count={approvedCount} style={{ marginLeft: 8, backgroundColor: "#52c41a" }} />}
</span>
),
children: (
<MCPApprovalTable
requests={approvalRequests.filter(r => r.status === "approved")}
onApprove={handleApprove}
onReject={handleReject}
readOnly={true}
/>
),
},
{
key: "rejected",
label: (
<span>
<CloseCircleOutlined /> Rejected{" "}
{rejectedCount > 0 && <Badge count={rejectedCount} style={{ marginLeft: 8, backgroundColor: "#ff4d4f" }} />}
</span>
),
children: (
<MCPApprovalTable
requests={approvalRequests.filter(r => r.status === "rejected")}
onApprove={handleApprove}
onReject={handleReject}
readOnly={true}
/>
),
},
];
return (
<div style={{ padding: "24px" }}>
<Card
title="MCP Server Approvals"
extra={
<div style={{ fontSize: 14, color: "#888" }}>
{pendingCount} pending approval{pendingCount !== 1 ? "s" : ""}
</div>
}
>
<Tabs items={tabItems} defaultActiveKey="pending" />
</Card>
</div>
);
}

View file

@ -0,0 +1,152 @@
import { Modal, Descriptions, Tag, Button, Space, Divider } from "antd";
import { MCPApprovalRequest } from "./mockData";
interface ApprovalDetailModalProps {
request: MCPApprovalRequest;
visible: boolean;
onClose: () => void;
onApprove?: (approvalId: string) => void;
onReject?: (approvalId: string) => void;
}
export default function ApprovalDetailModal({
request,
visible,
onClose,
onApprove,
onReject
}: ApprovalDetailModalProps) {
const getStatusColor = (status: string) => {
switch (status) {
case "pending":
return "gold";
case "approved":
return "green";
case "rejected":
return "red";
default:
return "default";
}
};
return (
<Modal
title={`MCP Server Request: ${request.server_name}`}
open={visible}
onCancel={onClose}
width={800}
footer={
<Space>
<Button onClick={onClose}>Close</Button>
{onApprove && request.status === "pending" && (
<>
<Button
danger
onClick={() => {
onReject?.(request.approval_id);
onClose();
}}
>
Reject
</Button>
<Button
type="primary"
onClick={() => {
onApprove(request.approval_id);
onClose();
}}
>
Approve
</Button>
</>
)}
</Space>
}
>
<Descriptions column={1} bordered>
<Descriptions.Item label="Status">
<Tag color={getStatusColor(request.status)}>
{request.status.toUpperCase()}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Server Name">{request.server_name}</Descriptions.Item>
<Descriptions.Item label="Alias">{request.alias || "-"}</Descriptions.Item>
<Descriptions.Item label="Description">{request.description || "-"}</Descriptions.Item>
<Descriptions.Item label="URL">{request.url}</Descriptions.Item>
<Descriptions.Item label="Transport">{request.transport}</Descriptions.Item>
<Descriptions.Item label="Auth Type">{request.auth_type || "None"}</Descriptions.Item>
<Descriptions.Item label="Access Groups">
{request.mcp_access_groups.length > 0 ? (
request.mcp_access_groups.map(group => (
<Tag key={group} color="blue" style={{ marginBottom: 4 }}>
{group}
</Tag>
))
) : (
"-"
)}
</Descriptions.Item>
<Descriptions.Item label="Allowed Tools">
{request.allowed_tools.length > 0 ? (
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
{request.allowed_tools.map(tool => (
<Tag key={tool} style={{ marginBottom: 4 }}>
{tool}
</Tag>
))}
</div>
) : (
"-"
)}
</Descriptions.Item>
</Descriptions>
<Divider>Requester Information</Divider>
<Descriptions column={1} bordered>
<Descriptions.Item label="Requester Email">{request.requester_email}</Descriptions.Item>
<Descriptions.Item label="Team">{request.team_name || "-"}</Descriptions.Item>
<Descriptions.Item label="Requested Date">
{new Date(request.created_at).toLocaleString()}
</Descriptions.Item>
</Descriptions>
{request.request_metadata && (
<>
<Divider>Request Metadata</Divider>
<Descriptions column={1} bordered>
{request.request_metadata.business_justification && (
<Descriptions.Item label="Business Justification">
{request.request_metadata.business_justification}
</Descriptions.Item>
)}
{request.request_metadata.expected_usage && (
<Descriptions.Item label="Expected Usage">
{request.request_metadata.expected_usage}
</Descriptions.Item>
)}
</Descriptions>
</>
)}
{request.status !== "pending" && (
<>
<Divider>Review Information</Divider>
<Descriptions column={1} bordered>
<Descriptions.Item label="Reviewed At">
{request.reviewed_at ? new Date(request.reviewed_at).toLocaleString() : "-"}
</Descriptions.Item>
{request.rejection_reason && (
<Descriptions.Item label="Rejection Reason">
<div style={{ color: "#ff4d4f" }}>{request.rejection_reason}</div>
</Descriptions.Item>
)}
</Descriptions>
</>
)}
</Modal>
);
}

View file

@ -0,0 +1,231 @@
import { useState } from "react";
import { Table, Button, Space, Tag, Tooltip, Modal, Input, Badge } from "antd";
import { EyeOutlined, CheckOutlined, CloseOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import { MCPApprovalRequest } from "./mockData";
import ApprovalDetailModal from "./ApprovalDetailModal";
interface MCPApprovalTableProps {
requests: MCPApprovalRequest[];
onApprove: (approvalId: string) => void;
onReject: (approvalId: string, reason: string) => void;
readOnly?: boolean;
}
export default function MCPApprovalTable({
requests,
onApprove,
onReject,
readOnly = false
}: MCPApprovalTableProps) {
const [selectedRequest, setSelectedRequest] = useState<MCPApprovalRequest | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const [showRejectModal, setShowRejectModal] = useState(false);
const [rejectReason, setRejectReason] = useState("");
const [rejectingId, setRejectingId] = useState<string | null>(null);
const handleViewDetails = (record: MCPApprovalRequest) => {
setSelectedRequest(record);
setShowDetailModal(true);
};
const handleApproveClick = (approvalId: string) => {
onApprove(approvalId);
};
const handleRejectClick = (approvalId: string) => {
setRejectingId(approvalId);
setShowRejectModal(true);
};
const handleRejectConfirm = () => {
if (rejectingId && rejectReason.trim()) {
onReject(rejectingId, rejectReason);
setShowRejectModal(false);
setRejectReason("");
setRejectingId(null);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "pending":
return "gold";
case "approved":
return "green";
case "rejected":
return "red";
default:
return "default";
}
};
const getStatusText = (status: string) => {
switch (status) {
case "pending":
return "Pending";
case "approved":
return "Approved";
case "rejected":
return "Rejected";
default:
return status;
}
};
const columns: ColumnsType<MCPApprovalRequest> = [
{
title: "Status",
dataIndex: "status",
key: "status",
width: 120,
render: (status: string) => (
<Tag color={getStatusColor(status)}>{getStatusText(status)}</Tag>
)
},
{
title: "Server Name",
dataIndex: "server_name",
key: "server_name",
width: 200,
render: (text: string, record: MCPApprovalRequest) => (
<div>
<div style={{ fontWeight: 500 }}>{text}</div>
{record.alias && (
<div style={{ fontSize: 12, color: "#888" }}>Alias: {record.alias}</div>
)}
</div>
)
},
{
title: "Description",
dataIndex: "description",
key: "description",
ellipsis: true,
render: (text: string) => (
<Tooltip title={text}>
<span>{text || "-"}</span>
</Tooltip>
)
},
{
title: "Requested By",
key: "requester",
width: 200,
render: (record: MCPApprovalRequest) => (
<div>
<div>{record.requester_email}</div>
{record.team_name && (
<Badge
count={record.team_name}
style={{ backgroundColor: "#1890ff", marginTop: 4 }}
/>
)}
</div>
)
},
{
title: "Requested Date",
dataIndex: "created_at",
key: "created_at",
width: 150,
render: (date: string) => new Date(date).toLocaleDateString()
},
{
title: "Access Groups",
dataIndex: "mcp_access_groups",
key: "mcp_access_groups",
width: 150,
render: (groups: string[]) => (
<Tooltip title={groups.join(", ")}>
<Badge count={groups.length} style={{ backgroundColor: "#52c41a" }} />
</Tooltip>
)
},
{
title: "Actions",
key: "actions",
width: 200,
fixed: "right",
render: (record: MCPApprovalRequest) => (
<Space size="small">
<Button
size="small"
icon={<EyeOutlined />}
onClick={() => handleViewDetails(record)}
>
View
</Button>
{!readOnly && record.status === "pending" && (
<>
<Button
size="small"
type="primary"
icon={<CheckOutlined />}
onClick={() => handleApproveClick(record.approval_id)}
>
Approve
</Button>
<Button
size="small"
danger
icon={<CloseOutlined />}
onClick={() => handleRejectClick(record.approval_id)}
>
Reject
</Button>
</>
)}
</Space>
)
}
];
return (
<>
<Table
columns={columns}
dataSource={requests}
rowKey="approval_id"
pagination={{ pageSize: 10 }}
scroll={{ x: 1200 }}
/>
{selectedRequest && (
<ApprovalDetailModal
request={selectedRequest}
visible={showDetailModal}
onClose={() => setShowDetailModal(false)}
onApprove={!readOnly ? handleApproveClick : undefined}
onReject={!readOnly ? handleRejectClick : undefined}
/>
)}
<Modal
title="Reject MCP Server Request"
open={showRejectModal}
onOk={handleRejectConfirm}
onCancel={() => {
setShowRejectModal(false);
setRejectReason("");
}}
okText="Confirm Rejection"
okButtonProps={{ danger: true, disabled: !rejectReason.trim() }}
>
<div style={{ marginBottom: 8, fontWeight: 500 }}>
Rejection Reason <span style={{ color: "#ff4d4f" }}>*</span>
</div>
<Input.TextArea
rows={4}
placeholder="Explain why this request is being rejected..."
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
required
/>
<div style={{ marginTop: 8, fontSize: 12, color: "#888" }}>
The requester will be notified with this reason.
</div>
</Modal>
</>
);
}

View file

@ -0,0 +1,252 @@
// Mock data for MCP approval requests
// This file contains sample data for demonstrating the approval workflow
export interface MCPApprovalRequest {
approval_id: string;
server_name: string;
alias?: string;
description?: string;
url: string;
transport: string;
auth_type?: string;
mcp_access_groups: string[];
allowed_tools: string[];
status: 'pending' | 'approved' | 'rejected';
requested_by: string;
requested_by_team_id?: string;
team_name?: string;
requester_email: string;
created_at: string;
reviewed_by?: string;
reviewed_at?: string;
rejection_reason?: string;
request_metadata?: {
business_justification?: string;
expected_usage?: string;
};
}
export const mockMCPApprovalRequests: MCPApprovalRequest[] = [
{
approval_id: "req-001",
server_name: "team-engineering-mcp",
alias: "eng-mcp",
description: "Engineering team's MCP server for code analysis and automated review",
url: "https://mcp.engineering.example.com",
transport: "sse",
auth_type: "bearer",
mcp_access_groups: ["engineering", "backend-team"],
allowed_tools: ["code-review", "lint-check", "test-runner", "coverage-analysis"],
status: "pending",
requested_by: "user-123",
requested_by_team_id: "team-eng",
team_name: "Engineering Team",
requester_email: "john.smith@example.com",
created_at: "2026-02-15T10:30:00Z",
request_metadata: {
business_justification: "Need automated code review for PR workflows and continuous integration. Will help reduce manual code review time by ~40%.",
expected_usage: "~500 requests per day across 50 engineers"
}
},
{
approval_id: "req-002",
server_name: "data-science-ml-mcp",
alias: "ds-ml",
description: "Data Science team's MCP server for ML model training and evaluation",
url: "https://mcp.datascience.example.com",
transport: "sse",
auth_type: "oauth2",
mcp_access_groups: ["data-science", "ml-team"],
allowed_tools: ["train-model", "evaluate-model", "feature-engineering", "data-viz"],
status: "pending",
requested_by: "user-456",
requested_by_team_id: "team-ds",
team_name: "Data Science Team",
requester_email: "sarah.jones@example.com",
created_at: "2026-02-16T14:20:00Z",
request_metadata: {
business_justification: "Required for automated ML pipeline. Will enable faster model iteration and A/B testing.",
expected_usage: "~200 training jobs per week, ~1000 evaluation requests per day"
}
},
{
approval_id: "req-003",
server_name: "security-audit-mcp",
alias: "sec-audit",
description: "Security team's MCP server for vulnerability scanning and compliance checks",
url: "https://mcp.security.example.com",
transport: "sse",
auth_type: "bearer",
mcp_access_groups: ["security", "compliance-team"],
allowed_tools: ["vulnerability-scan", "dependency-check", "secrets-detection", "compliance-audit"],
status: "pending",
requested_by: "user-789",
requested_by_team_id: "team-sec",
team_name: "Security Team",
requester_email: "mike.chen@example.com",
created_at: "2026-02-17T09:15:00Z",
request_metadata: {
business_justification: "Critical for SOC 2 compliance. Automated security scanning for all production deployments.",
expected_usage: "~100 scans per day, triggered on every deployment"
}
},
{
approval_id: "req-004",
server_name: "devops-infrastructure-mcp",
alias: "devops-infra",
description: "DevOps team's MCP server for infrastructure provisioning and monitoring",
url: "https://mcp.devops.example.com",
transport: "stdio",
auth_type: "api_key",
mcp_access_groups: ["devops", "sre-team"],
allowed_tools: ["provision-server", "deploy-service", "health-check", "log-analysis"],
status: "approved",
requested_by: "user-111",
requested_by_team_id: "team-devops",
team_name: "DevOps Team",
requester_email: "alex.rodriguez@example.com",
created_at: "2026-02-10T11:00:00Z",
reviewed_by: "admin-001",
reviewed_at: "2026-02-11T15:30:00Z",
request_metadata: {
business_justification: "Essential for automated infrastructure management. Reduces manual toil and improves reliability.",
expected_usage: "~300 infrastructure operations per day"
}
},
{
approval_id: "req-005",
server_name: "product-analytics-mcp",
alias: "prod-analytics",
description: "Product team's MCP server for user analytics and metrics tracking",
url: "https://mcp.product.example.com",
transport: "sse",
auth_type: "bearer",
mcp_access_groups: ["product", "analytics-team"],
allowed_tools: ["track-event", "query-metrics", "generate-report", "create-dashboard"],
status: "approved",
requested_by: "user-222",
requested_by_team_id: "team-product",
team_name: "Product Team",
requester_email: "lisa.thompson@example.com",
created_at: "2026-02-12T13:45:00Z",
reviewed_by: "admin-002",
reviewed_at: "2026-02-13T10:00:00Z",
request_metadata: {
business_justification: "Required for product analytics and user behavior tracking. Enables data-driven product decisions.",
expected_usage: "~1000 events per minute, ~50 report generations per day"
}
},
{
approval_id: "req-006",
server_name: "marketing-automation-mcp",
alias: "marketing-auto",
description: "Marketing team's MCP server for campaign automation and email marketing",
url: "https://mcp.marketing.example.com",
transport: "sse",
auth_type: "oauth2",
mcp_access_groups: ["marketing"],
allowed_tools: ["send-campaign", "segment-users", "track-conversion", "ab-test"],
status: "rejected",
requested_by: "user-333",
requested_by_team_id: "team-marketing",
team_name: "Marketing Team",
requester_email: "david.kim@example.com",
created_at: "2026-02-14T16:00:00Z",
reviewed_by: "admin-001",
reviewed_at: "2026-02-15T09:20:00Z",
rejection_reason: "Security concerns: OAuth2 configuration incomplete. Missing required scopes and callback URLs. Please resubmit with proper authentication setup.",
request_metadata: {
business_justification: "Need automated marketing campaigns for user engagement and retention.",
expected_usage: "~10 campaigns per week, ~100K emails per month"
}
},
{
approval_id: "req-007",
server_name: "customer-support-mcp",
alias: "support",
description: "Customer Support team's MCP server for ticket management and chatbot",
url: "https://mcp.support.example.com",
transport: "sse",
auth_type: "bearer",
mcp_access_groups: ["support", "customer-success"],
allowed_tools: ["create-ticket", "update-ticket", "search-knowledge-base", "chatbot-response"],
status: "pending",
requested_by: "user-444",
requested_by_team_id: "team-support",
team_name: "Customer Support Team",
requester_email: "emma.wilson@example.com",
created_at: "2026-02-18T08:00:00Z",
request_metadata: {
business_justification: "Enhance customer support response times. Automated ticket routing and AI-powered responses.",
expected_usage: "~500 tickets per day, ~2000 chatbot interactions per day"
}
},
{
approval_id: "req-008",
server_name: "finance-reporting-mcp",
alias: "fin-reports",
description: "Finance team's MCP server for financial reporting and analysis",
url: "https://mcp.finance.example.com",
transport: "stdio",
auth_type: "mtls",
mcp_access_groups: ["finance", "accounting"],
allowed_tools: ["generate-financial-report", "calculate-metrics", "budget-analysis", "forecast"],
status: "rejected",
requested_by: "user-555",
requested_by_team_id: "team-finance",
team_name: "Finance Team",
requester_email: "robert.anderson@example.com",
created_at: "2026-02-13T10:30:00Z",
reviewed_by: "admin-002",
reviewed_at: "2026-02-14T11:45:00Z",
rejection_reason: "Insufficient access controls. Finance data requires additional encryption and audit logging. Please add detailed access policies and data retention policies.",
request_metadata: {
business_justification: "Automate monthly financial reports and forecasting. Reduce manual work by 60%.",
expected_usage: "~50 reports per month, daily metric calculations"
}
},
{
approval_id: "req-009",
server_name: "hr-recruitment-mcp",
alias: "hr-recruit",
description: "HR team's MCP server for recruitment automation and candidate screening",
url: "https://mcp.hr.example.com",
transport: "sse",
auth_type: "bearer",
mcp_access_groups: ["hr", "recruitment-team"],
allowed_tools: ["parse-resume", "screen-candidate", "schedule-interview", "generate-offer"],
status: "pending",
requested_by: "user-666",
requested_by_team_id: "team-hr",
team_name: "HR Team",
requester_email: "jessica.brown@example.com",
created_at: "2026-02-17T15:30:00Z",
request_metadata: {
business_justification: "Streamline recruitment process. Automated resume screening and interview scheduling to reduce time-to-hire.",
expected_usage: "~200 applications per week, ~50 interviews per month"
}
},
{
approval_id: "req-010",
server_name: "legal-contract-mcp",
alias: "legal-docs",
description: "Legal team's MCP server for contract management and document review",
url: "https://mcp.legal.example.com",
transport: "sse",
auth_type: "mtls",
mcp_access_groups: ["legal"],
allowed_tools: ["review-contract", "extract-terms", "compliance-check", "redline-document"],
status: "approved",
requested_by: "user-777",
requested_by_team_id": "team-legal",
team_name: "Legal Team",
requester_email: "william.martinez@example.com",
created_at: "2026-02-11T09:00:00Z",
reviewed_by: "admin-001",
reviewed_at: "2026-02-12T14:15:00Z",
request_metadata: {
business_justification: "Automated contract review and compliance checking. Speeds up legal review process by 50%.",
expected_usage: "~30 contracts per week, ongoing compliance monitoring"
}
}
];

View file

@ -27,7 +27,7 @@ import {
UserOutlined,
} from "@ant-design/icons";
import type { MenuProps } from "antd";
import { ConfigProvider, Layout, Menu } from "antd";
import { Badge, ConfigProvider, Layout, Menu } from "antd";
import { useMemo } from "react";
import { all_admin_roles, internalUserRoles, isAdminRole, rolesWithWriteAccess } from "../utils/roles";
import NewBadge from "./common_components/NewBadge";
@ -117,6 +117,18 @@ const menuGroups: MenuGroup[] = [
icon: <AuditOutlined />,
roles: all_admin_roles,
},
{
key: "approvals",
page: "approvals",
label: (
<span className="flex items-center gap-4">
Approvals
<Badge count={5} style={{ backgroundColor: "#faad14" }} />
</span>
),
icon: <AuditOutlined />,
roles: all_admin_roles,
},
{
key: "tools",
page: "tools",