feat(ui): i18n for mcp_tools batch 2

This commit is contained in:
吹雪 2026-06-10 18:55:20 +09:00
parent c3617bc3d2
commit dff009460e
5 changed files with 366 additions and 117 deletions

View file

@ -28,29 +28,32 @@ import {
} from "./MCPStandardsSettings";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { useTranslation } from "react-i18next";
import { TFunction } from "i18next";
type MCPStatus = "active" | "pending_review" | "rejected";
const STATUS_CONFIG: Record<MCPStatus, { label: string; bg: string; text: string; dot: string }> = {
const getStatusConfig = (
t: TFunction,
): Record<MCPStatus, { label: string; bg: string; text: string; dot: string }> => ({
active: {
label: "Active",
label: t("mcpTools.mCPSubmissionsTab.statusActive"),
bg: "bg-green-50",
text: "text-green-700",
dot: "bg-green-500",
},
pending_review: {
label: "Pending Review",
label: t("mcpTools.mCPSubmissionsTab.statusPendingReview"),
bg: "bg-yellow-50",
text: "text-yellow-700",
dot: "bg-yellow-500",
},
rejected: {
label: "Rejected",
label: t("mcpTools.mCPSubmissionsTab.statusRejected"),
bg: "bg-red-50",
text: "text-red-700",
dot: "bg-red-500",
},
};
});
function formatDate(value: string | null | undefined): string {
if (!value) return "—";
@ -80,11 +83,12 @@ type ConfirmDialogProps = {
};
function ConfirmDialog({ action, serverName, isCurrentlyActive, onConfirm, onCancel }: ConfirmDialogProps) {
const { t } = useTranslation();
const [reviewNotes, setReviewNotes] = useState("");
const isApprove = action === "approve";
const rejectBody = isCurrentlyActive
? "This server is currently live. Rejecting it will immediately remove it from the proxy runtime."
: "This will mark the submission as rejected.";
? t("mcpTools.mCPSubmissionsTab.rejectBodyActive")
: t("mcpTools.mCPSubmissionsTab.rejectBodyInactive");
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl p-6 max-w-sm w-full mx-4">
@ -100,15 +104,18 @@ function ConfirmDialog({ action, serverName, isCurrentlyActive, onConfirm, onCan
)}
</div>
<h3 className="text-base font-semibold text-gray-900 mb-1">
{isApprove ? "Approve MCP Server" : "Reject MCP Server"}
{isApprove
? t("mcpTools.mCPSubmissionsTab.confirmApproveMcpServer")
: t("mcpTools.mCPSubmissionsTab.confirmRejectMcpServer")}
</h3>
<p className="text-sm text-gray-500 mb-4">
Are you sure you want to {action} <span className="font-medium text-gray-700">&quot;{serverName}&quot;</span>?{" "}
{isApprove ? "This will make it active and available for use." : rejectBody}
{t("mcpTools.mCPSubmissionsTab.confirmAreYouSure", { action })}{" "}
<span className="font-medium text-gray-700">&quot;{serverName}&quot;</span>?{" "}
{isApprove ? t("mcpTools.mCPSubmissionsTab.confirmApproveBody") : rejectBody}
</p>
{!isApprove && (
<textarea
placeholder="Reason for rejection (optional)"
placeholder={t("mcpTools.mCPSubmissionsTab.rejectionReasonPlaceholder")}
value={reviewNotes}
onChange={(e) => setReviewNotes(e.target.value)}
className="w-full border border-gray-200 rounded-md px-3 py-2 text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500 mb-4 resize-none"
@ -121,7 +128,7 @@ function ConfirmDialog({ action, serverName, isCurrentlyActive, onConfirm, onCan
onClick={onCancel}
className="flex-1 border border-gray-300 text-gray-700 hover:bg-gray-50 text-sm font-medium py-2 rounded-md transition-colors"
>
Cancel
{t("common.cancel")}
</button>
<button
type="button"
@ -130,7 +137,7 @@ function ConfirmDialog({ action, serverName, isCurrentlyActive, onConfirm, onCan
isApprove ? "bg-green-500 hover:bg-green-600" : "bg-red-500 hover:bg-red-600"
}`}
>
{isApprove ? "Approve" : "Reject"}
{isApprove ? t("mcpTools.mCPSubmissionsTab.approveButton") : t("mcpTools.mCPSubmissionsTab.rejectButton")}
</button>
</div>
</div>
@ -165,13 +172,15 @@ function SubmissionRulesPanel({ requiredFields, onChange, onSave, isSaving }: Su
>
<div className="flex items-center gap-2">
<SettingsIcon className="h-4 w-4 text-gray-400" />
<span className="text-sm font-semibold text-gray-800">Submission Rules</span>
<span className="text-sm font-semibold text-gray-800">
{t("mcpTools.mCPSubmissionsTab.submissionRulesTitle")}
</span>
{activeLabels.length > 0 ? (
<span className="text-xs text-gray-500">
({activeLabels.length} required field{activeLabels.length !== 1 ? "s" : ""})
({t("mcpTools.mCPSubmissionsTab.requiredFieldCount", { count: activeLabels.length })})
</span>
) : (
<span className="text-xs text-gray-400 italic">no rules set</span>
<span className="text-xs text-gray-400 italic">{t("mcpTools.mCPSubmissionsTab.noRulesSet")}</span>
)}
</div>
<div className="flex items-center gap-3">
@ -200,10 +209,7 @@ function SubmissionRulesPanel({ requiredFields, onChange, onSave, isSaving }: Su
{/* Expanded editor */}
{expanded && (
<div className="border-t border-gray-100 px-4 pt-4 pb-4">
<p className="text-xs text-gray-500 mb-4">
Select which fields must be filled in before a submission is considered compliant. LiteLLM will show ✓ / ✗
for each rule on every submission card below.
</p>
<p className="text-xs text-gray-500 mb-4">{t("mcpTools.mCPSubmissionsTab.submissionRulesDesc")}</p>
<div className="grid grid-cols-2 gap-x-8 gap-y-5">
{fieldGroups.map((group: FieldGroup) => (
<div key={group.label}>
@ -242,14 +248,14 @@ function SubmissionRulesPanel({ requiredFields, onChange, onSave, isSaving }: Su
}}
className="px-4 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-md transition-colors"
>
{isSaving ? "Saving…" : "Save Rules"}
{isSaving ? t("common.saving") : t("mcpTools.mCPSubmissionsTab.saveRulesButton")}
</button>
<button
type="button"
onClick={() => setExpanded(false)}
className="px-4 py-1.5 text-sm font-medium text-gray-600 hover:text-gray-900 border border-gray-200 rounded-md hover:bg-gray-50 transition-colors"
>
Cancel
{t("common.cancel")}
</button>
</div>
</div>
@ -267,8 +273,9 @@ type MCPServerCardProps = {
function MCPServerCard({ server, onApprove, onReject, requiredFields }: MCPServerCardProps) {
const { t } = useTranslation();
const statusConfig = getStatusConfig(t);
const approvalStatus = (server.approval_status ?? "active") as MCPStatus;
const statusCfg = STATUS_CONFIG[approvalStatus] ?? STATUS_CONFIG["active"];
const statusCfg = statusConfig[approvalStatus] ?? statusConfig["active"];
const checks = getMcpRequiredFieldDefs(t)
.filter((f: RequiredFieldDef) => requiredFields.includes(f.key))
@ -308,17 +315,21 @@ function MCPServerCard({ server, onApprove, onReject, requiredFields }: MCPServe
)}
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-400">
<span>
Transport: <span className="text-gray-600">{server.transport ?? "sse"}</span>
{t("mcpTools.mCPSubmissionsTab.transportLabel")}{" "}
<span className="text-gray-600">{server.transport ?? "sse"}</span>
</span>
<span>·</span>
<span>
Submitted by: <span className="text-gray-600">{server.submitted_by ?? "—"}</span>
{t("mcpTools.mCPSubmissionsTab.submittedByLabel")}{" "}
<span className="text-gray-600">{server.submitted_by ?? "—"}</span>
</span>
<span>·</span>
<span>{formatDate(server.submitted_at)}</span>
</div>
{approvalStatus === "rejected" && server.review_notes && (
<p className="text-xs text-red-600 mt-1.5">Rejection reason: {server.review_notes}</p>
<p className="text-xs text-red-600 mt-1.5">
{t("mcpTools.mCPSubmissionsTab.rejectionReasonLabel", { reason: server.review_notes })}
</p>
)}
</div>
{/* Approve/Reject when no checks panel (no rules configured) */}
@ -330,7 +341,7 @@ function MCPServerCard({ server, onApprove, onReject, requiredFields }: MCPServe
onClick={onApprove}
className="text-xs bg-green-500 hover:bg-green-600 text-white px-3 py-1.5 rounded-md transition-colors font-medium"
>
Approve
{t("mcpTools.mCPSubmissionsTab.approveButton")}
</button>
)}
<button
@ -338,7 +349,7 @@ function MCPServerCard({ server, onApprove, onReject, requiredFields }: MCPServe
onClick={onReject}
className="text-xs border border-red-300 text-red-600 hover:bg-red-50 px-3 py-1.5 rounded-md transition-colors font-medium"
>
Reject
{t("mcpTools.mCPSubmissionsTab.rejectButton")}
</button>
</div>
)}
@ -349,7 +360,7 @@ function MCPServerCard({ server, onApprove, onReject, requiredFields }: MCPServe
onClick={onApprove}
className="text-xs bg-green-500 hover:bg-green-600 text-white px-3 py-1.5 rounded-md transition-colors font-medium"
>
Re-approve
{t("mcpTools.mCPSubmissionsTab.reapproveButton")}
</button>
</div>
)}
@ -375,10 +386,12 @@ function MCPServerCard({ server, onApprove, onReject, requiredFields }: MCPServe
</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold leading-tight ${allPassed ? "text-green-800" : "text-red-800"}`}>
{allPassed ? "All checks passed" : `${failCount} check${failCount !== 1 ? "s" : ""} failed`}
{allPassed
? t("mcpTools.mCPSubmissionsTab.allChecksPassed")
: t("mcpTools.mCPSubmissionsTab.checksFailed", { count: failCount })}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{passCount} passing, {failCount} failing
{t("mcpTools.mCPSubmissionsTab.checksPassFail", { passCount, failCount })}
</div>
</div>
{/* Approve / Reject in header */}
@ -389,7 +402,7 @@ function MCPServerCard({ server, onApprove, onReject, requiredFields }: MCPServe
onClick={onApprove}
className="text-xs bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md transition-colors font-medium"
>
Approve
{t("mcpTools.mCPSubmissionsTab.approveButton")}
</button>
)}
{approvalStatus === "rejected" && (
@ -398,7 +411,7 @@ function MCPServerCard({ server, onApprove, onReject, requiredFields }: MCPServe
onClick={onApprove}
className="text-xs bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md transition-colors font-medium"
>
Re-approve
{t("mcpTools.mCPSubmissionsTab.reapproveButton")}
</button>
)}
{approvalStatus !== "rejected" && (
@ -407,7 +420,7 @@ function MCPServerCard({ server, onApprove, onReject, requiredFields }: MCPServe
onClick={onReject}
className="text-xs border border-red-300 text-red-600 hover:bg-red-50 bg-white px-3 py-1.5 rounded-md transition-colors font-medium"
>
Reject
{t("mcpTools.mCPSubmissionsTab.rejectButton")}
</button>
)}
</div>
@ -431,7 +444,9 @@ function MCPServerCard({ server, onApprove, onReject, requiredFields }: MCPServe
</div>
<span className={`text-sm flex-1 ${c.passed ? "text-gray-700" : "text-gray-800"}`}>{c.label}</span>
<span className={`text-xs ${c.passed ? "text-green-600" : "text-red-500"}`}>
{c.passed ? "Passes" : "Missing"}
{c.passed
? t("mcpTools.mCPSubmissionsTab.checkPasses")
: t("mcpTools.mCPSubmissionsTab.checkMissing")}
</span>
</div>
))}
@ -447,6 +462,7 @@ interface MCPSubmissionsTabProps {
}
export function MCPSubmissionsTab({ accessToken }: MCPSubmissionsTabProps) {
const { t } = useTranslation();
const [summary, setSummary] = useState<MCPSubmissionsSummary>({
total: 0,
pending_review: 0,
@ -492,7 +508,7 @@ export function MCPSubmissionsTab({ accessToken }: MCPSubmissionsTabProps) {
}
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load submissions");
setError(err instanceof Error ? err.message : t("mcpTools.mCPSubmissionsTab.failedToLoadSubmissions"));
} finally {
setIsLoading(false);
}
@ -507,9 +523,9 @@ export function MCPSubmissionsTab({ accessToken }: MCPSubmissionsTabProps) {
setIsSavingRules(true);
try {
await updateConfigFieldSetting(accessToken, SETTINGS_KEY, requiredFields);
NotificationsManager.success("Submission rules saved");
NotificationsManager.success(t("mcpTools.mCPSubmissionsTab.submissionRulesSaved"));
} catch {
NotificationsManager.fromBackend("Failed to save submission rules");
NotificationsManager.fromBackend(t("mcpTools.mCPSubmissionsTab.failedToSaveRules"));
} finally {
setIsSavingRules(false);
}
@ -531,9 +547,9 @@ export function MCPSubmissionsTab({ accessToken }: MCPSubmissionsTabProps) {
try {
await approveMCPServer(accessToken, serverId);
await fetchData();
NotificationsManager.success(`MCP server "${serverName}" approved`);
NotificationsManager.success(t("mcpTools.mCPSubmissionsTab.approvedSuccess", { name: serverName }));
} catch {
NotificationsManager.fromBackend("Failed to approve MCP server");
NotificationsManager.fromBackend(t("mcpTools.mCPSubmissionsTab.failedToApprove"));
} finally {
setConfirmAction(null);
}
@ -544,9 +560,9 @@ export function MCPSubmissionsTab({ accessToken }: MCPSubmissionsTabProps) {
try {
await rejectMCPServer(accessToken, serverId, reviewNotes);
await fetchData();
NotificationsManager.success(`MCP server "${serverName}" rejected`);
NotificationsManager.success(t("mcpTools.mCPSubmissionsTab.rejectedSuccess", { name: serverName }));
} catch {
NotificationsManager.fromBackend("Failed to reject MCP server");
NotificationsManager.fromBackend(t("mcpTools.mCPSubmissionsTab.failedToReject"));
} finally {
setConfirmAction(null);
}
@ -563,10 +579,18 @@ export function MCPSubmissionsTab({ accessToken }: MCPSubmissionsTabProps) {
/>
<div className="grid grid-cols-4 gap-4 mb-6">
<StatCard label="Total Submitted" value={summary.total} color="text-gray-900" />
<StatCard label="Pending Review" value={summary.pending_review} color="text-yellow-600" />
<StatCard label="Active" value={summary.active} color="text-green-600" />
<StatCard label="Rejected" value={summary.rejected} color="text-red-600" />
<StatCard
label={t("mcpTools.mCPSubmissionsTab.statTotalSubmitted")}
value={summary.total}
color="text-gray-900"
/>
<StatCard
label={t("mcpTools.mCPSubmissionsTab.statPendingReview")}
value={summary.pending_review}
color="text-yellow-600"
/>
<StatCard label={t("mcpTools.mCPSubmissionsTab.statActive")} value={summary.active} color="text-green-600" />
<StatCard label={t("mcpTools.mCPSubmissionsTab.statRejected")} value={summary.rejected} color="text-red-600" />
</div>
<div className="flex items-center gap-3 mb-5">
@ -574,7 +598,7 @@ export function MCPSubmissionsTab({ accessToken }: MCPSubmissionsTabProps) {
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Search MCP servers..."
placeholder={t("common.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 border border-gray-200 rounded-md text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
@ -585,18 +609,24 @@ export function MCPSubmissionsTab({ accessToken }: MCPSubmissionsTabProps) {
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
className="border border-gray-200 rounded-md px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
<option value="all">All Status</option>
<option value="pending_review">Pending Review</option>
<option value="active">Active</option>
<option value="rejected">Rejected</option>
<option value="all">{t("mcpTools.mCPSubmissionsTab.filterAllStatus")}</option>
<option value="pending_review">{t("mcpTools.mCPSubmissionsTab.statusPendingReview")}</option>
<option value="active">{t("mcpTools.mCPSubmissionsTab.statusActive")}</option>
<option value="rejected">{t("mcpTools.mCPSubmissionsTab.statusRejected")}</option>
</select>
</div>
<div className="space-y-3">
{isLoading && <div className="text-center py-12 text-gray-500 text-sm">Loading submissions…</div>}
{isLoading && (
<div className="text-center py-12 text-gray-500 text-sm">
{t("mcpTools.mCPSubmissionsTab.loadingSubmissions")}
</div>
)}
{error && <div className="text-center py-12 text-red-600 text-sm">{error}</div>}
{!isLoading && !error && filtered.length === 0 && (
<div className="text-center py-12 text-gray-400 text-sm">No MCP server submissions match your filters.</div>
<div className="text-center py-12 text-gray-400 text-sm">
{t("mcpTools.mCPSubmissionsTab.noSubmissionsMatch")}
</div>
)}
{!isLoading &&
!error &&

View file

@ -2,6 +2,7 @@ import React, { forwardRef, useImperativeHandle, useMemo } from "react";
import { Form, Input, InputNumber, Select, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { MCPTool, InputSchema, InputSchemaProperty } from "./types";
import { useTranslation } from "react-i18next";
const isPlainObject = (value: unknown): value is Record<string, any> =>
typeof value === "object" && value !== null && !Array.isArray(value);
@ -140,6 +141,7 @@ interface MCPToolArgumentsFormProps {
const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgumentsFormProps>(
({ tool, className }, ref) => {
const { t } = useTranslation();
const [form] = Form.useForm();
const schema: InputSchema = useMemo(() => {
@ -149,14 +151,14 @@ const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgument
properties: {
input: {
type: "string",
description: "Input for this tool",
description: t("mcpTools.mCPToolArgumentsForm.inputDescription"),
},
},
required: ["input"],
};
}
return tool.inputSchema as InputSchema;
}, [tool.inputSchema]);
}, [tool.inputSchema, t]);
const actualSchema: InputSchema = useMemo(() => {
if (schema.properties?.params?.type === "object" && schema.properties.params.properties) {
@ -193,13 +195,13 @@ const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgument
<Form.Item
label={
<span className="text-sm font-medium text-gray-700">
Input <span className="text-red-500">*</span>
{t("mcpTools.mCPToolArgumentsForm.inputLabel")} <span className="text-red-500">*</span>
</span>
}
name="input"
rules={[{ required: true, message: "Please enter input for this tool" }]}
rules={[{ required: true, message: t("mcpTools.mCPToolArgumentsForm.inputRequired") }]}
>
<Input placeholder="Enter input for this tool" />
<Input placeholder={t("mcpTools.mCPToolArgumentsForm.inputPlaceholder")} />
</Form.Item>
</Form>
);
@ -208,7 +210,9 @@ const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgument
if (!actualSchema.properties) {
return (
<Form form={form} layout="vertical" className={className}>
<div className="py-4 text-center text-sm text-gray-500">No parameters required for this tool.</div>
<div className="py-4 text-center text-sm text-gray-500">
{t("mcpTools.mCPToolArgumentsForm.noParameters")}
</div>
</Form>
);
}
@ -236,7 +240,7 @@ const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgument
rules={[
{
required: actualSchema.required?.includes(key),
message: `Please enter ${key}`,
message: t("mcpTools.mCPToolArgumentsForm.fieldRequired", { key }),
},
...(prop.type === "object" || prop.type === "array"
? [
@ -261,11 +265,13 @@ const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgument
}
return Promise.reject(
new Error(
prop.type === "object" ? "Please enter a JSON object" : "Please enter a JSON array",
prop.type === "object"
? t("mcpTools.mCPToolArgumentsForm.invalidJsonObject")
: t("mcpTools.mCPToolArgumentsForm.invalidJsonArray"),
),
);
} catch {
return Promise.reject(new Error("Invalid JSON"));
return Promise.reject(new Error(t("mcpTools.mCPToolArgumentsForm.invalidJson")));
}
},
},
@ -275,26 +281,29 @@ const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgument
>
{prop.type === "string" && prop.enum ? (
<Select
placeholder={`Select ${key}`}
placeholder={t("mcpTools.mCPToolArgumentsForm.selectPlaceholder", { key })}
allowClear={!actualSchema.required?.includes(key)}
options={prop.enum.map((v) => ({ value: v, label: v }))}
/>
) : prop.type === "string" && !prop.enum ? (
<Input placeholder={prop.description || `Enter ${key}`} allowClear />
<Input
placeholder={prop.description || t("mcpTools.mCPToolArgumentsForm.enterPlaceholder", { key })}
allowClear
/>
) : prop.type === "number" || prop.type === "integer" ? (
<InputNumber
step={prop.type === "integer" ? 1 : undefined}
placeholder={prop.description || `Enter ${key}`}
placeholder={prop.description || t("mcpTools.mCPToolArgumentsForm.enterPlaceholder", { key })}
className="w-full"
style={{ width: "100%" }}
/>
) : prop.type === "boolean" ? (
<Select
placeholder={`Select ${key}`}
placeholder={t("mcpTools.mCPToolArgumentsForm.selectPlaceholder", { key })}
allowClear={!actualSchema.required?.includes(key)}
options={[
{ value: true, label: "True" },
{ value: false, label: "False" },
{ value: true, label: t("mcpTools.mCPToolArgumentsForm.booleanTrue") },
{ value: false, label: t("mcpTools.mCPToolArgumentsForm.booleanFalse") },
]}
/>
) : prop.type === "object" || prop.type === "array" ? (
@ -302,13 +311,18 @@ const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgument
rows={prop.type === "object" ? 4 : 3}
placeholder={
prop.description ||
(prop.type === "object" ? `Enter JSON object for ${key}` : `Enter JSON array for ${key}`)
(prop.type === "object"
? t("mcpTools.mCPToolArgumentsForm.jsonObjectPlaceholder", { key })
: t("mcpTools.mCPToolArgumentsForm.jsonArrayPlaceholder", { key }))
}
spellCheck={false}
className="font-mono"
/>
) : (
<Input placeholder={prop.description || `Enter ${key}`} allowClear />
<Input
placeholder={prop.description || t("mcpTools.mCPToolArgumentsForm.enterPlaceholder", { key })}
allowClear
/>
)}
</Form.Item>
);

View file

@ -9,6 +9,8 @@ import { useQueryClient } from "@tanstack/react-query";
import { DataTable } from "../view_logs/table";
import { createMCPToolset, updateMCPToolset, deleteMCPToolset, listMCPTools, getProxyBaseUrl } from "../networking";
import { MCPToolset, MCPToolsetTool } from "./types";
import { useTranslation } from "react-i18next";
import { TFunction } from "i18next";
const { Text: AntdText } = Typography;
@ -36,6 +38,7 @@ interface ToolEntry {
}
function MCPToolList({ serverId, serverName, accessToken, selectedTools, onToggle }: MCPToolListProps) {
const { t } = useTranslation();
const [tools, setTools] = useState<ToolEntry[]>([]);
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState(false);
@ -72,7 +75,9 @@ function MCPToolList({ serverId, serverName, accessToken, selectedTools, onToggl
<span className="inline-block w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" />
{serverName}
{selectedSet.size > 0 && (
<span className="ml-1 text-xs text-purple-600 font-semibold">{selectedSet.size} selected</span>
<span className="ml-1 text-xs text-purple-600 font-semibold">
{t("mcpTools.mCPToolsetsTab.selectedCount", { count: selectedSet.size })}
</span>
)}
</span>
<span className="text-gray-400 text-xs">{expanded ? "▲" : "▼"}</span>
@ -84,7 +89,7 @@ function MCPToolList({ serverId, serverName, accessToken, selectedTools, onToggl
<Spin size="small" />
</div>
) : tools.length === 0 ? (
<p className="text-xs text-gray-400 px-2 py-2">No tools found for this server.</p>
<p className="text-xs text-gray-400 px-2 py-2">{t("mcpTools.mCPToolsetsTab.noToolsFound")}</p>
) : (
<div className="flex flex-col gap-1">
{tools.map((tool) => {
@ -133,6 +138,7 @@ interface CreateToolsetModalProps {
}
function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset }: CreateToolsetModalProps) {
const { t } = useTranslation();
const [form] = Form.useForm<ToolsetFormValues>();
const [selectedTools, setSelectedTools] = useState<MCPToolsetTool[]>(initialToolset?.tools || []);
const [saving, setSaving] = useState(false);
@ -179,7 +185,7 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset
<Modal
open={open}
onCancel={onClose}
title={initialToolset ? "Edit Toolset" : "New Toolset"}
title={initialToolset ? t("mcpTools.mCPToolsetsTab.editToolset") : t("mcpTools.mCPToolsetsTab.newToolset")}
width={960}
footer={null}
forceRender
@ -187,15 +193,15 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset
<Form form={form} layout="vertical" className="mt-2">
<div className="flex gap-4 mb-4">
<Form.Item
label="Toolset Name"
label={t("mcpTools.mCPToolsetsTab.toolsetNameLabel")}
name="toolset_name"
rules={[{ required: true, message: "Please enter a toolset name" }]}
rules={[{ required: true, message: t("mcpTools.mCPToolsetsTab.toolsetNameRequired") }]}
className="flex-1 mb-0"
>
<Input placeholder="e.g. github-linear-tools" />
</Form.Item>
<Form.Item label="Description" name="description" className="flex-1 mb-0">
<Input placeholder="Optional description" />
<Form.Item label={t("common.description")} name="description" className="flex-1 mb-0">
<Input placeholder={t("mcpTools.mCPToolsetsTab.descriptionPlaceholder")} />
</Form.Item>
</div>
</Form>
@ -204,10 +210,10 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset
{/* Left panel: Available Tools */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<Text className="text-sm font-semibold text-gray-700">Available Tools</Text>
<Text className="text-sm font-semibold text-gray-700">{t("mcpTools.mCPToolsetsTab.availableTools")}</Text>
</div>
<Input
placeholder="Search MCP servers..."
placeholder={t("common.searchPlaceholder")}
value={serverSearch}
onChange={(e) => setServerSearch(e.target.value)}
className="mb-2"
@ -216,7 +222,9 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset
<div className="space-y-2 overflow-y-auto" style={{ maxHeight: 300 }}>
{filteredServers.length === 0 ? (
<Text className="text-gray-400 text-sm">
{mcpServers.length === 0 ? "No MCP servers configured" : "No servers match your search"}
{mcpServers.length === 0
? t("mcpTools.mCPToolsetsTab.noMcpServersConfigured")
: t("mcpTools.mCPToolsetsTab.noServersMatchSearch")}
</Text>
) : (
filteredServers.map((server) => (
@ -239,11 +247,14 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset
{/* Right panel: Your Toolset */}
<div className="w-72 flex-shrink-0">
<Text className="text-sm font-semibold text-gray-700 mb-2 block">
Your Toolset <span className="text-xs font-normal text-gray-400">({selectedTools.length} tools)</span>
{t("mcpTools.mCPToolsetsTab.yourToolset")}{" "}
<span className="text-xs font-normal text-gray-400">
({t("mcpTools.mCPToolsetsTab.toolCount", { count: selectedTools.length })})
</span>
</Text>
<div className="space-y-1 overflow-y-auto" style={{ maxHeight: 340 }}>
{selectedTools.length === 0 ? (
<Text className="text-gray-400 text-sm">No tools added yet</Text>
<Text className="text-gray-400 text-sm">{t("mcpTools.mCPToolsetsTab.noToolsAdded")}</Text>
) : (
selectedTools.map((tool, idx) => (
<button
@ -268,10 +279,10 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-gray-200">
<Button variant="secondary" onClick={onClose}>
Cancel
{t("common.cancel")}
</Button>
<Button onClick={handleSubmit} loading={saving}>
{initialToolset ? "Save Changes" : "Create Toolset"}
{initialToolset ? t("mcpTools.mCPToolsetsTab.saveChanges") : t("mcpTools.mCPToolsetsTab.createToolset")}
</Button>
</div>
</Modal>
@ -279,14 +290,15 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset
}
function toolsetColumns(
t: TFunction,
isAdmin: boolean,
onEdit: (t: MCPToolset) => void,
onEdit: (toolset: MCPToolset) => void,
onDelete: (id: string) => void,
proxyBaseUrl: string,
): ColumnDef<MCPToolset>[] {
return [
{
header: "Toolset ID",
header: t("mcpTools.mCPToolsetsTab.colToolsetId"),
accessorKey: "toolset_id",
cell: ({ row }) => (
<span className="font-mono text-xs bg-gray-100 px-2 py-0.5 rounded text-gray-600">
@ -295,7 +307,7 @@ function toolsetColumns(
),
},
{
header: "Name",
header: t("common.name"),
accessorKey: "toolset_name",
cell: ({ row }) => {
const url = `${proxyBaseUrl}/toolset/${row.original.toolset_name}/mcp`;
@ -309,7 +321,7 @@ function toolsetColumns(
type="button"
className="text-xs text-gray-400 hover:text-purple-600 font-mono truncate max-w-xs text-left transition-colors"
onClick={() => navigator.clipboard.writeText(url)}
title="Click to copy endpoint URL"
title={t("mcpTools.mCPToolsetsTab.copyEndpointTitle")}
>
{url}
</button>
@ -318,32 +330,36 @@ function toolsetColumns(
},
},
{
header: "Description",
header: t("common.description"),
accessorKey: "description",
cell: ({ row }) => <span className="text-sm text-gray-500">{row.original.description || "—"}</span>,
},
{
header: "Tools",
header: t("mcpTools.mCPToolsetsTab.colTools"),
accessorKey: "tools",
cell: ({ row }) => {
const tools = row.original.tools;
return (
<div className="flex flex-wrap gap-1 max-w-xs">
{tools.slice(0, 4).map((t, i) => (
{tools.slice(0, 4).map((tool, i) => (
<span
key={i}
className="inline-flex items-center px-1.5 py-0.5 rounded bg-purple-50 border border-purple-200 text-purple-700 text-xs"
>
{t.tool_name}
{tool.tool_name}
</span>
))}
{tools.length > 4 && <span className="text-xs text-gray-400 self-center">+{tools.length - 4} more</span>}
{tools.length > 4 && (
<span className="text-xs text-gray-400 self-center">
{t("mcpTools.mCPToolsetsTab.moreTools", { count: tools.length - 4 })}
</span>
)}
</div>
);
},
},
{
header: "Created",
header: t("common.createdAt"),
accessorKey: "created_at",
cell: ({ row }) => (
<span className="text-xs text-gray-500">
@ -381,6 +397,7 @@ function toolsetColumns(
}
function ToolsetUsageGuide() {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const proxyBaseUrl = getProxyBaseUrl();
@ -405,13 +422,13 @@ function ToolsetUsageGuide() {
return (
<div className="mb-6 rounded-lg border border-gray-200 bg-gray-50 px-5 py-4">
<p className="text-sm font-medium text-gray-700 mb-1">How toolsets work</p>
<p className="text-sm font-medium text-gray-700 mb-1">{t("mcpTools.mCPToolsetsTab.howToolsetsWorkTitle")}</p>
<p className="text-sm text-gray-500 mb-3">
Create a toolset, assign it to a key via{" "}
<span className="font-medium text-gray-700">API Keys → Edit Key → MCP Servers</span>, then point your MCP client
at the toolset URL. The client only sees the tools you picked.
{t("mcpTools.mCPToolsetsTab.howToolsetsWorkDesc1")}{" "}
<span className="font-medium text-gray-700">{t("mcpTools.mCPToolsetsTab.howToolsetsWorkPath")}</span>
{t("mcpTools.mCPToolsetsTab.howToolsetsWorkDesc2")}
</p>
<div className="text-xs text-gray-400 mb-1">Claude Code / Cursor config</div>
<div className="text-xs text-gray-400 mb-1">{t("mcpTools.mCPToolsetsTab.configLabel")}</div>
<div className="relative">
<pre className="bg-white border border-gray-200 rounded px-4 py-3 text-xs font-mono text-gray-700 overflow-x-auto leading-relaxed pr-14">
{snippet}
@ -421,7 +438,7 @@ function ToolsetUsageGuide() {
onClick={copy}
className="absolute top-2 right-2 px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 text-gray-400 hover:text-gray-600 border-gray-200 transition-colors"
>
{copied ? "✓" : "copy"}
{copied ? "✓" : t("common.copy")}
</button>
</div>
</div>
@ -429,6 +446,7 @@ function ToolsetUsageGuide() {
}
export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { data: toolsets = [], isLoading } = useMCPToolsets();
const [createOpen, setCreateOpen] = useState(false);
@ -441,14 +459,14 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
const handleCreate = async (name: string, description: string | undefined, tools: MCPToolsetTool[]) => {
if (!accessToken) return;
await createMCPToolset(accessToken, { toolset_name: name, description, tools });
message.success("Toolset created");
message.success(t("mcpTools.mCPToolsetsTab.toolsetCreated"));
queryClient.invalidateQueries({ queryKey: ["mcpToolsets"] });
};
const handleUpdate = async (name: string, description: string | undefined, tools: MCPToolsetTool[]) => {
if (!accessToken || !editToolset) return;
await updateMCPToolset(accessToken, { toolset_id: editToolset.toolset_id, toolset_name: name, description, tools });
message.success("Toolset updated");
message.success(t("mcpTools.mCPToolsetsTab.toolsetUpdated"));
queryClient.invalidateQueries({ queryKey: ["mcpToolsets"] });
setEditToolset(null);
};
@ -458,7 +476,7 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
setDeleting(true);
try {
await deleteMCPToolset(accessToken, deleteId);
message.success("Toolset deleted");
message.success(t("mcpTools.mCPToolsetsTab.toolsetDeleted"));
queryClient.invalidateQueries({ queryKey: ["mcpToolsets"] });
setDeleteId(null);
} finally {
@ -467,21 +485,18 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
};
const proxyBaseUrl = getProxyBaseUrl();
const columns = toolsetColumns(isAdmin, setEditToolset, setDeleteId, proxyBaseUrl);
const columns = toolsetColumns(t, isAdmin, setEditToolset, setDeleteId, proxyBaseUrl);
return (
<div className="mt-4">
<div className="flex items-center justify-between mb-4">
<div>
<Title>MCP Toolsets</Title>
<Text className="text-gray-500 text-sm">
Curated collections of tools from one or more MCP servers. Assign toolsets to keys and teams via the MCP
permissions dropdown.
</Text>
<Title>{t("mcpTools.mCPToolsetsTab.pageTitle")}</Title>
<Text className="text-gray-500 text-sm">{t("mcpTools.mCPToolsetsTab.pageSubtitle")}</Text>
</div>
{isAdmin && (
<Button icon={PlusIcon} onClick={() => setCreateOpen(true)}>
New Toolset
{t("mcpTools.mCPToolsetsTab.newToolset")}
</Button>
)}
</div>
@ -494,8 +509,8 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
renderSubComponent={() => <div />}
getRowCanExpand={() => false}
isLoading={isLoading}
noDataMessage="No toolsets yet. Click 'New Toolset' to create one."
loadingMessage="Loading toolsets..."
noDataMessage={t("mcpTools.mCPToolsetsTab.noToolsets")}
loadingMessage={t("mcpTools.mCPToolsetsTab.loadingToolsets")}
enableSorting={true}
/>
@ -520,13 +535,11 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
open={!!deleteId}
onCancel={() => setDeleteId(null)}
onOk={handleDelete}
okText="Delete"
okText={t("common.delete")}
okButtonProps={{ danger: true, loading: deleting }}
title="Delete Toolset"
title={t("mcpTools.mCPToolsetsTab.deleteToolsetTitle")}
>
<p>
Are you sure you want to delete this toolset? Keys and teams using it will lose access to the scoped tools.
</p>
<p>{t("mcpTools.mCPToolsetsTab.deleteToolsetConfirm")}</p>
</Modal>
</div>
);

View file

@ -3166,6 +3166,102 @@
"groupSecurity": "Security",
"fieldAuthLabel": "Auth configured",
"fieldAuthDesc": "Must use authentication (not 'none')"
},
"mCPSubmissionsTab": {
"statusActive": "Active",
"statusPendingReview": "Pending Review",
"statusRejected": "Rejected",
"rejectBodyActive": "This server is currently live. Rejecting it will immediately remove it from the proxy runtime.",
"rejectBodyInactive": "This will mark the submission as rejected.",
"confirmApproveMcpServer": "Approve MCP Server",
"confirmRejectMcpServer": "Reject MCP Server",
"confirmAreYouSure": "Are you sure you want to {{action}}",
"confirmApproveBody": "This will make it active and available for use.",
"rejectionReasonPlaceholder": "Reason for rejection (optional)",
"approveButton": "Approve",
"rejectButton": "Reject",
"reapproveButton": "Re-approve",
"submissionRulesTitle": "Submission Rules",
"requiredFieldCount_one": "{{count}} required field",
"requiredFieldCount_other": "{{count}} required fields",
"noRulesSet": "no rules set",
"submissionRulesDesc": "Select which fields must be filled in before a submission is considered compliant. LiteLLM will show ✓ / ✗ for each rule on every submission card below.",
"saveRulesButton": "Save Rules",
"transportLabel": "Transport:",
"submittedByLabel": "Submitted by:",
"rejectionReasonLabel": "Rejection reason: {{reason}}",
"allChecksPassed": "All checks passed",
"checksFailed_one": "{{count}} check failed",
"checksFailed_other": "{{count}} checks failed",
"checksPassFail": "{{passCount}} passing, {{failCount}} failing",
"checkPasses": "Passes",
"checkMissing": "Missing",
"statTotalSubmitted": "Total Submitted",
"statPendingReview": "Pending Review",
"statActive": "Active",
"statRejected": "Rejected",
"filterAllStatus": "All Status",
"loadingSubmissions": "Loading submissions…",
"noSubmissionsMatch": "No MCP server submissions match your filters.",
"failedToLoadSubmissions": "Failed to load submissions",
"submissionRulesSaved": "Submission rules saved",
"failedToSaveRules": "Failed to save submission rules",
"approvedSuccess": "MCP server \"{{name}}\" approved",
"failedToApprove": "Failed to approve MCP server",
"rejectedSuccess": "MCP server \"{{name}}\" rejected",
"failedToReject": "Failed to reject MCP server"
},
"mCPToolArgumentsForm": {
"inputDescription": "Input for this tool",
"inputLabel": "Input",
"inputRequired": "Please enter input for this tool",
"inputPlaceholder": "Enter input for this tool",
"noParameters": "No parameters required for this tool.",
"fieldRequired": "Please enter {{key}}",
"invalidJsonObject": "Please enter a JSON object",
"invalidJsonArray": "Please enter a JSON array",
"invalidJson": "Invalid JSON",
"selectPlaceholder": "Select {{key}}",
"enterPlaceholder": "Enter {{key}}",
"booleanTrue": "True",
"booleanFalse": "False",
"jsonObjectPlaceholder": "Enter JSON object for {{key}}",
"jsonArrayPlaceholder": "Enter JSON array for {{key}}"
},
"mCPToolsetsTab": {
"selectedCount": "{{count}} selected",
"noToolsFound": "No tools found for this server.",
"editToolset": "Edit Toolset",
"newToolset": "New Toolset",
"toolsetNameLabel": "Toolset Name",
"toolsetNameRequired": "Please enter a toolset name",
"descriptionPlaceholder": "Optional description",
"availableTools": "Available Tools",
"noMcpServersConfigured": "No MCP servers configured",
"noServersMatchSearch": "No servers match your search",
"yourToolset": "Your Toolset",
"toolCount": "{{count}} tools",
"noToolsAdded": "No tools added yet",
"saveChanges": "Save Changes",
"createToolset": "Create Toolset",
"colToolsetId": "Toolset ID",
"colTools": "Tools",
"copyEndpointTitle": "Click to copy endpoint URL",
"moreTools": "+{{count}} more",
"toolsetCreated": "Toolset created",
"toolsetUpdated": "Toolset updated",
"toolsetDeleted": "Toolset deleted",
"pageTitle": "MCP Toolsets",
"pageSubtitle": "Curated collections of tools from one or more MCP servers. Assign toolsets to keys and teams via the MCP permissions dropdown.",
"noToolsets": "No toolsets yet. Click 'New Toolset' to create one.",
"loadingToolsets": "Loading toolsets...",
"deleteToolsetTitle": "Delete Toolset",
"deleteToolsetConfirm": "Are you sure you want to delete this toolset? Keys and teams using it will lose access to the scoped tools.",
"howToolsetsWorkTitle": "How toolsets work",
"howToolsetsWorkDesc1": "Create a toolset, assign it to a key via",
"howToolsetsWorkPath": "API Keys → Edit Key → MCP Servers",
"howToolsetsWorkDesc2": ", then point your MCP client at the toolset URL. The client only sees the tools you picked.",
"configLabel": "Claude Code / Cursor config"
}
}
}

View file

@ -3166,6 +3166,102 @@
"groupSecurity": "安全",
"fieldAuthLabel": "已配置认证",
"fieldAuthDesc": "必须使用认证(不能为 'none')"
},
"mCPSubmissionsTab": {
"statusActive": "活跃",
"statusPendingReview": "待审核",
"statusRejected": "已拒绝",
"rejectBodyActive": "此服务器当前正在运行。拒绝后将立即从代理运行时中移除。",
"rejectBodyInactive": "这将把提交标记为已拒绝。",
"confirmApproveMcpServer": "批准 MCP 服务器",
"confirmRejectMcpServer": "拒绝 MCP 服务器",
"confirmAreYouSure": "确定要对以下服务器执行{{action}}操作吗",
"confirmApproveBody": "这将使其处于活跃状态并可供使用。",
"rejectionReasonPlaceholder": "拒绝原因(可选)",
"approveButton": "批准",
"rejectButton": "拒绝",
"reapproveButton": "重新批准",
"submissionRulesTitle": "提交规则",
"requiredFieldCount_one": "{{count}} 个必填字段",
"requiredFieldCount_other": "{{count}} 个必填字段",
"noRulesSet": "未设置规则",
"submissionRulesDesc": "选择提交内容被视为合规前必须填写的字段。LiteLLM 将在下方每张提交卡片上为每条规则显示 ✓ / ✗。",
"saveRulesButton": "保存规则",
"transportLabel": "传输方式:",
"submittedByLabel": "提交者:",
"rejectionReasonLabel": "拒绝原因:{{reason}}",
"allChecksPassed": "所有检查已通过",
"checksFailed_one": "{{count}} 项检查失败",
"checksFailed_other": "{{count}} 项检查失败",
"checksPassFail": "{{passCount}} 项通过,{{failCount}} 项未通过",
"checkPasses": "通过",
"checkMissing": "缺失",
"statTotalSubmitted": "总提交数",
"statPendingReview": "待审核",
"statActive": "活跃",
"statRejected": "已拒绝",
"filterAllStatus": "全部状态",
"loadingSubmissions": "加载提交中…",
"noSubmissionsMatch": "没有符合筛选条件的 MCP 服务器提交。",
"failedToLoadSubmissions": "加载提交失败",
"submissionRulesSaved": "提交规则已保存",
"failedToSaveRules": "保存提交规则失败",
"approvedSuccess": "MCP 服务器「{{name}}」已批准",
"failedToApprove": "批准 MCP 服务器失败",
"rejectedSuccess": "MCP 服务器「{{name}}」已拒绝",
"failedToReject": "拒绝 MCP 服务器失败"
},
"mCPToolArgumentsForm": {
"inputDescription": "此工具的输入",
"inputLabel": "输入",
"inputRequired": "请为此工具输入内容",
"inputPlaceholder": "为此工具输入内容",
"noParameters": "此工具不需要任何参数。",
"fieldRequired": "请输入 {{key}}",
"invalidJsonObject": "请输入 JSON 对象",
"invalidJsonArray": "请输入 JSON 数组",
"invalidJson": "无效的 JSON",
"selectPlaceholder": "选择 {{key}}",
"enterPlaceholder": "输入 {{key}}",
"booleanTrue": "是",
"booleanFalse": "否",
"jsonObjectPlaceholder": "为 {{key}} 输入 JSON 对象",
"jsonArrayPlaceholder": "为 {{key}} 输入 JSON 数组"
},
"mCPToolsetsTab": {
"selectedCount": "已选 {{count}} 个",
"noToolsFound": "未找到此服务器的工具。",
"editToolset": "编辑工具集",
"newToolset": "新建工具集",
"toolsetNameLabel": "工具集名称",
"toolsetNameRequired": "请输入工具集名称",
"descriptionPlaceholder": "可选描述",
"availableTools": "可用工具",
"noMcpServersConfigured": "未配置 MCP 服务器",
"noServersMatchSearch": "没有服务器匹配您的搜索",
"yourToolset": "您的工具集",
"toolCount": "{{count}} 个工具",
"noToolsAdded": "尚未添加工具",
"saveChanges": "保存更改",
"createToolset": "创建工具集",
"colToolsetId": "工具集 ID",
"colTools": "工具",
"copyEndpointTitle": "点击复制端点 URL",
"moreTools": "+{{count}} 个",
"toolsetCreated": "工具集已创建",
"toolsetUpdated": "工具集已更新",
"toolsetDeleted": "工具集已删除",
"pageTitle": "MCP 工具集",
"pageSubtitle": "来自一个或多个 MCP 服务器的精选工具集合。通过 MCP 权限下拉菜单将工具集分配给密钥和团队。",
"noToolsets": "暂无工具集。点击「新建工具集」创建一个。",
"loadingToolsets": "加载工具集中...",
"deleteToolsetTitle": "删除工具集",
"deleteToolsetConfirm": "确定要删除此工具集吗?使用它的密钥和团队将失去对已选工具的访问权限。",
"howToolsetsWorkTitle": "工具集如何工作",
"howToolsetsWorkDesc1": "创建工具集,通过",
"howToolsetsWorkPath": "API 密钥 → 编辑密钥 → MCP 服务器",
"howToolsetsWorkDesc2": "将其分配给密钥,然后将您的 MCP 客户端指向工具集 URL。客户端只会看到您选定的工具。",
"configLabel": "Claude Code / Cursor 配置"
}
}
}