mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-01 07:03:02 +00:00
refactor(ui): move MCP text onto i18n
Route the page-authored English on /mcp-servers, /mcp/oauth/callback and the shared MCP permission, server selector and BYOK components through t() and Trans, with 1,094 messages added to en.json under mcpServers, mcpServerManagement, mcpTools and mcpOAuthCallback. The rendered English is unchanged.
This commit is contained in:
parent
bfef2e1e56
commit
b60e82e364
60 changed files with 4151 additions and 1630 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import { Info } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
|
||||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||
|
|
@ -22,134 +23,150 @@ const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, toolt
|
|||
const ACCESS_KEY_PATH = ["credentials", "aws_access_key_id"] as const;
|
||||
const SECRET_KEY_PATH = ["credentials", "aws_secret_access_key"] as const;
|
||||
|
||||
const AwsSigV4Fields: React.FC = () => (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
For MCP servers hosted on AWS Bedrock AgentCore.{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/mcp_aws_sigv4"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-info hover:text-info/80"
|
||||
const AwsSigV4Fields: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{t("mcpServers.awsSigV4Fields.intro")}{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/mcp_aws_sigv4"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-info hover:text-info/80"
|
||||
>
|
||||
{t("mcpServers.awsSigV4Fields.viewDocs")}
|
||||
</a>
|
||||
</p>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label={t("mcpServers.awsSigV4Fields.region.label")}
|
||||
tooltip={t("mcpServers.awsSigV4Fields.region.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={["credentials", "aws_region_name"]}
|
||||
required
|
||||
rules={{ validate: { required: requiredRule(t("mcpServers.awsSigV4Fields.region.required")) } }}
|
||||
>
|
||||
View docs →
|
||||
</a>
|
||||
</p>
|
||||
<MountedFormField
|
||||
label={<FieldLabel label="AWS Region" tooltip="AWS region for SigV4 signing (e.g., us-east-1)" />}
|
||||
name={["credentials", "aws_region_name"]}
|
||||
required
|
||||
rules={{ validate: { required: requiredRule("AWS region is required for SigV4 auth") } }}
|
||||
>
|
||||
{(control) => <Input {...textControl(control)} placeholder="us-east-1" className={fieldClassName} />}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="AWS Service Name"
|
||||
tooltip="AWS service name for SigV4 signing. Defaults to 'bedrock-agentcore'."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "aws_service_name"]}
|
||||
>
|
||||
{(control) => <Input {...textControl(control)} placeholder="bedrock-agentcore" className={fieldClassName} />}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="AWS Access Key ID"
|
||||
tooltip="Optional. If not provided, falls back to the boto3 credential chain (IAM role, env vars, etc.)."
|
||||
/>
|
||||
}
|
||||
name={ACCESS_KEY_PATH}
|
||||
rules={{
|
||||
deps: ["credentials.aws_secret_access_key"],
|
||||
validate: {
|
||||
pairedWithSecret: requiredWhenSiblingSet(
|
||||
SECRET_KEY_PATH,
|
||||
"Access Key ID is required when Secret Access Key is provided",
|
||||
),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(control) => (
|
||||
<PasswordInput
|
||||
{...textControl(control)}
|
||||
placeholder="AKIA... (optional — uses IAM role if blank)"
|
||||
groupClassName={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel label="AWS Secret Access Key" tooltip="Optional. Required if AWS Access Key ID is provided." />
|
||||
}
|
||||
name={SECRET_KEY_PATH}
|
||||
rules={{
|
||||
deps: ["credentials.aws_access_key_id"],
|
||||
validate: {
|
||||
pairedWithAccessKey: requiredWhenSiblingSet(
|
||||
ACCESS_KEY_PATH,
|
||||
"Secret Access Key is required when Access Key ID is provided",
|
||||
),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(control) => (
|
||||
<PasswordInput
|
||||
{...textControl(control)}
|
||||
placeholder="Enter secret key (optional — uses IAM role if blank)"
|
||||
groupClassName={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={<FieldLabel label="AWS Session Token" tooltip="Optional. Only needed for temporary STS credentials." />}
|
||||
name={["credentials", "aws_session_token"]}
|
||||
>
|
||||
{(control) => (
|
||||
<PasswordInput
|
||||
{...textControl(control)}
|
||||
placeholder="Enter session token (optional)"
|
||||
groupClassName={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="AWS Role ARN"
|
||||
tooltip="Optional. IAM role ARN to assume via STS before signing. If set, LiteLLM calls sts:AssumeRole to get temporary credentials. Uses ambient credentials (IAM role, env vars) as the source identity unless explicit keys are also provided."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "aws_role_name"]}
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="arn:aws:iam::123456789012:role/MyRole (optional)"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="AWS Session Name"
|
||||
tooltip="Optional. Session name for the AssumeRole call — appears in CloudTrail logs. Auto-generated if omitted."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "aws_session_name"]}
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="litellm-prod (optional, auto-generated if blank)"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</>
|
||||
);
|
||||
{(control) => <Input {...textControl(control)} placeholder="us-east-1" className={fieldClassName} />}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label={t("mcpServers.awsSigV4Fields.serviceName.label")}
|
||||
tooltip={t("mcpServers.awsSigV4Fields.serviceName.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={["credentials", "aws_service_name"]}
|
||||
>
|
||||
{(control) => <Input {...textControl(control)} placeholder="bedrock-agentcore" className={fieldClassName} />}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label={t("mcpServers.awsSigV4Fields.accessKeyId.label")}
|
||||
tooltip={t("mcpServers.awsSigV4Fields.accessKeyId.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={ACCESS_KEY_PATH}
|
||||
rules={{
|
||||
deps: ["credentials.aws_secret_access_key"],
|
||||
validate: {
|
||||
pairedWithSecret: requiredWhenSiblingSet(
|
||||
SECRET_KEY_PATH,
|
||||
t("mcpServers.awsSigV4Fields.accessKeyId.requiredWithSecret"),
|
||||
),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(control) => (
|
||||
<PasswordInput
|
||||
{...textControl(control)}
|
||||
placeholder={t("mcpServers.awsSigV4Fields.accessKeyId.placeholder")}
|
||||
groupClassName={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label={t("mcpServers.awsSigV4Fields.secretAccessKey.label")}
|
||||
tooltip={t("mcpServers.awsSigV4Fields.secretAccessKey.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={SECRET_KEY_PATH}
|
||||
rules={{
|
||||
deps: ["credentials.aws_access_key_id"],
|
||||
validate: {
|
||||
pairedWithAccessKey: requiredWhenSiblingSet(
|
||||
ACCESS_KEY_PATH,
|
||||
t("mcpServers.awsSigV4Fields.secretAccessKey.requiredWithAccessKey"),
|
||||
),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(control) => (
|
||||
<PasswordInput
|
||||
{...textControl(control)}
|
||||
placeholder={t("mcpServers.awsSigV4Fields.secretAccessKey.placeholder")}
|
||||
groupClassName={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label={t("mcpServers.awsSigV4Fields.sessionToken.label")}
|
||||
tooltip={t("mcpServers.awsSigV4Fields.sessionToken.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={["credentials", "aws_session_token"]}
|
||||
>
|
||||
{(control) => (
|
||||
<PasswordInput
|
||||
{...textControl(control)}
|
||||
placeholder={t("mcpServers.awsSigV4Fields.sessionToken.placeholder")}
|
||||
groupClassName={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label={t("mcpServers.awsSigV4Fields.roleArn.label")}
|
||||
tooltip={t("mcpServers.awsSigV4Fields.roleArn.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={["credentials", "aws_role_name"]}
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder={t("mcpServers.awsSigV4Fields.roleArn.placeholder")}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label={t("mcpServers.awsSigV4Fields.sessionName.label")}
|
||||
tooltip={t("mcpServers.awsSigV4Fields.sessionName.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={["credentials", "aws_session_name"]}
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder={t("mcpServers.awsSigV4Fields.sessionName.placeholder")}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AwsSigV4Fields;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { TFunction } from "i18next";
|
||||
import React, { useState } from "react";
|
||||
import { FormProvider, useForm, useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, Info } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
|
|
@ -35,7 +37,7 @@ import {
|
|||
buildCreateServerPayload,
|
||||
reduceStaticHeaders,
|
||||
} from "./createServerPayload";
|
||||
import { DUPLICATE_IDENTIFIER_MESSAGE, findDuplicateMcpServer, mcpSubmitErrorReason } from "./duplicateServerCheck";
|
||||
import { DUPLICATE_IDENTIFIER_MESSAGE_KEY, findDuplicateMcpServer, mcpSubmitErrorReason } from "./duplicateServerCheck";
|
||||
import { readCreateUiSnapshot, writeCreateUiSnapshot } from "./createOAuthUiState";
|
||||
import AwsSigV4Fields from "./AwsSigV4Fields";
|
||||
import OpenApiByokFields from "./OpenApiByokFields";
|
||||
|
|
@ -86,14 +88,14 @@ interface CreateMCPServerProps {
|
|||
stdioEnabled?: boolean;
|
||||
}
|
||||
|
||||
const payloadErrorMessage = (result: Exclude<BuildCreatePayloadResult, { kind: "ok" }>): string => {
|
||||
const payloadErrorMessage = (result: Exclude<BuildCreatePayloadResult, { kind: "ok" }>, t: TFunction): string => {
|
||||
switch (result.kind) {
|
||||
case "invalid_tool_display_name":
|
||||
return `Tool display name "${result.displayName}" is invalid. Only letters, digits, underscores, and hyphens are allowed (no spaces).`;
|
||||
return t("mcpServers.createMcpServer.payloadErrors.invalidToolDisplayName", { displayName: result.displayName });
|
||||
case "invalid_stdio_json":
|
||||
return "Invalid JSON in stdio configuration";
|
||||
return t("mcpServers.createMcpServer.payloadErrors.invalidStdioJson");
|
||||
case "invalid_token_validation_json":
|
||||
return "Invalid JSON in Token Validation Rules";
|
||||
return t("mcpServers.createMcpServer.payloadErrors.invalidTokenValidationJson");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -117,6 +119,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
onBackToDiscovery,
|
||||
stdioEnabled = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useForm<MountedFormValues>({ mode: "onChange", defaultValues: CREATE_DEFAULTS });
|
||||
const registry = useMountRegistry();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
|
@ -255,9 +258,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
// which would persist it as server-level credentials on the created server row. Mirrors the
|
||||
// edit form's onTokenReceived early return.
|
||||
setAuthorizedIdentity(getOAuthAuthorizationIdentity(allFieldsValue(form)));
|
||||
toast.success(
|
||||
"Token held for this browser session. Tools can now be previewed and configured; the token is not saved to LiteLLM.",
|
||||
);
|
||||
toast.success(t("mcpServers.createMcpServer.toasts.browserTokenHeld"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -283,7 +284,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
// its own credential write.
|
||||
setAuthorizedIdentity(getOAuthAuthorizationIdentity(allFieldsValue(form)));
|
||||
|
||||
toast.success("OAuth authorization successful! Please click 'Create MCP Server' to save the configuration.");
|
||||
toast.success(t("mcpServers.createMcpServer.toasts.oauthAuthorized"));
|
||||
},
|
||||
onBeforeRedirect: persistCreateUiState,
|
||||
flowSource: "create",
|
||||
|
|
@ -425,8 +426,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
typeof values.alias === "string" ? values.alias : undefined,
|
||||
);
|
||||
if (duplicate) {
|
||||
form.setError(duplicate.field, { type: "duplicate", message: DUPLICATE_IDENTIFIER_MESSAGE });
|
||||
toast.fromError(DUPLICATE_IDENTIFIER_MESSAGE);
|
||||
form.setError(duplicate.field, { type: "duplicate", message: t(DUPLICATE_IDENTIFIER_MESSAGE_KEY) });
|
||||
toast.fromError(t(DUPLICATE_IDENTIFIER_MESSAGE_KEY));
|
||||
return;
|
||||
}
|
||||
const built = buildCreateServerPayload(values, {
|
||||
|
|
@ -440,7 +441,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
dcrClient: dcrClientRef.current,
|
||||
});
|
||||
if (built.kind !== "ok") {
|
||||
toast.fromError(payloadErrorMessage(built));
|
||||
toast.fromError(payloadErrorMessage(built, t));
|
||||
return;
|
||||
}
|
||||
const payload = built.payload;
|
||||
|
|
@ -482,10 +483,10 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
}
|
||||
|
||||
if (isAdmin) {
|
||||
toast.success("MCP Server created successfully");
|
||||
toast.success(t("mcpServers.createMcpServer.toasts.created"));
|
||||
} else {
|
||||
toast.success("MCP Server submitted for admin review", {
|
||||
description: "Once an admin approves it, the server will appear in your MCP Servers list.",
|
||||
toast.success(t("mcpServers.createMcpServer.toasts.submittedForReview"), {
|
||||
description: t("mcpServers.createMcpServer.toasts.submittedForReviewDescription"),
|
||||
});
|
||||
}
|
||||
form.reset(CREATE_DEFAULTS);
|
||||
|
|
@ -500,7 +501,11 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
}
|
||||
} catch (error) {
|
||||
const reason = mcpSubmitErrorReason(error);
|
||||
toast.fromError(isAdmin ? `Error creating MCP Server: ${reason}` : `Error submitting MCP Server: ${reason}`);
|
||||
toast.fromError(
|
||||
isAdmin
|
||||
? t("mcpServers.createMcpServer.toasts.createFailed", { reason })
|
||||
: t("mcpServers.createMcpServer.toasts.submitFailed", { reason }),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
@ -640,9 +645,11 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
←
|
||||
</Button>
|
||||
)}
|
||||
<img src={mcpLogoImg} alt="MCP Logo" className="size-5 object-contain" />
|
||||
<img src={mcpLogoImg} alt={t("mcpServers.createMcpServer.logoAlt")} className="size-5 object-contain" />
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
{isAdmin ? "Add New MCP Server" : "Submit MCP Server for Review"}
|
||||
{isAdmin
|
||||
? t("mcpServers.createMcpServer.title.admin")
|
||||
: t("mcpServers.createMcpServer.title.submitForReview")}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
|
@ -653,27 +660,26 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{!isAdmin && (
|
||||
<div className="rounded-md bg-info/10 border border-info/20 px-4 py-3 text-sm text-info">
|
||||
Your submission will be sent for admin review. Once approved, the server will appear in your MCP
|
||||
Servers list. The request must be made with a team-scoped API key.
|
||||
{t("mcpServers.createMcpServer.reviewNotice")}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
MCP Server Name
|
||||
<SimpleTooltip content="Best practice: Use a descriptive name that indicates the server's purpose (e.g., 'GitHub_MCP', 'Email_Service'). Cannot contain spaces or hyphens; use underscores instead. Names must comply with SEP-986 and will be rejected if invalid (https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names).">
|
||||
{t("mcpServers.createMcpServer.serverName.label")}
|
||||
<SimpleTooltip content={t("mcpServers.createMcpServer.serverName.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="server_name"
|
||||
rules={{ validate: validatorRules({ validator: (_, value) => validateMCPServerName(value) }) }}
|
||||
rules={{ validate: validatorRules({ validator: (_, value) => validateMCPServerName(value, t) }) }}
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="e.g., GitHub_MCP, Zapier_MCP, etc."
|
||||
placeholder={t("mcpServers.createMcpServer.serverName.placeholder")}
|
||||
className="rounded-lg border-border focus:border-info focus:ring-ring"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -682,19 +688,19 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
Alias
|
||||
<SimpleTooltip content="A short, unique identifier for this server. Defaults to the server name if not provided. Cannot contain spaces or hyphens; use underscores instead.">
|
||||
{t("mcpServers.createMcpServer.alias.label")}
|
||||
<SimpleTooltip content={t("mcpServers.createMcpServer.alias.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="alias"
|
||||
rules={{ validate: validatorRules({ validator: (_, value) => validateMCPServerName(value) }) }}
|
||||
rules={{ validate: validatorRules({ validator: (_, value) => validateMCPServerName(value, t) }) }}
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="e.g., GitHub_MCP, Zapier_MCP, etc."
|
||||
placeholder={t("mcpServers.createMcpServer.alias.placeholder")}
|
||||
className="rounded-lg border-border focus:border-info focus:ring-ring"
|
||||
onChange={(event) => {
|
||||
control.onChange(event);
|
||||
|
|
@ -705,13 +711,17 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
</MountedFormField>
|
||||
|
||||
<MountedFormField
|
||||
label={<span className="text-sm font-medium text-foreground">Description</span>}
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t("mcpServers.createMcpServer.description.label")}
|
||||
</span>
|
||||
}
|
||||
name="description"
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="Brief description of what this server does"
|
||||
placeholder={t("mcpServers.createMcpServer.description.placeholder")}
|
||||
className="rounded-lg border-border focus:border-info focus:ring-ring"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -720,7 +730,11 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
<MCPLogoSelector value={logoUrl} onChange={setLogoUrl} />
|
||||
|
||||
<MountedFormField
|
||||
label={<span className="text-sm font-medium text-foreground">GitHub / Source URL</span>}
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t("mcpServers.createMcpServer.sourceUrl.label")}
|
||||
</span>
|
||||
}
|
||||
name="source_url"
|
||||
>
|
||||
{(control) => (
|
||||
|
|
@ -733,10 +747,14 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
</MountedFormField>
|
||||
|
||||
<MountedFormField
|
||||
label={<span className="text-sm font-medium text-foreground">Transport Type</span>}
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t("mcpServers.createMcpServer.transport.label")}
|
||||
</span>
|
||||
}
|
||||
name="transport"
|
||||
required
|
||||
rules={{ validate: { required: requiredRule("Please select a transport type") } }}
|
||||
rules={{ validate: { required: requiredRule(t("mcpServers.createMcpServer.transport.required")) } }}
|
||||
>
|
||||
{(control) => (
|
||||
<Select
|
||||
|
|
@ -745,7 +763,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
onValueChange={handleTransportSelected(control.onChange)}
|
||||
>
|
||||
<SelectTrigger {...selectTriggerControl(control)} className="w-full rounded-lg">
|
||||
<SelectValue placeholder="Select transport" />
|
||||
<SelectValue placeholder={t("mcpServers.createMcpServer.transport.placeholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<TransportSelectItems stdioEnabled={stdioEnabled} />
|
||||
|
|
@ -757,13 +775,17 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
{/* URL field - only show for HTTP and SSE */}
|
||||
{(transportType === "http" || transportType === "sse") && (
|
||||
<MountedFormField
|
||||
label={<span className="text-sm font-medium text-foreground">MCP Server URL</span>}
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t("mcpServers.createMcpServer.url.label")}
|
||||
</span>
|
||||
}
|
||||
name="url"
|
||||
required
|
||||
rules={{
|
||||
validate: {
|
||||
required: requiredRule("Please enter a server URL"),
|
||||
...validatorRules({ validator: (_, value) => validateMCPServerUrl(value) }),
|
||||
required: requiredRule(t("mcpServers.createMcpServer.url.required")),
|
||||
...validatorRules({ validator: (_, value) => validateMCPServerUrl(value, t) }),
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
|
@ -797,8 +819,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
Max Concurrent Requests (optional)
|
||||
<SimpleTooltip content="Maximum number of tool calls LiteLLM will run against this server at the same time. Additional calls wait for a free slot. Leave blank for no limit.">
|
||||
{t("mcpServers.createMcpServer.maxConcurrentRequests.label")}
|
||||
<SimpleTooltip content={t("mcpServers.createMcpServer.maxConcurrentRequests.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
|
|
@ -810,7 +832,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
{...numberControl(control, 0)}
|
||||
min={1}
|
||||
step={1}
|
||||
placeholder="e.g. 10"
|
||||
placeholder={t("mcpServers.createMcpServer.maxConcurrentRequests.placeholder")}
|
||||
className="w-full rounded-lg"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -819,8 +841,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
RPM limit (all callers)
|
||||
<SimpleTooltip content="Max requests per minute to this server across all keys and teams. Leave empty for no limit">
|
||||
{t("mcpServers.createMcpServer.rpm.label")}
|
||||
<SimpleTooltip content={t("mcpServers.createMcpServer.rpm.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
|
|
@ -832,7 +854,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
{...numberControl(control, 0)}
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder="e.g. 60"
|
||||
placeholder={t("mcpServers.createMcpServer.rpm.placeholder")}
|
||||
className="w-full rounded-lg"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -842,20 +864,24 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
{transportType !== "stdio" && transportType !== "" && (
|
||||
<Collapsible defaultOpen className="mb-4">
|
||||
<CollapsibleTrigger className="group flex w-full items-center justify-between gap-4 py-2 text-left">
|
||||
<span className="text-sm font-semibold text-foreground">Authentication settings</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{t("mcpServers.createMcpServer.auth.sectionTitle")}
|
||||
</span>
|
||||
<ChevronDown className="size-4 text-muted-foreground transition-transform group-data-[panel-open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent keepMounted className="space-y-6 pt-2">
|
||||
<MountedFormField
|
||||
label="Authentication"
|
||||
label={t("mcpServers.createMcpServer.auth.typeLabel")}
|
||||
name="auth_type"
|
||||
required
|
||||
rules={{ validate: { required: requiredRule("Please select an auth type") } }}
|
||||
rules={{
|
||||
validate: { required: requiredRule(t("mcpServers.createMcpServer.auth.typeRequired")) },
|
||||
}}
|
||||
>
|
||||
{(control) => (
|
||||
<Select {...selectControl<string>(control)} items={AUTH_TYPE_ITEMS}>
|
||||
<SelectTrigger {...selectTriggerControl(control)} className="w-full rounded-lg">
|
||||
<SelectValue placeholder="Select auth type" />
|
||||
<SelectValue placeholder={t("mcpServers.createMcpServer.auth.typePlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AUTH_TYPE_ITEMS.map((item) => (
|
||||
|
|
@ -886,8 +912,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
Authentication Value
|
||||
<SimpleTooltip content="Token, password, or header value to send with each request for the selected auth type.">
|
||||
{t("mcpServers.createMcpServer.auth.valueLabel")}
|
||||
<SimpleTooltip content={t("mcpServers.createMcpServer.auth.valueTooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
|
|
@ -895,14 +921,14 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
name={["credentials", "auth_value"]}
|
||||
rules={{
|
||||
validate: {
|
||||
notWhitespace: notOnlyWhitespace("Authentication value cannot be empty whitespace"),
|
||||
notWhitespace: notOnlyWhitespace(t("mcpServers.createMcpServer.auth.valueWhitespace")),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(control) => (
|
||||
<PasswordInput
|
||||
{...textControl(control)}
|
||||
placeholder="Enter token or secret"
|
||||
placeholder={t("mcpServers.createMcpServer.auth.valuePlaceholder")}
|
||||
groupClassName="rounded-lg border-border focus:border-info focus:ring-ring"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -1000,11 +1026,13 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
|
||||
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-border">
|
||||
<Button variant="secondary" onClick={handleCancel}>
|
||||
Cancel
|
||||
{t("mcpServers.createMcpServer.actions.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading} aria-busy={isLoading}>
|
||||
{isLoading && <UiLoadingSpinner className="size-4" />}
|
||||
{isLoading ? "Creating..." : "Add MCP Server"}
|
||||
{isLoading
|
||||
? t("mcpServers.createMcpServer.actions.creating")
|
||||
: t("mcpServers.createMcpServer.actions.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Info } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
|
@ -23,13 +24,14 @@ export default function DcrBridgeToggle({
|
|||
authType?: string | null;
|
||||
initialChecked?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!isClientForwardedTokenMode(authType)) return null;
|
||||
return (
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
Gateway-hosted sign-in (DCR bridge)
|
||||
<SimpleTooltip content="Lets OAuth-only clients like Claude Desktop register and sign in through the gateway. Turn off to relay the upstream server's own OAuth metadata instead (for clients pre-registered with the upstream IdP).">
|
||||
{t("mcpServers.dcrBridgeToggle.label")}
|
||||
<SimpleTooltip content={t("mcpServers.dcrBridgeToggle.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
import { CircleMinus, Info, Plus } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -16,11 +18,13 @@ import { requiredRule } from "@/components/common_components/formRules";
|
|||
import { matchesPattern, selectControl, selectTriggerControl, textControl } from "./mcpFieldRules";
|
||||
import { listControl } from "./mcpFormStore";
|
||||
|
||||
const SCOPE_OPTIONS = [
|
||||
{ value: "global", label: "Instance" },
|
||||
{ value: "user", label: "Per-user" },
|
||||
const SCOPE_OPTIONS: ReadonlyArray<{ value: string; labelKey: ParseKeys }> = [
|
||||
{ value: "global", labelKey: "mcpServers.envVarsSection.scopes.instance" },
|
||||
{ value: "user", labelKey: "mcpServers.envVarsSection.scopes.perUser" },
|
||||
];
|
||||
|
||||
const VARIABLE_SYNTAX = "${VAR_NAME}";
|
||||
|
||||
const VARIABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
/**
|
||||
|
|
@ -34,31 +38,34 @@ const VARIABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|||
* The parent form reads the ``env_vars`` field from the form values.
|
||||
*/
|
||||
const EnvVarsSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<MountedFormValues>();
|
||||
const { fields, append, remove } = useFieldArray({ control: listControl(control), name: "env_vars" });
|
||||
useMountedName("env_vars");
|
||||
const scopeOptions = SCOPE_OPTIONS.map((option) => ({ value: option.value, label: t(option.labelKey) }));
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-muted p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<strong className="text-sm font-semibold">Variables</strong>
|
||||
<strong className="text-sm font-semibold">{t("mcpServers.envVarsSection.title")}</strong>
|
||||
<SimpleTooltip
|
||||
content={
|
||||
<>
|
||||
Define variables you can interpolate in Static Headers or Authentication using{" "}
|
||||
<code>{"${VAR_NAME}"}</code>. <br />
|
||||
<b>Instance</b>: admin-defined value used for every user.
|
||||
<br />
|
||||
<b>Per-user</b>: each user supplies their own value (e.g. personal credentials) via the MCP Gateway
|
||||
dashboard.
|
||||
</>
|
||||
<Trans
|
||||
i18nKey="mcpServers.envVarsSection.tooltip"
|
||||
values={{ variableSyntax: VARIABLE_SYNTAX }}
|
||||
components={{ code: <code />, b: <b /> }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Info className="size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<span className="mb-3 block text-xs text-muted-foreground">
|
||||
Reference these in Static Headers or Authentication as <code>{"${VAR_NAME}"}</code>. For example:{" "}
|
||||
<Trans
|
||||
i18nKey="mcpServers.envVarsSection.reference"
|
||||
values={{ variableSyntax: VARIABLE_SYNTAX }}
|
||||
components={{ code: <code /> }}
|
||||
/>{" "}
|
||||
<code className="bg-card px-1 rounded-sm border border-border">
|
||||
{"${DB_PROTOCOL}://${CORP_USERNAME}:${CORP_PASSWORD}@${DB_HOSTNAME}"}
|
||||
</code>
|
||||
|
|
@ -67,9 +74,9 @@ const EnvVarsSection: React.FC = () => {
|
|||
<div className="space-y-2">
|
||||
{fields.length > 0 && (
|
||||
<div className="flex gap-3 px-1 text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
<div style={{ flex: 1 }}>Variable Name</div>
|
||||
<div style={{ flex: 1 }}>Value / Description</div>
|
||||
<div style={{ width: 160 }}>Scope</div>
|
||||
<div style={{ flex: 1 }}>{t("mcpServers.envVarsSection.columns.name")}</div>
|
||||
<div style={{ flex: 1 }}>{t("mcpServers.envVarsSection.columns.value")}</div>
|
||||
<div style={{ width: 160 }}>{t("mcpServers.envVarsSection.columns.scope")}</div>
|
||||
<div style={{ width: 24 }} />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -80,16 +87,17 @@ const EnvVarsSection: React.FC = () => {
|
|||
className="mb-0 flex-1"
|
||||
rules={{
|
||||
validate: {
|
||||
required: requiredRule("Variable name is required"),
|
||||
pattern: matchesPattern(
|
||||
VARIABLE_NAME_PATTERN,
|
||||
"Use letters, digits, underscores; cannot start with a digit.",
|
||||
),
|
||||
required: requiredRule(t("mcpServers.envVarsSection.name.required")),
|
||||
pattern: matchesPattern(VARIABLE_NAME_PATTERN, t("mcpServers.envVarsSection.name.invalid")),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(control) => (
|
||||
<Input {...textControl(control)} placeholder="e.g. DB_PROTOCOL" className="rounded-md font-mono" />
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder={t("mcpServers.envVarsSection.name.placeholder")}
|
||||
className="rounded-md font-mono"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<div style={{ flex: 1 }}>
|
||||
|
|
@ -97,12 +105,12 @@ const EnvVarsSection: React.FC = () => {
|
|||
</div>
|
||||
<MountedFormField name={["env_vars", String(index), "scope"]} className="mb-0 w-40" defaultValue="global">
|
||||
{(control) => (
|
||||
<Select {...selectControl<string>(control)} items={SCOPE_OPTIONS}>
|
||||
<Select {...selectControl<string>(control)} items={scopeOptions}>
|
||||
<SelectTrigger {...selectTriggerControl(control)} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCOPE_OPTIONS.map((option) => (
|
||||
{scopeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
|
|
@ -121,7 +129,7 @@ const EnvVarsSection: React.FC = () => {
|
|||
))}
|
||||
<Button variant="outline" className="w-full border-dashed" onClick={() => append({ scope: "global" })}>
|
||||
<Plus />
|
||||
Add Variable
|
||||
{t("mcpServers.envVarsSection.addVariable")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -132,6 +140,7 @@ const EnvVarsSection: React.FC = () => {
|
|||
// vars the value comes from each user later, so the column instead captures an
|
||||
// optional description that the per-user fill-in modal shows as a hint.
|
||||
const ScopedValueOrDescription: React.FC<{ index: number }> = ({ index }) => {
|
||||
const { t } = useTranslation();
|
||||
const isPerUser = useWatch({ name: `env_vars.${index}.scope` }) === "user";
|
||||
if (isPerUser) {
|
||||
return (
|
||||
|
|
@ -139,16 +148,16 @@ const ScopedValueOrDescription: React.FC<{ index: number }> = ({ index }) => {
|
|||
{(control) => (
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<SimpleTooltip content="Per-user variables have no shared value. This text is only a hint shown to each user when they fill in their own value.">
|
||||
<SimpleTooltip content={t("mcpServers.envVarsSection.hint.tooltip")}>
|
||||
<span className="text-xs text-muted-foreground cursor-help whitespace-nowrap">
|
||||
<Info className="mr-1 inline size-3 align-text-bottom" />
|
||||
Hint
|
||||
{t("mcpServers.envVarsSection.hint.label")}
|
||||
</span>
|
||||
</SimpleTooltip>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
{...textControl(control)}
|
||||
placeholder="e.g. Your DB username"
|
||||
placeholder={t("mcpServers.envVarsSection.hint.placeholder")}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</InputGroup>
|
||||
|
|
@ -158,7 +167,13 @@ const ScopedValueOrDescription: React.FC<{ index: number }> = ({ index }) => {
|
|||
}
|
||||
return (
|
||||
<MountedFormField name={["env_vars", String(index), "value"]} className="mb-0">
|
||||
{(control) => <Input {...textControl(control)} placeholder="e.g. postgresql" className="rounded-md font-mono" />}
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder={t("mcpServers.envVarsSection.value.placeholder")}
|
||||
className="rounded-md font-mono"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Info } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
|
||||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||
|
|
@ -27,9 +28,10 @@ const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, toolt
|
|||
);
|
||||
|
||||
const PRIVATE_KEY_PATH = ["credentials", "client_private_key"] as const;
|
||||
const PEM_HEADER = "-----BEGIN PRIVATE KEY-----";
|
||||
|
||||
const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false }) => {
|
||||
const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : "";
|
||||
const { t } = useTranslation();
|
||||
const requiredWhenCreating = (message: string) =>
|
||||
isEditing ? undefined : { validate: { required: requiredRule(message) } };
|
||||
|
||||
|
|
@ -38,13 +40,13 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Org Token Endpoint (leg 1)"
|
||||
tooltip="Your IdP org authorization server's token endpoint. LiteLLM exchanges the user's identity assertion here for an ID-JAG assertion (RFC 8693 with requested_token_type=urn:ietf:params:oauth:token-type:id-jag)."
|
||||
label={t("mcpServers.idJagFormFields.orgTokenEndpoint.label")}
|
||||
tooltip={t("mcpServers.idJagFormFields.orgTokenEndpoint.tooltip")}
|
||||
/>
|
||||
}
|
||||
name="token_exchange_endpoint"
|
||||
required={!isEditing}
|
||||
rules={requiredWhenCreating("The org token endpoint is required for ID-JAG")}
|
||||
rules={requiredWhenCreating(t("mcpServers.idJagFormFields.orgTokenEndpoint.required"))}
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
|
|
@ -57,13 +59,13 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Resource Token Endpoint (leg 2)"
|
||||
tooltip="The upstream resource authorization server's token endpoint. LiteLLM posts the ID-JAG assertion here as an RFC 7523 jwt-bearer grant to get the access token the MCP server accepts."
|
||||
label={t("mcpServers.idJagFormFields.resourceTokenEndpoint.label")}
|
||||
tooltip={t("mcpServers.idJagFormFields.resourceTokenEndpoint.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={["credentials", "id_jag_resource_token_endpoint"]}
|
||||
required={!isEditing}
|
||||
rules={requiredWhenCreating("The resource token endpoint is required for ID-JAG")}
|
||||
rules={requiredWhenCreating(t("mcpServers.idJagFormFields.resourceTokenEndpoint.required"))}
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
|
|
@ -74,15 +76,24 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={<FieldLabel label="Client ID" tooltip="OAuth2 client ID LiteLLM authenticates as on both legs." />}
|
||||
label={
|
||||
<FieldLabel
|
||||
label={t("mcpServers.idJagFormFields.clientId.label")}
|
||||
tooltip={t("mcpServers.idJagFormFields.clientId.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={["credentials", "client_id"]}
|
||||
required={!isEditing}
|
||||
rules={requiredWhenCreating("Client ID is required for ID-JAG")}
|
||||
rules={requiredWhenCreating(t("mcpServers.idJagFormFields.clientId.required"))}
|
||||
>
|
||||
{(control) => (
|
||||
<PasswordInput
|
||||
{...textControl(control)}
|
||||
placeholder={`Enter OAuth client ID${placeholderSuffix}`}
|
||||
placeholder={
|
||||
isEditing
|
||||
? t("mcpServers.idJagFormFields.clientId.placeholderEditing")
|
||||
: t("mcpServers.idJagFormFields.clientId.placeholder")
|
||||
}
|
||||
groupClassName={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -90,8 +101,8 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Client Secret"
|
||||
tooltip="Authenticates LiteLLM as the OAuth client via client_secret_post. Leave blank when using a private key instead; a private key takes precedence over this secret."
|
||||
label={t("mcpServers.idJagFormFields.clientSecret.label")}
|
||||
tooltip={t("mcpServers.idJagFormFields.clientSecret.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={["credentials", "client_secret"]}
|
||||
|
|
@ -103,7 +114,7 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
validate: {
|
||||
secretOrPrivateKey: requiredUnlessSiblingSet(
|
||||
PRIVATE_KEY_PATH,
|
||||
"Provide either a client secret or a client private key",
|
||||
t("mcpServers.idJagFormFields.clientSecret.secretOrPrivateKey"),
|
||||
),
|
||||
},
|
||||
}
|
||||
|
|
@ -112,7 +123,11 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
{(control) => (
|
||||
<PasswordInput
|
||||
{...textControl(control)}
|
||||
placeholder={`Enter OAuth client secret${placeholderSuffix}`}
|
||||
placeholder={
|
||||
isEditing
|
||||
? t("mcpServers.idJagFormFields.clientSecret.placeholderEditing")
|
||||
: t("mcpServers.idJagFormFields.clientSecret.placeholder")
|
||||
}
|
||||
groupClassName={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -120,8 +135,8 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Client Private Key (PEM)"
|
||||
tooltip="PEM private key signing the RFC 7523 private_key_jwt client assertion. Okta Cross App Access normally requires this. When set it takes precedence over the client secret."
|
||||
label={t("mcpServers.idJagFormFields.privateKey.label")}
|
||||
tooltip={t("mcpServers.idJagFormFields.privateKey.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={PRIVATE_KEY_PATH}
|
||||
|
|
@ -130,7 +145,11 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
<Textarea
|
||||
{...textControl(control)}
|
||||
rows={3}
|
||||
placeholder={`-----BEGIN PRIVATE KEY-----${placeholderSuffix}`}
|
||||
placeholder={
|
||||
isEditing
|
||||
? t("mcpServers.idJagFormFields.privateKey.placeholderEditing", { pemHeader: PEM_HEADER })
|
||||
: PEM_HEADER
|
||||
}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -138,8 +157,8 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Private Key ID (optional)"
|
||||
tooltip="The kid advertised in the client assertion JWT header, so the IdP can select the right registered key."
|
||||
label={t("mcpServers.idJagFormFields.privateKeyId.label")}
|
||||
tooltip={t("mcpServers.idJagFormFields.privateKeyId.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={["credentials", "client_private_key_id"]}
|
||||
|
|
@ -149,8 +168,8 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Client Assertion Signing Algorithm (optional)"
|
||||
tooltip="Algorithm signing the client assertion JWT. Defaults to RS256."
|
||||
label={t("mcpServers.idJagFormFields.signingAlg.label")}
|
||||
tooltip={t("mcpServers.idJagFormFields.signingAlg.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={["credentials", "client_assertion_signing_alg"]}
|
||||
|
|
@ -160,8 +179,8 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Audience (optional)"
|
||||
tooltip="RFC 8693 audience sent on leg 1, identifying the upstream the ID-JAG assertion is minted for."
|
||||
label={t("mcpServers.idJagFormFields.audience.label")}
|
||||
tooltip={t("mcpServers.idJagFormFields.audience.tooltip")}
|
||||
/>
|
||||
}
|
||||
name="audience"
|
||||
|
|
@ -173,8 +192,8 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Resource Indicator (optional)"
|
||||
tooltip="RFC 8707 resource indicator sent on leg 1. Separate from Audience, which is the RFC 8693 parameter."
|
||||
label={t("mcpServers.idJagFormFields.resource.label")}
|
||||
tooltip={t("mcpServers.idJagFormFields.resource.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={["credentials", "id_jag_resource"]}
|
||||
|
|
@ -186,8 +205,8 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Subject Token Type (optional)"
|
||||
tooltip="Type of the identity assertion exchanged on leg 1. Defaults to urn:ietf:params:oauth:token-type:id_token."
|
||||
label={t("mcpServers.idJagFormFields.subjectTokenType.label")}
|
||||
tooltip={t("mcpServers.idJagFormFields.subjectTokenType.tooltip")}
|
||||
/>
|
||||
}
|
||||
name="subject_token_type"
|
||||
|
|
@ -201,10 +220,21 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={<FieldLabel label="Scopes (optional)" tooltip="Scopes requested on leg 1 of the exchange." />}
|
||||
label={
|
||||
<FieldLabel
|
||||
label={t("mcpServers.idJagFormFields.scopes.label")}
|
||||
tooltip={t("mcpServers.idJagFormFields.scopes.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={["credentials", "scopes"]}
|
||||
>
|
||||
{(control) => <MultiSelect {...tagsControl(control)} placeholder="Add scopes" className="rounded-lg" />}
|
||||
{(control) => (
|
||||
<MultiSelect
|
||||
{...tagsControl(control, t)}
|
||||
placeholder={t("mcpServers.idJagFormFields.scopes.placeholder")}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<UpstreamTokenHeaderField />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
|
@ -24,6 +25,7 @@ const PLACEHOLDER = `{
|
|||
}`;
|
||||
|
||||
const ImportMCPServers: React.FC<ImportMCPServersProps> = ({ accessToken, open, onClose, onImported }) => {
|
||||
const { t } = useTranslation();
|
||||
const [configText, setConfigText] = useState("");
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
|
@ -37,7 +39,7 @@ const ImportMCPServers: React.FC<ImportMCPServersProps> = ({ accessToken, open,
|
|||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
const parsed = parseConnectorConfig(configText);
|
||||
const parsed = parseConnectorConfig(configText, t);
|
||||
if (!parsed.ok) {
|
||||
setParseError(parsed.error);
|
||||
return;
|
||||
|
|
@ -48,12 +50,12 @@ const ImportMCPServers: React.FC<ImportMCPServersProps> = ({ accessToken, open,
|
|||
const response = (await importMCPServers(accessToken, parsed.payload)) as MCPConnectorImportResponse;
|
||||
setResult(response);
|
||||
if (response.imported.length > 0) {
|
||||
toast.success(`Imported ${response.imported.length} MCP server${response.imported.length === 1 ? "" : "s"}`);
|
||||
toast.success(t("mcpServers.importMcpServers.toasts.imported", { count: response.imported.length }));
|
||||
onImported();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to import MCP servers:", error);
|
||||
setParseError("Import request failed. Check the proxy logs for details.");
|
||||
setParseError(t("mcpServers.importMcpServers.errors.requestFailed"));
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
|
|
@ -63,15 +65,14 @@ const ImportMCPServers: React.FC<ImportMCPServersProps> = ({ accessToken, open,
|
|||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import MCP Connectors</DialogTitle>
|
||||
<DialogTitle>{t("mcpServers.importMcpServers.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Paste an Anthropic connector configuration: the <code>mcpServers</code> mapping from a Claude Desktop /
|
||||
Claude Code config file, or the <code>mcp_servers</code> array from the Anthropic Messages API.
|
||||
<Trans i18nKey="mcpServers.importMcpServers.description" components={{ code: <code /> }} />
|
||||
</p>
|
||||
<Textarea
|
||||
aria-label="Connector JSON"
|
||||
aria-label={t("mcpServers.importMcpServers.connectorJsonLabel")}
|
||||
value={configText}
|
||||
onChange={(e) => setConfigText(e.target.value)}
|
||||
placeholder={PLACEHOLDER}
|
||||
|
|
@ -87,13 +88,13 @@ const ImportMCPServers: React.FC<ImportMCPServersProps> = ({ accessToken, open,
|
|||
<div className="space-y-2 text-sm">
|
||||
{result.imported.length > 0 && (
|
||||
<div>
|
||||
<span className="font-semibold">Imported:</span>{" "}
|
||||
<span className="font-semibold">{t("mcpServers.importMcpServers.result.imported")}</span>{" "}
|
||||
{result.imported.map((entry) => entry.alias || entry.name).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{result.skipped.length > 0 && (
|
||||
<div>
|
||||
<span className="font-semibold">Skipped:</span>
|
||||
<span className="font-semibold">{t("mcpServers.importMcpServers.result.skipped")}</span>
|
||||
<ul className="ml-4 list-disc">
|
||||
{result.skipped.map((entry) => (
|
||||
<li key={entry.name}>
|
||||
|
|
@ -105,7 +106,7 @@ const ImportMCPServers: React.FC<ImportMCPServersProps> = ({ accessToken, open,
|
|||
)}
|
||||
{result.errors.length > 0 && (
|
||||
<div>
|
||||
<span className="font-semibold">Failed:</span>
|
||||
<span className="font-semibold">{t("mcpServers.importMcpServers.result.failed")}</span>
|
||||
<ul className="ml-4 list-disc">
|
||||
{result.errors.map((entry) => (
|
||||
<li key={entry.name}>
|
||||
|
|
@ -119,10 +120,10 @@ const ImportMCPServers: React.FC<ImportMCPServersProps> = ({ accessToken, open,
|
|||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleClose} disabled={isImporting}>
|
||||
Close
|
||||
{t("mcpServers.importMcpServers.close")}
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={isImporting}>
|
||||
{isImporting ? "Importing..." : "Import"}
|
||||
{isImporting ? t("mcpServers.importMcpServers.importing") : t("mcpServers.importMcpServers.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
import { useTerminology } from "@/contexts/TerminologyContext";
|
||||
import React, { useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { RefreshCw, Unplug } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
|
|
@ -27,29 +30,36 @@ import { createQueryKeys } from "@/app/(dashboard)/hooks/common/queryKeysFactory
|
|||
|
||||
const mcpGatewaySessionKeys = createQueryKeys("mcpGatewaySessions");
|
||||
const REFETCH_INTERVAL_MS = 15000;
|
||||
const UNKNOWN_LABEL = "(unknown)";
|
||||
|
||||
export function formatIdleSeconds(idleSeconds: number): string {
|
||||
export function formatIdleSeconds(idleSeconds: number, t: TFunction = i18n.t.bind(i18n)): string {
|
||||
const total = Math.max(0, Math.floor(idleSeconds));
|
||||
if (total < 60) return `${total}s`;
|
||||
if (total < 60) return t("mcpServers.gatewaySessionsTab.idle.seconds", { seconds: total });
|
||||
const minutes = Math.floor(total / 60);
|
||||
const seconds = total % 60;
|
||||
return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
|
||||
return seconds === 0
|
||||
? t("mcpServers.gatewaySessionsTab.idle.minutes", { minutes })
|
||||
: t("mcpServers.gatewaySessionsTab.idle.minutesSeconds", { minutes, seconds });
|
||||
}
|
||||
|
||||
function groupLabel(label: string | null): string {
|
||||
if (label === null) return UNKNOWN_LABEL;
|
||||
function groupLabel(label: string | null, t: TFunction): string {
|
||||
if (label === null) return t("mcpServers.gatewaySessionsTab.unknown");
|
||||
return label === "" ? '""' : label;
|
||||
}
|
||||
|
||||
export function describeSelector(selector: MCPGatewaySessionSelector): string {
|
||||
if (selector.user_id !== undefined) return `every live session opened by user ${groupLabel(selector.user_id)}`;
|
||||
return `session ${selector.session_id_prefix}`;
|
||||
export function describeSelector(selector: MCPGatewaySessionSelector, t: TFunction): string {
|
||||
if (selector.user_id !== undefined)
|
||||
return t("mcpServers.gatewaySessionsTab.selector.user", { user: groupLabel(selector.user_id, t) });
|
||||
return t("mcpServers.gatewaySessionsTab.selector.session", { sessionId: selector.session_id_prefix });
|
||||
}
|
||||
|
||||
export function describeTerminateResult(result: MCPGatewaySessionsTerminateResponse): string {
|
||||
const noun = result.terminated_sessions === 1 ? "session" : "sessions";
|
||||
return `Disconnected ${result.terminated_sessions} ${noun} on worker pid ${result.worker_pid}.`;
|
||||
export function describeTerminateResult(
|
||||
result: MCPGatewaySessionsTerminateResponse,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string {
|
||||
return t("mcpServers.gatewaySessionsTab.terminateResult", {
|
||||
count: result.terminated_sessions,
|
||||
pid: result.worker_pid,
|
||||
});
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: number }) {
|
||||
|
|
@ -68,16 +78,17 @@ function DisconnectUserButton({
|
|||
userId: string | null;
|
||||
onDisconnectUser: (userId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (userId === null || userId === "") return null;
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDisconnectUser(userId)}
|
||||
aria-label={`Disconnect all sessions for user ${groupLabel(userId)}`}
|
||||
aria-label={t("mcpServers.gatewaySessionsTab.disconnectUser.ariaLabel", { user: groupLabel(userId, t) })}
|
||||
>
|
||||
<Unplug className="size-4" />
|
||||
Disconnect all
|
||||
{t("mcpServers.gatewaySessionsTab.disconnectUser.label")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -93,6 +104,7 @@ function GroupCountTable({
|
|||
labelHeader: string;
|
||||
onDisconnectUser?: (userId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section aria-label={title} className="rounded-lg border border-border bg-card">
|
||||
<h3 className="border-b border-border px-4 py-2 text-sm font-semibold text-foreground">{title}</h3>
|
||||
|
|
@ -100,14 +112,16 @@ function GroupCountTable({
|
|||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{labelHeader}</TableHead>
|
||||
<TableHead className="text-right">Sessions</TableHead>
|
||||
{onDisconnectUser ? <TableHead className="text-right">Actions</TableHead> : null}
|
||||
<TableHead className="text-right">{t("mcpServers.gatewaySessionsTab.columns.sessions")}</TableHead>
|
||||
{onDisconnectUser ? (
|
||||
<TableHead className="text-right">{t("mcpServers.gatewaySessionsTab.columns.actions")}</TableHead>
|
||||
) : null}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups.map((group) => (
|
||||
<TableRow key={group.label ?? "__unknown__"}>
|
||||
<TableCell className="font-mono text-xs">{groupLabel(group.label)}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{groupLabel(group.label, t)}</TableCell>
|
||||
<TableCell className="text-right">{group.count}</TableCell>
|
||||
{onDisconnectUser ? (
|
||||
<TableCell className="text-right">
|
||||
|
|
@ -134,6 +148,7 @@ function SessionsBody({
|
|||
onDisconnect: ((selector: MCPGatewaySessionSelector) => void) | null;
|
||||
}) {
|
||||
const term = useTerminology();
|
||||
const { t } = useTranslation();
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
|
|
@ -141,14 +156,14 @@ function SessionsBody({
|
|||
className="flex items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-card p-12"
|
||||
>
|
||||
<UiLoadingSpinner className="size-6 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Loading live connections...</p>
|
||||
<p className="text-sm text-muted-foreground">{t("mcpServers.gatewaySessionsTab.loading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Could not load live connections</AlertTitle>
|
||||
<AlertTitle>{t("mcpServers.gatewaySessionsTab.loadError.title")}</AlertTitle>
|
||||
<AlertDescription>{error.message}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
|
@ -158,8 +173,8 @@ function SessionsBody({
|
|||
return (
|
||||
<div className="rounded-lg border border-dashed border-border bg-card p-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No live MCP connections on this worker (pid {data.worker_pid}).{" "}
|
||||
{term("Connect an AI client to the gateway to see it here.")}
|
||||
{t("mcpServers.gatewaySessionsTab.empty.noConnections", { pid: data.worker_pid })}{" "}
|
||||
{term(t("mcpServers.gatewaySessionsTab.empty.connectHint"))}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -167,35 +182,44 @@ function SessionsBody({
|
|||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<StatCard label="Live sessions" value={data.total_sessions} />
|
||||
<StatCard label={term("AI clients")} value={data.by_client.length} />
|
||||
<StatCard label="Users" value={data.by_user.length} />
|
||||
<StatCard label={t("mcpServers.gatewaySessionsTab.stats.liveSessions")} value={data.total_sessions} />
|
||||
<StatCard label={term(t("mcpServers.gatewaySessionsTab.stats.aiClients"))} value={data.by_client.length} />
|
||||
<StatCard label={t("mcpServers.gatewaySessionsTab.stats.users")} value={data.by_user.length} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<GroupCountTable title={term("Sessions by AI client")} labelHeader="Client" groups={data.by_client} />
|
||||
<GroupCountTable
|
||||
title="Sessions by user"
|
||||
labelHeader="User"
|
||||
title={term(t("mcpServers.gatewaySessionsTab.groups.byClient"))}
|
||||
labelHeader={t("mcpServers.gatewaySessionsTab.columns.client")}
|
||||
groups={data.by_client}
|
||||
/>
|
||||
<GroupCountTable
|
||||
title={t("mcpServers.gatewaySessionsTab.groups.byUser")}
|
||||
labelHeader={t("mcpServers.gatewaySessionsTab.columns.user")}
|
||||
groups={data.by_user}
|
||||
onDisconnectUser={onDisconnect ? (userId) => onDisconnect({ user_id: userId }) : undefined}
|
||||
/>
|
||||
</div>
|
||||
<section aria-label="Live sessions" className="rounded-lg border border-border bg-card">
|
||||
<section
|
||||
aria-label={t("mcpServers.gatewaySessionsTab.liveSessions.regionLabel")}
|
||||
className="rounded-lg border border-border bg-card"
|
||||
>
|
||||
<h3 className="border-b border-border px-4 py-2 text-sm font-semibold text-foreground">
|
||||
Live sessions (worker pid {data.worker_pid})
|
||||
{t("mcpServers.gatewaySessionsTab.liveSessions.heading", { pid: data.worker_pid })}
|
||||
</h3>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Session</TableHead>
|
||||
<TableHead>Client</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Key alias</TableHead>
|
||||
<TableHead>Team</TableHead>
|
||||
<TableHead>Client IP</TableHead>
|
||||
<TableHead className="text-right">Idle</TableHead>
|
||||
<TableHead className="text-right">In flight</TableHead>
|
||||
{onDisconnect ? <TableHead className="text-right">Actions</TableHead> : null}
|
||||
<TableHead>{t("mcpServers.gatewaySessionsTab.columns.session")}</TableHead>
|
||||
<TableHead>{t("mcpServers.gatewaySessionsTab.columns.client")}</TableHead>
|
||||
<TableHead>{t("mcpServers.gatewaySessionsTab.columns.user")}</TableHead>
|
||||
<TableHead>{t("mcpServers.gatewaySessionsTab.columns.keyAlias")}</TableHead>
|
||||
<TableHead>{t("mcpServers.gatewaySessionsTab.columns.team")}</TableHead>
|
||||
<TableHead>{t("mcpServers.gatewaySessionsTab.columns.clientIp")}</TableHead>
|
||||
<TableHead className="text-right">{t("mcpServers.gatewaySessionsTab.columns.idle")}</TableHead>
|
||||
<TableHead className="text-right">{t("mcpServers.gatewaySessionsTab.columns.inFlight")}</TableHead>
|
||||
{onDisconnect ? (
|
||||
<TableHead className="text-right">{t("mcpServers.gatewaySessionsTab.columns.actions")}</TableHead>
|
||||
) : null}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
@ -204,10 +228,10 @@ function SessionsBody({
|
|||
<TableCell className="font-mono text-xs">{session.session_id_prefix}</TableCell>
|
||||
<TableCell>
|
||||
{session.client_name === null ? (
|
||||
<span className="text-muted-foreground">{UNKNOWN_LABEL}</span>
|
||||
<span className="text-muted-foreground">{t("mcpServers.gatewaySessionsTab.unknown")}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-mono text-xs">{groupLabel(session.client_name)}</span>
|
||||
<span className="font-mono text-xs">{groupLabel(session.client_name, t)}</span>
|
||||
{session.client_version ? (
|
||||
<span className="ml-1 text-xs text-muted-foreground">v{session.client_version}</span>
|
||||
) : null}
|
||||
|
|
@ -216,7 +240,7 @@ function SessionsBody({
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
{session.user_id === null ? (
|
||||
<span className="text-muted-foreground">{UNKNOWN_LABEL}</span>
|
||||
<span className="text-muted-foreground">{t("mcpServers.gatewaySessionsTab.unknown")}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-mono text-xs">{session.user_id}</span>
|
||||
|
|
@ -229,7 +253,7 @@ function SessionsBody({
|
|||
<TableCell className="text-xs">{session.key_alias ?? "-"}</TableCell>
|
||||
<TableCell className="text-xs">{session.team_alias ?? session.team_id ?? "-"}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{session.client_ip || "-"}</TableCell>
|
||||
<TableCell className="text-right text-xs">{formatIdleSeconds(session.idle_seconds)}</TableCell>
|
||||
<TableCell className="text-right text-xs">{formatIdleSeconds(session.idle_seconds, t)}</TableCell>
|
||||
<TableCell className="text-right text-xs">{session.in_flight_requests}</TableCell>
|
||||
{onDisconnect ? (
|
||||
<TableCell className="text-right">
|
||||
|
|
@ -237,10 +261,12 @@ function SessionsBody({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDisconnect({ session_id_prefix: session.session_id_prefix })}
|
||||
aria-label={`Disconnect session ${session.session_id_prefix}`}
|
||||
aria-label={t("mcpServers.gatewaySessionsTab.disconnectSession.ariaLabel", {
|
||||
sessionId: session.session_id_prefix,
|
||||
})}
|
||||
>
|
||||
<Unplug className="size-4" />
|
||||
Disconnect
|
||||
{t("mcpServers.gatewaySessionsTab.disconnectSession.label")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
) : null}
|
||||
|
|
@ -260,6 +286,7 @@ interface MCPGatewaySessionsTabProps {
|
|||
|
||||
export function MCPGatewaySessionsTab({ accessToken, canTerminate }: MCPGatewaySessionsTabProps) {
|
||||
const term = useTerminology();
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [pendingSelector, setPendingSelector] = useState<MCPGatewaySessionSelector | null>(null);
|
||||
const queryOptions = {
|
||||
|
|
@ -283,37 +310,32 @@ export function MCPGatewaySessionsTab({ accessToken, canTerminate }: MCPGatewayS
|
|||
<div className="mt-4 space-y-4" data-testid="mcp-gateway-sessions-tab">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">Live Connections</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{term(
|
||||
"Stateful Streamable HTTP sessions currently open on this proxy worker, grouped by the AI client that sent the MCP initialize request and by the authenticated LiteLLM user. Stateless requests and SSE connections are not counted.",
|
||||
)}
|
||||
</p>
|
||||
<h2 className="text-base font-semibold text-foreground">{t("mcpServers.gatewaySessionsTab.header.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">{term(t("mcpServers.gatewaySessionsTab.header.description"))}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
aria-label="Refresh live connections"
|
||||
aria-label={t("mcpServers.gatewaySessionsTab.refresh.ariaLabel")}
|
||||
>
|
||||
<RefreshCw className={`size-4 ${isFetching ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
{t("mcpServers.gatewaySessionsTab.refresh.label")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{terminate.isError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Could not disconnect</AlertTitle>
|
||||
<AlertTitle>{t("mcpServers.gatewaySessionsTab.terminateError.title")}</AlertTitle>
|
||||
<AlertDescription>{terminate.error.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{terminate.isSuccess ? (
|
||||
<Alert>
|
||||
<AlertTitle>Disconnected</AlertTitle>
|
||||
<AlertTitle>{t("mcpServers.gatewaySessionsTab.terminateSuccess.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{describeTerminateResult(terminate.data)} Clients holding those sessions must send a new initialize request,
|
||||
which re-runs authentication. Sessions on other proxy workers are not affected.
|
||||
{describeTerminateResult(terminate.data, t)} {t("mcpServers.gatewaySessionsTab.terminateSuccess.followUp")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
|
@ -328,18 +350,20 @@ export function MCPGatewaySessionsTab({ accessToken, canTerminate }: MCPGatewayS
|
|||
<AlertDialog open={pendingSelector !== null} onOpenChange={(open) => !open && setPendingSelector(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Disconnect MCP session</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("mcpServers.gatewaySessionsTab.confirm.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingSelector ? `This force-closes ${describeSelector(pendingSelector)} on this proxy worker. ` : ""}
|
||||
In-flight requests fail and the client must initialize again before it can call tools.
|
||||
{pendingSelector
|
||||
? `${t("mcpServers.gatewaySessionsTab.confirm.target", { target: describeSelector(pendingSelector, t) })} `
|
||||
: ""}
|
||||
{t("mcpServers.gatewaySessionsTab.confirm.consequence")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<Button variant="outline" onClick={() => setPendingSelector(null)}>
|
||||
Cancel
|
||||
{t("mcpServers.gatewaySessionsTab.confirm.cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDisconnect} disabled={terminate.isPending}>
|
||||
Disconnect
|
||||
{t("mcpServers.gatewaySessionsTab.confirm.confirm")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Info, Link as LinkIcon } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
|
|
@ -56,6 +57,7 @@ interface MCPLogoSelectorProps {
|
|||
}
|
||||
|
||||
const MCPLogoSelector: React.FC<MCPLogoSelectorProps> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const selectedWellKnown = WELL_KNOWN_LOGOS.find((l) => l.url === value);
|
||||
|
||||
const handleSelect = (url: string) => {
|
||||
|
|
@ -66,14 +68,17 @@ const MCPLogoSelector: React.FC<MCPLogoSelectorProps> = ({ value, onChange }) =>
|
|||
<TooltipProvider>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Logo</span>
|
||||
<span className="text-sm font-medium">{t("mcpServers.logoSelector.label")}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<Info className="size-4 cursor-help text-muted-foreground" aria-label="About the logo" />}
|
||||
render={
|
||||
<Info
|
||||
className="size-4 cursor-help text-muted-foreground"
|
||||
aria-label={t("mcpServers.logoSelector.tooltipLabel")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
Select a well-known logo or paste a URL to any image. The logo is shown on the admin and chat pages.
|
||||
</TooltipContent>
|
||||
<TooltipContent>{t("mcpServers.logoSelector.tooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
|
|
@ -82,7 +87,7 @@ const MCPLogoSelector: React.FC<MCPLogoSelectorProps> = ({ value, onChange }) =>
|
|||
<div className="mb-3 flex items-center gap-3 rounded-lg border border-border bg-muted p-3">
|
||||
<Logo
|
||||
src={selectedWellKnown?.src ?? value}
|
||||
label="Selected"
|
||||
label={t("mcpServers.logoSelector.selectedLabel")}
|
||||
className="h-10 w-10 rounded-sm object-contain"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
|
@ -130,7 +135,7 @@ const MCPLogoSelector: React.FC<MCPLogoSelectorProps> = ({ value, onChange }) =>
|
|||
<LinkIcon className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Or paste a custom logo URL..."
|
||||
placeholder={t("mcpServers.logoSelector.customUrlPlaceholder")}
|
||||
value={value && !selectedWellKnown ? value : ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import React, { useState, useEffect, useId } from "react";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Save, Plus, X } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -108,6 +110,7 @@ interface AllowedClientDialogProps {
|
|||
}
|
||||
|
||||
const AllowedClientDialog: React.FC<AllowedClientDialogProps> = ({ draft, onChange, onCommit, onRemove, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const aliasId = useId();
|
||||
const valueId = useId();
|
||||
if (draft === null) return null;
|
||||
|
|
@ -115,28 +118,29 @@ const AllowedClientDialog: React.FC<AllowedClientDialogProps> = ({ draft, onChan
|
|||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{draft.key === null ? "Add client" : "Edit client"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
The alias is the name shown in the dashboard and gateway logs. The value is the exact JWT claim or header
|
||||
value that identifies the client, such as the OAuth client ID your identity provider issues.
|
||||
</DialogDescription>
|
||||
<DialogTitle>
|
||||
{draft.key === null
|
||||
? t("mcpServers.networkSettings.clientDialog.addTitle")
|
||||
: t("mcpServers.networkSettings.clientDialog.editTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("mcpServers.networkSettings.clientDialog.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={aliasId}>Alias</Label>
|
||||
<Label htmlFor={aliasId}>{t("mcpServers.networkSettings.clientDialog.aliasLabel")}</Label>
|
||||
<Input
|
||||
id={aliasId}
|
||||
value={draft.alias}
|
||||
placeholder="e.g. Coding CLI"
|
||||
placeholder={t("mcpServers.networkSettings.clientDialog.aliasPlaceholder")}
|
||||
onChange={(e) => onChange({ ...draft, alias: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={valueId}>Value</Label>
|
||||
<Label htmlFor={valueId}>{t("mcpServers.networkSettings.clientDialog.valueLabel")}</Label>
|
||||
<Input
|
||||
id={valueId}
|
||||
value={draft.value}
|
||||
placeholder="e.g. 0oa1b2c3d4e5f6g7h8i9"
|
||||
placeholder={t("mcpServers.networkSettings.clientDialog.valuePlaceholder")}
|
||||
className="font-mono"
|
||||
onChange={(e) => onChange({ ...draft, value: e.target.value })}
|
||||
/>
|
||||
|
|
@ -145,14 +149,16 @@ const AllowedClientDialog: React.FC<AllowedClientDialogProps> = ({ draft, onChan
|
|||
<DialogFooter>
|
||||
{draft.key !== null && (
|
||||
<Button type="button" variant="destructive" className="sm:mr-auto" onClick={onRemove}>
|
||||
Remove client
|
||||
{t("mcpServers.networkSettings.clientDialog.remove")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
{t("mcpServers.networkSettings.clientDialog.cancel")}
|
||||
</Button>
|
||||
<Button type="button" disabled={isIncomplete(trimClient(draft))} onClick={onCommit}>
|
||||
{draft.key === null ? "Add" : "Done"}
|
||||
{draft.key === null
|
||||
? t("mcpServers.networkSettings.clientDialog.add")
|
||||
: t("mcpServers.networkSettings.clientDialog.done")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
@ -162,12 +168,16 @@ const AllowedClientDialog: React.FC<AllowedClientDialogProps> = ({ draft, onChan
|
|||
|
||||
type DisableableCapability = "prompts" | "resources";
|
||||
|
||||
const CAPABILITY_TOGGLES: { capability: DisableableCapability; label: string; description: string }[] = [
|
||||
{ capability: "prompts", label: "Prompts", description: "Advertise prompts and answer prompts/list and prompts/get" },
|
||||
const CAPABILITY_TOGGLES: { capability: DisableableCapability; labelKey: ParseKeys; descriptionKey: ParseKeys }[] = [
|
||||
{
|
||||
capability: "prompts",
|
||||
labelKey: "mcpServers.networkSettings.capabilities.prompts.label",
|
||||
descriptionKey: "mcpServers.networkSettings.capabilities.prompts.description",
|
||||
},
|
||||
{
|
||||
capability: "resources",
|
||||
label: "Resources",
|
||||
description: "Advertise resources and answer resources/list, resources/templates/list and resources/read",
|
||||
labelKey: "mcpServers.networkSettings.capabilities.resources.label",
|
||||
descriptionKey: "mcpServers.networkSettings.capabilities.resources.description",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -177,6 +187,7 @@ const parseDisabledCapabilities = (value: unknown): DisableableCapability[] =>
|
|||
: [];
|
||||
|
||||
const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken }) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [privateRanges, setPrivateRanges] = useState<string[]>([]);
|
||||
|
|
@ -303,7 +314,7 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
|
|||
(result): result is PromiseRejectedResult => result.status === "rejected",
|
||||
);
|
||||
if (failures.length === 0) {
|
||||
toast.success("MCP network settings saved");
|
||||
toast.success(t("mcpServers.networkSettings.saved"));
|
||||
return;
|
||||
}
|
||||
failures.forEach((failure) => toast.fromError(failure.reason));
|
||||
|
|
@ -362,10 +373,9 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
|
|||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
<div>
|
||||
<p className="text-lg font-semibold">Private IP Ranges</p>
|
||||
<p className="text-lg font-semibold">{t("mcpServers.networkSettings.privateRanges.title")}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Define which IP ranges are part of your private network. Callers from these IPs can see all MCP servers.
|
||||
Callers from any other IP can only see servers marked "Available on Public Internet".
|
||||
{t("mcpServers.networkSettings.privateRanges.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -373,11 +383,12 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
|
|||
{currentIp && (
|
||||
<div className="mb-4 rounded-lg bg-muted p-3">
|
||||
<p className="text-sm">
|
||||
Your current IP: <span className="font-mono font-medium">{currentIp}</span>
|
||||
{t("mcpServers.networkSettings.privateRanges.currentIp")}{" "}
|
||||
<span className="font-mono font-medium">{currentIp}</span>
|
||||
</p>
|
||||
{suggestedRange && !privateRanges.includes(suggestedRange) && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<p className="text-sm">Suggested range: </p>
|
||||
<p className="text-sm">{t("mcpServers.networkSettings.privateRanges.suggestedRange")} </p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -393,7 +404,7 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
|
|||
)}
|
||||
|
||||
<div className="mb-2 flex items-center">
|
||||
<p className="text-sm font-medium">Your Private Network Ranges</p>
|
||||
<p className="text-sm font-medium">{t("mcpServers.networkSettings.privateRanges.listTitle")}</p>
|
||||
</div>
|
||||
{privateRanges.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
|
|
@ -402,7 +413,7 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
|
|||
{range}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${range}`}
|
||||
aria-label={t("mcpServers.networkSettings.privateRanges.removeAriaLabel", { range })}
|
||||
onClick={() => setPrivateRanges(privateRanges.filter((r) => r !== range))}
|
||||
className="ml-1 cursor-pointer"
|
||||
>
|
||||
|
|
@ -414,7 +425,7 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
|
|||
)}
|
||||
<Input
|
||||
value={rangeDraft}
|
||||
placeholder="Leave empty to use defaults: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8"
|
||||
placeholder={t("mcpServers.networkSettings.privateRanges.placeholder")}
|
||||
onChange={(e) => setRangeDraft(e.target.value)}
|
||||
onBlur={commitDraft}
|
||||
onKeyDown={(e) => {
|
||||
|
|
@ -424,33 +435,24 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
|
|||
}
|
||||
}}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Enter CIDR ranges (e.g., 10.0.0.0/8). When empty, standard private IP ranges are used.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t("mcpServers.networkSettings.privateRanges.hint")}</p>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<p className="text-lg font-semibold">Allowed Clients</p>
|
||||
<p className="text-lg font-semibold">{t("mcpServers.networkSettings.allowedClients.title")}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Only the MCP client applications listed here can use the gateway. Leave empty to allow every client. A client
|
||||
that authenticates with a JWT is identified by the claim named in litellm_jwtauth.mcp_client_id_jwt_field in
|
||||
your proxy config (for example azp or client_id), which your identity provider asserts and the client cannot
|
||||
change. Any other client is identified by the request header configured below, if you enable one.
|
||||
{t("mcpServers.networkSettings.allowedClients.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
{storedAllowlistIsMalformed && (
|
||||
<p className="mb-2 text-sm text-destructive">
|
||||
The stored allowlist is not a list of alias and value pairs, so every client is denied. Add the clients you
|
||||
want and save to replace it, or save with the list empty to remove it and allow every client again.
|
||||
{t("mcpServers.networkSettings.allowedClients.malformedWarning")}
|
||||
</p>
|
||||
)}
|
||||
{storedAllowlistIsEmpty && (
|
||||
<p className="mb-2 text-sm text-destructive">
|
||||
An empty allowlist is currently stored, so every client is denied. Save with the list empty to remove it and
|
||||
allow every client again.
|
||||
</p>
|
||||
<p className="mb-2 text-sm text-destructive">{t("mcpServers.networkSettings.allowedClients.emptyWarning")}</p>
|
||||
)}
|
||||
{allowedClients.length > 0 && (
|
||||
<div className="mb-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
|
@ -474,46 +476,35 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
|
|||
onClick={() => setClientDraft({ key: null, alias: "", value: "" })}
|
||||
>
|
||||
<Plus />
|
||||
Add client
|
||||
{t("mcpServers.networkSettings.allowedClients.add")}
|
||||
</Button>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Click a client to edit or remove it. Leave the list empty to allow every client. Every MCP request from an
|
||||
unlisted client, or from one with no resolvable identity, gets a 403.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t("mcpServers.networkSettings.allowedClients.hint")}</p>
|
||||
|
||||
<div className="mt-6 mb-2 flex items-center">
|
||||
<p className="text-sm font-medium">Client Identity Header (less secure)</p>
|
||||
<p className="text-sm font-medium">{t("mcpServers.networkSettings.clientIdHeader.title")}</p>
|
||||
</div>
|
||||
<Input
|
||||
aria-label="Client identity header"
|
||||
aria-label={t("mcpServers.networkSettings.clientIdHeader.ariaLabel")}
|
||||
value={clientIdHeader}
|
||||
placeholder="Leave empty to identify clients by JWT only, e.g. x-mcp-client"
|
||||
placeholder={t("mcpServers.networkSettings.clientIdHeader.placeholder")}
|
||||
onChange={(e) => setClientIdHeader(e.target.value)}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Optional header whose value names the client for callers without a JWT identity. Clients pick this value
|
||||
themselves, so it is a policy control rather than a security boundary. Without it, callers that do not carry
|
||||
the JWT claim are rejected while the allowlist is set.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t("mcpServers.networkSettings.clientIdHeader.hint")}</p>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<p className="text-lg font-semibold">Served Capabilities</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Turn off prompts or resources when none of your MCP servers offer them. A capability that is turned off is
|
||||
left out of the initialize response, its list calls return an empty list without contacting any upstream
|
||||
server, and its get or read calls are rejected.
|
||||
</p>
|
||||
<p className="text-lg font-semibold">{t("mcpServers.networkSettings.capabilities.title")}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("mcpServers.networkSettings.capabilities.description")}</p>
|
||||
</div>
|
||||
|
||||
<Card className="space-y-4 p-6">
|
||||
{CAPABILITY_TOGGLES.map(({ capability, label, description }) => (
|
||||
{CAPABILITY_TOGGLES.map(({ capability, labelKey, descriptionKey }) => (
|
||||
<div key={capability} className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<Label htmlFor={`${capabilityToggleId}-${capability}`} className="text-sm font-medium">
|
||||
{label}
|
||||
{t(labelKey)}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
<p className="text-xs text-muted-foreground">{t(descriptionKey)}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={`${capabilityToggleId}-${capability}`}
|
||||
|
|
@ -528,7 +519,7 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
|
|||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
<Save />
|
||||
Save
|
||||
{t("mcpServers.networkSettings.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { ChevronRight, CircleMinus, Info, Plus, TriangleAlert, X } from "lucide-react";
|
||||
|
|
@ -53,6 +54,7 @@ const ClearableInput: React.FC<{
|
|||
};
|
||||
|
||||
const StaticHeadersFieldArray: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<MountedFormValues>();
|
||||
const { fields, append, remove } = useFieldArray({ control: listControl(control), name: "static_headers" });
|
||||
useMountedName("static_headers");
|
||||
|
|
@ -64,23 +66,35 @@ const StaticHeadersFieldArray: React.FC = () => {
|
|||
<MountedFormField
|
||||
name={["static_headers", String(index), "header"]}
|
||||
className="flex-1"
|
||||
rules={{ validate: { required: requiredRule("Header name is required") } }}
|
||||
rules={{
|
||||
validate: {
|
||||
required: requiredRule(t("mcpServers.permissionManagement.staticHeaders.headerNameRequired")),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(headerControl) => (
|
||||
<ClearableInput
|
||||
control={headerControl}
|
||||
placeholder="Header name (e.g., X-API-Key)"
|
||||
clearLabel="Clear header name"
|
||||
placeholder={t("mcpServers.permissionManagement.staticHeaders.headerNamePlaceholder")}
|
||||
clearLabel={t("mcpServers.permissionManagement.staticHeaders.clearHeaderName")}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
name={["static_headers", String(index), "value"]}
|
||||
className="flex-1"
|
||||
rules={{ validate: { required: requiredRule("Header value is required") } }}
|
||||
rules={{
|
||||
validate: {
|
||||
required: requiredRule(t("mcpServers.permissionManagement.staticHeaders.headerValueRequired")),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(valueControl) => (
|
||||
<ClearableInput control={valueControl} placeholder="Header value" clearLabel="Clear header value" />
|
||||
<ClearableInput
|
||||
control={valueControl}
|
||||
placeholder={t("mcpServers.permissionManagement.staticHeaders.headerValuePlaceholder")}
|
||||
clearLabel={t("mcpServers.permissionManagement.staticHeaders.clearHeaderValue")}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<CircleMinus
|
||||
|
|
@ -91,7 +105,7 @@ const StaticHeadersFieldArray: React.FC = () => {
|
|||
))}
|
||||
<Button variant="outline" className="w-full border-dashed" onClick={() => append({})}>
|
||||
<Plus />
|
||||
Add Static Header
|
||||
{t("mcpServers.permissionManagement.staticHeaders.add")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -102,6 +116,7 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
mcpServer,
|
||||
mountedAuthType,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { setValue } = useFormContext<MountedFormValues>();
|
||||
const isOAuth2 = mountedAuthType === AUTH_TYPE.OAUTH2;
|
||||
const isNoneAuth = mountedAuthType === AUTH_TYPE.NONE || mountedAuthType == null;
|
||||
|
|
@ -186,11 +201,9 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
<span className="flex items-center">
|
||||
<span className="flex items-center space-x-2">
|
||||
<span className="w-2 h-2 bg-info rounded-full"></span>
|
||||
<span className="text-lg font-semibold text-foreground">Permission Management / Access Control</span>
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground ml-4">
|
||||
Configure access permissions and security settings (Optional)
|
||||
<span className="text-lg font-semibold text-foreground">{t("mcpServers.permissionManagement.title")}</span>
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground ml-4">{t("mcpServers.permissionManagement.subtitle")}</span>
|
||||
</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-data-panel-open:rotate-90" />
|
||||
</CollapsibleTrigger>
|
||||
|
|
@ -199,34 +212,44 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
Allow All LiteLLM Keys
|
||||
<SimpleTooltip content="When enabled, every API key can access this MCP server.">
|
||||
{t("mcpServers.permissionManagement.allowAllKeys.label")}
|
||||
<SimpleTooltip content={t("mcpServers.permissionManagement.allowAllKeys.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Enable if this server should be "public" to all keys.
|
||||
{t("mcpServers.permissionManagement.allowAllKeys.description")}
|
||||
</p>
|
||||
</div>
|
||||
<MountedFormField name="allow_all_keys" defaultValue={mcpServer?.allow_all_keys ?? false} className="mb-0">
|
||||
{(control) => <Switch aria-label="Allow All LiteLLM Keys" {...switchControl(control)} />}
|
||||
{(control) => (
|
||||
<Switch
|
||||
aria-label={t("mcpServers.permissionManagement.allowAllKeys.label")}
|
||||
{...switchControl(control)}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
Internal network only
|
||||
<SimpleTooltip content="When on, requests must come from your internal network unless the server ID is explicitly published in public_mcp_servers. Turn off to allow public IPs. Authentication and access permissions still apply.">
|
||||
{t("mcpServers.permissionManagement.internalNetworkOnly.label")}
|
||||
<SimpleTooltip content={t("mcpServers.permissionManagement.internalNetworkOnly.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Turn on to restrict public IPs. Explicitly published server IDs remain accessible from public IPs.
|
||||
{t("mcpServers.permissionManagement.internalNetworkOnly.description")}
|
||||
</p>
|
||||
</div>
|
||||
<MountedFormField name="available_on_public_internet" defaultValue={true} className="mb-0">
|
||||
{(control) => <Switch aria-label="Internal network only" {...invertedSwitchControl(control)} />}
|
||||
{(control) => (
|
||||
<Switch
|
||||
aria-label={t("mcpServers.permissionManagement.internalNetworkOnly.label")}
|
||||
{...invertedSwitchControl(control)}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</div>
|
||||
|
||||
|
|
@ -234,13 +257,13 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
Delegate auth to upstream (PKCE passthrough)
|
||||
<SimpleTooltip content="When on, LiteLLM skips its own API key/SSO check for this server and lets the client complete PKCE directly with the upstream MCP server. Only honored when Auth Type is oauth2. No spend tracking or per-key rate limiting will run on this route.">
|
||||
{t("mcpServers.permissionManagement.delegateAuth.label")}
|
||||
<SimpleTooltip content={t("mcpServers.permissionManagement.delegateAuth.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Bypass LiteLLM auth so clients authenticate directly with the upstream OAuth MCP server.
|
||||
{t("mcpServers.permissionManagement.delegateAuth.description")}
|
||||
</p>
|
||||
</div>
|
||||
<MountedFormField
|
||||
|
|
@ -249,7 +272,10 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
className="mb-0"
|
||||
>
|
||||
{(control) => (
|
||||
<Switch aria-label="Delegate auth to upstream (PKCE passthrough)" {...switchControl(control)} />
|
||||
<Switch
|
||||
aria-label={t("mcpServers.permissionManagement.delegateAuth.label")}
|
||||
{...switchControl(control)}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</div>
|
||||
|
|
@ -259,14 +285,13 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
OAuth pass-through
|
||||
<SimpleTooltip content="When on, this server is treated as an OAuth pass-through: the gateway proxies the upstream /.well-known/oauth-protected-resource metadata, emits spec-compliant 401 challenges when no bearer is supplied, and propagates upstream 401/403 responses. Only honored when Auth Type is None and 'Authorization' is in Extra Headers.">
|
||||
{t("mcpServers.permissionManagement.oauthPassthrough.label")}
|
||||
<SimpleTooltip content={t("mcpServers.permissionManagement.oauthPassthrough.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Forward upstream OAuth discovery and 401 challenges so clients negotiate OAuth directly with the
|
||||
upstream MCP server.
|
||||
{t("mcpServers.permissionManagement.oauthPassthrough.description")}
|
||||
</p>
|
||||
</div>
|
||||
<MountedFormField
|
||||
|
|
@ -274,7 +299,12 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
defaultValue={mcpServer?.oauth_passthrough ?? false}
|
||||
className="mb-0"
|
||||
>
|
||||
{(control) => <Switch aria-label="OAuth pass-through" {...switchControl(control)} />}
|
||||
{(control) => (
|
||||
<Switch
|
||||
aria-label={t("mcpServers.permissionManagement.oauthPassthrough.label")}
|
||||
{...switchControl(control)}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -282,11 +312,9 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
{showInternalDelegatePkceWarning && (
|
||||
<Alert variant="warning" className="mb-2">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>Internal server with upstream OAuth delegation</AlertTitle>
|
||||
<AlertTitle>{t("mcpServers.permissionManagement.internalDelegateWarning.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
This MCP server is configured as internal-only but delegates auth to upstream. Anonymous users will be
|
||||
able to reach the upstream OAuth2 /authorize flow without a LiteLLM session. Ensure your upstream
|
||||
provider and network enforce access controls.
|
||||
{t("mcpServers.permissionManagement.internalDelegateWarning.description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
|
@ -294,8 +322,8 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
MCP Access Groups
|
||||
<SimpleTooltip content="Specify access groups for this MCP server. Users must be in at least one of these groups to access the server.">
|
||||
{t("mcpServers.permissionManagement.accessGroups.label")}
|
||||
<SimpleTooltip content={t("mcpServers.permissionManagement.accessGroups.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
|
|
@ -305,9 +333,9 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
>
|
||||
{(control) => (
|
||||
<MultiSelect
|
||||
{...tagsControl(control)}
|
||||
{...tagsControl(control, t)}
|
||||
options={availableAccessGroups.map((group) => ({ label: group, value: group }))}
|
||||
placeholder="Select existing groups or type to create new ones"
|
||||
placeholder={t("mcpServers.permissionManagement.accessGroups.placeholder")}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -316,13 +344,15 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
Extra Headers
|
||||
<SimpleTooltip content="Forward custom headers from incoming requests to this MCP server (e.g., Authorization, X-Custom-Header, User-Agent)">
|
||||
{t("mcpServers.permissionManagement.extraHeaders.label")}
|
||||
<SimpleTooltip content={t("mcpServers.permissionManagement.extraHeaders.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
{mcpServer?.extra_headers && mcpServer.extra_headers.length > 0 && (
|
||||
<span className="ml-2 text-xs bg-info/15 text-info px-2 py-1 rounded-full">
|
||||
{mcpServer.extra_headers.length} configured
|
||||
{t("mcpServers.permissionManagement.extraHeaders.configuredCount", {
|
||||
count: mcpServer.extra_headers.length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
|
@ -331,11 +361,13 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
>
|
||||
{(control) => (
|
||||
<MultiSelect
|
||||
{...tagsControl(control)}
|
||||
{...tagsControl(control, t)}
|
||||
placeholder={
|
||||
mcpServer?.extra_headers && mcpServer.extra_headers.length > 0
|
||||
? `Currently: ${mcpServer.extra_headers.join(", ")}`
|
||||
: "Enter header names (e.g., Authorization, X-Custom-Header)"
|
||||
? t("mcpServers.permissionManagement.extraHeaders.currentlyPlaceholder", {
|
||||
headers: mcpServer.extra_headers.join(", "),
|
||||
})
|
||||
: t("mcpServers.permissionManagement.extraHeaders.placeholder")
|
||||
}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
|
|
@ -345,8 +377,8 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
<Field>
|
||||
<FieldLabel>
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
Static Headers
|
||||
<SimpleTooltip content="Send these key-value headers with every request to this MCP server.">
|
||||
{t("mcpServers.permissionManagement.staticHeaders.label")}
|
||||
<SimpleTooltip content={t("mcpServers.permissionManagement.staticHeaders.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { type FC, type KeyboardEvent, type MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, CircleAlert, Ellipsis, Trash2, Zap } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -14,7 +15,7 @@ import { cn } from "@/lib/cva.config";
|
|||
import { AUTH_TYPE, MCP_REACHABLE_DESCRIPTION, type MCPServer } from "@/components/mcp_tools/types";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import { getMaskedAndFullUrl, getMCPNetworkAccess } from "./utils";
|
||||
import { STDIO_DISABLED_MESSAGE } from "./StdioAvailability";
|
||||
import { STDIO_DISABLED_MESSAGE_KEY } from "./StdioAvailability";
|
||||
|
||||
interface MCPServerCardProps {
|
||||
server: MCPServer;
|
||||
|
|
@ -56,6 +57,7 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
onDelete,
|
||||
stdioEnabled = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const alias = server.alias || server.server_name || "";
|
||||
const name = server.server_name || alias || server.server_id;
|
||||
// Logo is sourced exclusively from the admin-set `mcp_info.logo_url`.
|
||||
|
|
@ -73,7 +75,7 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
server.auth_type === AUTH_TYPE.OAUTH2 && !server.oauth2_flow && !server.delegate_auth_to_upstream;
|
||||
const status = server.status || "unknown";
|
||||
const healthTone = HEALTH_TONE[status] ?? HEALTH_TONE.unknown;
|
||||
const networkAccess = getMCPNetworkAccess(server);
|
||||
const networkAccess = getMCPNetworkAccess(server, t);
|
||||
const accessGroups = (server.mcp_access_groups ?? []).filter((g): g is string => typeof g === "string");
|
||||
|
||||
const missing = missingUserFields ?? [];
|
||||
|
|
@ -162,7 +164,7 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
type="button"
|
||||
onClick={stop}
|
||||
onKeyDown={stop}
|
||||
aria-label="Server actions"
|
||||
aria-label={t("mcpServers.serverCard.actionsMenu.label")}
|
||||
className="-mr-1 -mt-1 inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Ellipsis className="size-5" />
|
||||
|
|
@ -179,7 +181,7 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
}}
|
||||
>
|
||||
<Zap />
|
||||
Test Connection
|
||||
{t("mcpServers.serverCard.actionsMenu.testConnection")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onRecheckHealth && onDelete && <DropdownMenuSeparator />}
|
||||
|
|
@ -192,7 +194,7 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
{t("mcpServers.serverCard.actionsMenu.delete")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
|
@ -229,11 +231,11 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
render={
|
||||
<Badge variant="outline">
|
||||
<CircleAlert />
|
||||
stdio disabled
|
||||
{t("mcpServers.serverCard.stdioDisabled")}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{STDIO_DISABLED_MESSAGE}</TooltipContent>
|
||||
<TooltipContent>{t(STDIO_DISABLED_MESSAGE_KEY)}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{oauthFlowUnset && (
|
||||
|
|
@ -242,14 +244,11 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
render={
|
||||
<Badge variant="outline">
|
||||
<CircleAlert />
|
||||
OAuth flow not set
|
||||
{t("mcpServers.serverCard.oauthFlowUnset.badge")}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
This OAuth server has no flow set (Machine-to-Machine vs Interactive). Open it and choose an OAuth Flow
|
||||
Type so LiteLLM authenticates it as you intend.
|
||||
</TooltipContent>
|
||||
<TooltipContent>{t("mcpServers.serverCard.oauthFlowUnset.tooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
|
|
@ -294,13 +293,12 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
render={
|
||||
<span className="inline-flex items-center gap-1 font-semibold text-destructive">
|
||||
<CircleAlert className="size-3.5" />
|
||||
{missing.length} user field
|
||||
{missing.length === 1 ? "" : "s"} missing
|
||||
{t("mcpServers.serverCard.missingFields.summary", { count: missing.length })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<div className="mb-1 font-semibold">Missing user fields:</div>
|
||||
<div className="mb-1 font-semibold">{t("mcpServers.serverCard.missingFields.tooltipTitle")}</div>
|
||||
<ul className="ml-3">
|
||||
{missing.map((m) => (
|
||||
<li key={m}>• {m}</li>
|
||||
|
|
@ -317,7 +315,7 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
onOpenFillFields();
|
||||
}}
|
||||
>
|
||||
Set
|
||||
{t("mcpServers.serverCard.missingFields.set")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -348,11 +346,12 @@ const HealthChip: FC<HealthChipProps> = ({
|
|||
error,
|
||||
dotClass,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
if (isLoadingHealth || isRechecking) {
|
||||
return (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground" />
|
||||
Checking
|
||||
{t("mcpServers.serverCard.healthChip.checking")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
|
@ -379,44 +378,51 @@ const HealthChip: FC<HealthChipProps> = ({
|
|||
}
|
||||
/>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<div className="mb-1 font-semibold">Health: {status}</div>
|
||||
<div className="mb-1 font-semibold">{t("mcpServers.serverCard.healthChip.health", { status })}</div>
|
||||
{status === "reachable" && <div className="mb-1 text-xs">{MCP_REACHABLE_DESCRIPTION}</div>}
|
||||
{lastCheck && <div className="mb-1 text-xs">Last check: {new Date(lastCheck).toLocaleString()}</div>}
|
||||
{lastCheck && (
|
||||
<div className="mb-1 text-xs">
|
||||
{t("mcpServers.serverCard.healthChip.lastCheck", { time: new Date(lastCheck).toLocaleString() })}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-xs">
|
||||
<div className="mb-1 font-medium">Error</div>
|
||||
<div className="mb-1 font-medium">{t("mcpServers.serverCard.healthChip.error")}</div>
|
||||
<div className="wrap-break-word">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
{!hasHealthData && <div className="text-xs">No health data</div>}
|
||||
{onRecheck && <div className="mt-1 text-xs">Click to recheck</div>}
|
||||
{!hasHealthData && <div className="text-xs">{t("mcpServers.serverCard.healthChip.noData")}</div>}
|
||||
{onRecheck && <div className="mt-1 text-xs">{t("mcpServers.serverCard.healthChip.clickToRecheck")}</div>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const UserFieldsRow: FC<{ onUpdate?: () => void }> = ({ onUpdate }) => (
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground">Per-user credentials</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
<Check /> Set
|
||||
</Badge>
|
||||
{onUpdate && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
stop(e);
|
||||
onUpdate();
|
||||
}}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
const UserFieldsRow: FC<{ onUpdate?: () => void }> = ({ onUpdate }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t("mcpServers.serverCard.userFieldsRow.label")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
<Check /> {t("mcpServers.serverCard.userFieldsRow.set")}
|
||||
</Badge>
|
||||
{onUpdate && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
stop(e);
|
||||
onUpdate();
|
||||
}}
|
||||
>
|
||||
{t("mcpServers.serverCard.userFieldsRow.update")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
interface ByokRowProps {
|
||||
connected: boolean;
|
||||
|
|
@ -424,13 +430,14 @@ interface ByokRowProps {
|
|||
}
|
||||
|
||||
const ByokRow: FC<ByokRowProps> = ({ connected, onConnect }) => {
|
||||
const { t } = useTranslation();
|
||||
if (connected) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground">BYOK credential</span>
|
||||
<span className="text-muted-foreground">{t("mcpServers.serverCard.byokRow.label")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
<Check /> Connected
|
||||
<Check /> {t("mcpServers.serverCard.byokRow.connected")}
|
||||
</Badge>
|
||||
{onConnect && (
|
||||
<Button
|
||||
|
|
@ -441,7 +448,7 @@ const ByokRow: FC<ByokRowProps> = ({ connected, onConnect }) => {
|
|||
onConnect();
|
||||
}}
|
||||
>
|
||||
Update
|
||||
{t("mcpServers.serverCard.byokRow.update")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -450,7 +457,7 @@ const ByokRow: FC<ByokRowProps> = ({ connected, onConnect }) => {
|
|||
}
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground">BYOK credential</span>
|
||||
<span className="text-muted-foreground">{t("mcpServers.serverCard.byokRow.label")}</span>
|
||||
{onConnect ? (
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -459,7 +466,7 @@ const ByokRow: FC<ByokRowProps> = ({ connected, onConnect }) => {
|
|||
onConnect();
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
{t("mcpServers.serverCard.byokRow.connect")}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { RefreshCw, ShieldOff } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
|
|
@ -22,8 +24,11 @@ import { createQueryKeys } from "@/app/(dashboard)/hooks/common/queryKeysFactory
|
|||
|
||||
const mcpServerUserCredentialKeys = createQueryKeys("mcpServerUserCredentials");
|
||||
|
||||
export function credentialTypeLabel(credentialType: MCPServerUserCredentialListItem["credential_type"]): string {
|
||||
return credentialType === "oauth2" ? "OAuth2" : "BYOK API key";
|
||||
export function credentialTypeLabel(
|
||||
credentialType: MCPServerUserCredentialListItem["credential_type"],
|
||||
t: TFunction,
|
||||
): string {
|
||||
return credentialType === "oauth2" ? "OAuth2" : t("mcpServers.serverUserCredentialsPanel.credentialTypes.byokApiKey");
|
||||
}
|
||||
|
||||
export function formatTimestamp(value: string | null): string {
|
||||
|
|
@ -43,6 +48,7 @@ function CredentialsBody({
|
|||
isLoading: boolean;
|
||||
onRevoke: ((item: MCPServerUserCredentialListItem) => void) | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
|
|
@ -50,14 +56,14 @@ function CredentialsBody({
|
|||
className="flex items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-card p-12"
|
||||
>
|
||||
<UiLoadingSpinner className="size-6 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Loading user credentials...</p>
|
||||
<p className="text-sm text-muted-foreground">{t("mcpServers.serverUserCredentialsPanel.loading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Could not load user credentials</AlertTitle>
|
||||
<AlertTitle>{t("mcpServers.serverUserCredentialsPanel.loadError")}</AlertTitle>
|
||||
<AlertDescription>{error.message}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
|
@ -66,21 +72,26 @@ function CredentialsBody({
|
|||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border bg-card p-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">No user has a stored credential for this server.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("mcpServers.serverUserCredentialsPanel.empty")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<section aria-label="Stored user credentials" className="rounded-lg border border-border bg-card">
|
||||
<section
|
||||
aria-label={t("mcpServers.serverUserCredentialsPanel.tableLabel")}
|
||||
className="rounded-lg border border-border bg-card"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Connected</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>Updated</TableHead>
|
||||
{onRevoke ? <TableHead className="text-right">Actions</TableHead> : null}
|
||||
<TableHead>{t("mcpServers.serverUserCredentialsPanel.columns.user")}</TableHead>
|
||||
<TableHead>{t("mcpServers.serverUserCredentialsPanel.columns.type")}</TableHead>
|
||||
<TableHead>{t("mcpServers.serverUserCredentialsPanel.columns.connected")}</TableHead>
|
||||
<TableHead>{t("mcpServers.serverUserCredentialsPanel.columns.expires")}</TableHead>
|
||||
<TableHead>{t("mcpServers.serverUserCredentialsPanel.columns.updated")}</TableHead>
|
||||
{onRevoke ? (
|
||||
<TableHead className="text-right">{t("mcpServers.serverUserCredentialsPanel.columns.actions")}</TableHead>
|
||||
) : null}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
@ -88,7 +99,7 @@ function CredentialsBody({
|
|||
<TableRow key={item.user_id}>
|
||||
<TableCell className="font-mono text-xs">{item.user_id}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{credentialTypeLabel(item.credential_type)}</Badge>
|
||||
<Badge variant="secondary">{credentialTypeLabel(item.credential_type, t)}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{formatTimestamp(item.connected_at)}</TableCell>
|
||||
<TableCell className="text-xs">{formatTimestamp(item.expires_at)}</TableCell>
|
||||
|
|
@ -99,10 +110,10 @@ function CredentialsBody({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onRevoke(item)}
|
||||
aria-label={`Revoke credential for user ${item.user_id}`}
|
||||
aria-label={t("mcpServers.serverUserCredentialsPanel.revokeAriaLabel", { user: item.user_id })}
|
||||
>
|
||||
<ShieldOff className="size-4" />
|
||||
Revoke
|
||||
{t("mcpServers.serverUserCredentialsPanel.revoke")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
) : null}
|
||||
|
|
@ -125,6 +136,7 @@ export function MCPServerUserCredentialsPanel({
|
|||
accessToken,
|
||||
canRevoke,
|
||||
}: MCPServerUserCredentialsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [pendingItem, setPendingItem] = useState<MCPServerUserCredentialListItem | null>(null);
|
||||
const queryKey = mcpServerUserCredentialKeys.detail(serverId);
|
||||
|
|
@ -147,37 +159,35 @@ export function MCPServerUserCredentialsPanel({
|
|||
<div className="space-y-4" data-testid="mcp-server-user-credentials-panel">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium">User Credentials</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Per-user OAuth2 tokens and BYOK API keys stored for this server. Revoking one deletes it from the database
|
||||
and clears the cached copy, so the user must connect again before the gateway will call this server for
|
||||
them.
|
||||
</p>
|
||||
<h2 className="text-lg font-medium">{t("mcpServers.serverUserCredentialsPanel.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t("mcpServers.serverUserCredentialsPanel.description")}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
aria-label="Refresh user credentials"
|
||||
aria-label={t("mcpServers.serverUserCredentialsPanel.refreshAriaLabel")}
|
||||
>
|
||||
<RefreshCw className={`size-4 ${isFetching ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
{t("mcpServers.serverUserCredentialsPanel.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{revoke.isError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Could not revoke credential</AlertTitle>
|
||||
<AlertTitle>{t("mcpServers.serverUserCredentialsPanel.revokeError")}</AlertTitle>
|
||||
<AlertDescription>{revoke.error.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{revoke.isSuccess ? (
|
||||
<Alert>
|
||||
<AlertTitle>Credential revoked</AlertTitle>
|
||||
<AlertTitle>{t("mcpServers.serverUserCredentialsPanel.revokeSuccess.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
The stored {credentialTypeLabel(revoke.variables.credential_type)} credential for user{" "}
|
||||
{revoke.variables.user_id} was deleted.
|
||||
{t("mcpServers.serverUserCredentialsPanel.revokeSuccess.description", {
|
||||
type: credentialTypeLabel(revoke.variables.credential_type, t),
|
||||
user: revoke.variables.user_id,
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
|
@ -187,20 +197,23 @@ export function MCPServerUserCredentialsPanel({
|
|||
<AlertDialog open={pendingItem !== null} onOpenChange={(open) => !open && setPendingItem(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revoke stored credential</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("mcpServers.serverUserCredentialsPanel.revokeDialog.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingItem
|
||||
? `This deletes the ${credentialTypeLabel(pendingItem.credential_type)} credential stored for user ${pendingItem.user_id}. `
|
||||
? `${t("mcpServers.serverUserCredentialsPanel.revokeDialog.target", {
|
||||
type: credentialTypeLabel(pendingItem.credential_type, t),
|
||||
user: pendingItem.user_id,
|
||||
})} `
|
||||
: ""}
|
||||
Their next MCP request to this server fails until they connect again.
|
||||
{t("mcpServers.serverUserCredentialsPanel.revokeDialog.consequence")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<Button variant="outline" onClick={() => setPendingItem(null)}>
|
||||
Cancel
|
||||
{t("mcpServers.serverUserCredentialsPanel.revokeDialog.cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmRevoke} disabled={revoke.isPending}>
|
||||
Revoke
|
||||
{t("mcpServers.serverUserCredentialsPanel.revokeDialog.confirm")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { MCPServer } from "@/components/mcp_tools/types";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
export interface RequiredFieldDef {
|
||||
key: string;
|
||||
|
|
@ -16,51 +17,79 @@ export interface FieldGroup {
|
|||
|
||||
export const FIELD_GROUPS: FieldGroup[] = [
|
||||
{
|
||||
label: "Documentation",
|
||||
get label() {
|
||||
return i18n.t("mcpServers.standardsSettings.groups.documentation");
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
key: "description",
|
||||
label: "Description",
|
||||
description: "Must have a non-empty description",
|
||||
get label() {
|
||||
return i18n.t("mcpServers.standardsSettings.fields.description.label");
|
||||
},
|
||||
get description() {
|
||||
return i18n.t("mcpServers.standardsSettings.fields.description.description");
|
||||
},
|
||||
check: (s) => !!s.description?.trim(),
|
||||
},
|
||||
{
|
||||
key: "alias",
|
||||
label: "Alias",
|
||||
description: "Must have a display alias",
|
||||
get label() {
|
||||
return i18n.t("mcpServers.standardsSettings.fields.alias.label");
|
||||
},
|
||||
get description() {
|
||||
return i18n.t("mcpServers.standardsSettings.fields.alias.description");
|
||||
},
|
||||
check: (s) => !!s.alias?.trim(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Source",
|
||||
get label() {
|
||||
return i18n.t("mcpServers.standardsSettings.groups.source");
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
key: "source_url",
|
||||
label: "GitHub / Source URL",
|
||||
description: "Must link to a source repository",
|
||||
get label() {
|
||||
return i18n.t("mcpServers.standardsSettings.fields.sourceUrl.label");
|
||||
},
|
||||
get description() {
|
||||
return i18n.t("mcpServers.standardsSettings.fields.sourceUrl.description");
|
||||
},
|
||||
check: (s) => !!s.source_url?.trim(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Connection",
|
||||
get label() {
|
||||
return i18n.t("mcpServers.standardsSettings.groups.connection");
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
key: "url",
|
||||
label: "Server URL",
|
||||
description: "Must have a URL configured",
|
||||
get label() {
|
||||
return i18n.t("mcpServers.standardsSettings.fields.url.label");
|
||||
},
|
||||
get description() {
|
||||
return i18n.t("mcpServers.standardsSettings.fields.url.description");
|
||||
},
|
||||
check: (s) => !!s.url?.trim(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Security",
|
||||
get label() {
|
||||
return i18n.t("mcpServers.standardsSettings.groups.security");
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
key: "auth_type",
|
||||
label: "Auth configured",
|
||||
description: "Must use authentication (not 'none')",
|
||||
get label() {
|
||||
return i18n.t("mcpServers.standardsSettings.fields.authType.label");
|
||||
},
|
||||
get description() {
|
||||
return i18n.t("mcpServers.standardsSettings.fields.authType.description");
|
||||
},
|
||||
check: (s) => !!s.auth_type && s.auth_type !== "none",
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import {
|
||||
SearchIcon,
|
||||
CheckIcon,
|
||||
|
|
@ -25,21 +27,21 @@ import { toast } from "@/lib/toast";
|
|||
|
||||
type MCPStatus = "active" | "pending_review" | "rejected";
|
||||
|
||||
const STATUS_CONFIG: Record<MCPStatus, { label: string; bg: string; text: string; dot: string }> = {
|
||||
const STATUS_CONFIG: Record<MCPStatus, { labelKey: ParseKeys; bg: string; text: string; dot: string }> = {
|
||||
active: {
|
||||
label: "Active",
|
||||
labelKey: "mcpServers.submissionsTab.status.active",
|
||||
bg: "bg-success/10",
|
||||
text: "text-success",
|
||||
dot: "bg-success",
|
||||
},
|
||||
pending_review: {
|
||||
label: "Pending Review",
|
||||
labelKey: "mcpServers.submissionsTab.status.pendingReview",
|
||||
bg: "bg-warning/10",
|
||||
text: "text-warning",
|
||||
dot: "bg-warning",
|
||||
},
|
||||
rejected: {
|
||||
label: "Rejected",
|
||||
labelKey: "mcpServers.submissionsTab.status.rejected",
|
||||
bg: "bg-destructive/10",
|
||||
text: "text-destructive",
|
||||
dot: "bg-destructive",
|
||||
|
|
@ -82,11 +84,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("mcpServers.submissionsTab.confirmDialog.rejectBodyLive")
|
||||
: t("mcpServers.submissionsTab.confirmDialog.rejectBody");
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-overlay">
|
||||
<div className="bg-card rounded-xl shadow-xl p-6 max-w-sm w-full mx-4">
|
||||
|
|
@ -102,18 +105,24 @@ function ConfirmDialog({ action, serverName, isCurrentlyActive, onConfirm, onCan
|
|||
)}
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground mb-1">
|
||||
{isApprove ? "Approve MCP Server" : "Reject MCP Server"}
|
||||
{isApprove
|
||||
? t("mcpServers.submissionsTab.confirmDialog.approveTitle")
|
||||
: t("mcpServers.submissionsTab.confirmDialog.rejectTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Are you sure you want to {action}{" "}
|
||||
<span className="font-medium text-foreground">"{serverName}"</span>?{" "}
|
||||
{isApprove
|
||||
? "This will activate the server. The submitting user will see it in their MCP Servers list once approved."
|
||||
: rejectBody}
|
||||
<Trans
|
||||
i18nKey={
|
||||
isApprove
|
||||
? "mcpServers.submissionsTab.confirmDialog.approvePrompt"
|
||||
: "mcpServers.submissionsTab.confirmDialog.rejectPrompt"
|
||||
}
|
||||
components={{ name: <span className="font-medium text-foreground" />, server: <>{serverName}</> }}
|
||||
/>{" "}
|
||||
{isApprove ? t("mcpServers.submissionsTab.confirmDialog.approveBody") : rejectBody}
|
||||
</p>
|
||||
{!isApprove && (
|
||||
<textarea
|
||||
placeholder="Reason for rejection (optional)"
|
||||
placeholder={t("mcpServers.submissionsTab.confirmDialog.reasonPlaceholder")}
|
||||
value={reviewNotes}
|
||||
onChange={(e) => setReviewNotes(e.target.value)}
|
||||
className="w-full border border-border rounded-md px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-ring mb-4 resize-none"
|
||||
|
|
@ -126,7 +135,7 @@ function ConfirmDialog({ action, serverName, isCurrentlyActive, onConfirm, onCan
|
|||
onClick={onCancel}
|
||||
className="flex-1 border border-border text-foreground hover:bg-accent text-sm font-medium py-2 rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{t("mcpServers.submissionsTab.confirmDialog.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -137,7 +146,9 @@ function ConfirmDialog({ action, serverName, isCurrentlyActive, onConfirm, onCan
|
|||
: "bg-destructive text-destructive-foreground hover:bg-destructive/80"
|
||||
}`}
|
||||
>
|
||||
{isApprove ? "Approve" : "Reject"}
|
||||
{isApprove
|
||||
? t("mcpServers.submissionsTab.confirmDialog.approve")
|
||||
: t("mcpServers.submissionsTab.confirmDialog.reject")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -154,6 +165,7 @@ type SubmissionRulesPanelProps = {
|
|||
};
|
||||
|
||||
function SubmissionRulesPanel({ requiredFields, onChange, onSave, isSaving, disabled }: SubmissionRulesPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const activeLabels = MCP_REQUIRED_FIELD_DEFS.filter((f) => requiredFields.includes(f.key));
|
||||
|
||||
|
|
@ -169,13 +181,17 @@ function SubmissionRulesPanel({ requiredFields, onChange, onSave, isSaving, disa
|
|||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SettingsIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold text-foreground">Submission Rules</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{t("mcpServers.submissionsTab.rulesPanel.title")}
|
||||
</span>
|
||||
{activeLabels.length > 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({activeLabels.length} required field{activeLabels.length !== 1 ? "s" : ""})
|
||||
{t("mcpServers.submissionsTab.rulesPanel.requiredFieldCount", { count: activeLabels.length })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground italic">no rules set</span>
|
||||
<span className="text-xs text-muted-foreground italic">
|
||||
{t("mcpServers.submissionsTab.rulesPanel.noRules")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -202,10 +218,7 @@ function SubmissionRulesPanel({ requiredFields, onChange, onSave, isSaving, disa
|
|||
|
||||
{expanded && (
|
||||
<div className="border-t border-border px-4 pt-4 pb-4">
|
||||
<p className="text-xs text-muted-foreground 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-muted-foreground mb-4">{t("mcpServers.submissionsTab.rulesPanel.help")}</p>
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-5">
|
||||
{FIELD_GROUPS.map((group) => (
|
||||
<div key={group.label}>
|
||||
|
|
@ -247,14 +260,16 @@ function SubmissionRulesPanel({ requiredFields, onChange, onSave, isSaving, disa
|
|||
}}
|
||||
className="px-4 py-1.5 text-sm font-medium text-info-foreground bg-info hover:bg-info/80 disabled:opacity-50 rounded-md transition-colors"
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save Rules"}
|
||||
{isSaving
|
||||
? t("mcpServers.submissionsTab.rulesPanel.saving")
|
||||
: t("mcpServers.submissionsTab.rulesPanel.save")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(false)}
|
||||
className="px-4 py-1.5 text-sm font-medium text-muted-foreground hover:text-foreground border border-border rounded-md hover:bg-accent transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{t("mcpServers.submissionsTab.rulesPanel.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -284,6 +299,7 @@ type CardReviewActionsProps = {
|
|||
};
|
||||
|
||||
function CardReviewActions({ approvalStatus, onApprove, onReject }: CardReviewActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{approvalStatus !== "rejected" && (
|
||||
|
|
@ -294,7 +310,7 @@ function CardReviewActions({ approvalStatus, onApprove, onReject }: CardReviewAc
|
|||
onClick={onApprove}
|
||||
className="text-xs bg-success hover:bg-success/80 text-success-foreground px-3 py-1.5 rounded-md transition-colors font-medium"
|
||||
>
|
||||
Approve
|
||||
{t("mcpServers.submissionsTab.reviewActions.approve")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
|
@ -302,7 +318,7 @@ function CardReviewActions({ approvalStatus, onApprove, onReject }: CardReviewAc
|
|||
onClick={onReject}
|
||||
className="text-xs border border-destructive/30 text-destructive hover:bg-destructive/10 px-3 py-1.5 rounded-md transition-colors font-medium"
|
||||
>
|
||||
Reject
|
||||
{t("mcpServers.submissionsTab.reviewActions.reject")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -313,7 +329,7 @@ function CardReviewActions({ approvalStatus, onApprove, onReject }: CardReviewAc
|
|||
onClick={onApprove}
|
||||
className="text-xs bg-success hover:bg-success/80 text-success-foreground px-3 py-1.5 rounded-md transition-colors font-medium"
|
||||
>
|
||||
Re-approve
|
||||
{t("mcpServers.submissionsTab.reviewActions.reapprove")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -329,6 +345,7 @@ type ComplianceChecksPanelProps = {
|
|||
};
|
||||
|
||||
function ComplianceChecksPanel({ checks, approvalStatus, onApprove, onReject }: ComplianceChecksPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const passCount = checks.filter((c) => c.passed).length;
|
||||
const failCount = checks.length - passCount;
|
||||
const allPassed = checks.length > 0 && failCount === 0;
|
||||
|
|
@ -353,10 +370,12 @@ function ComplianceChecksPanel({ checks, approvalStatus, onApprove, onReject }:
|
|||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-semibold leading-tight ${allPassed ? "text-success" : "text-destructive"}`}>
|
||||
{allPassed ? "All checks passed" : `${failCount} check${failCount !== 1 ? "s" : ""} failed`}
|
||||
{allPassed
|
||||
? t("mcpServers.submissionsTab.compliance.allPassed")
|
||||
: t("mcpServers.submissionsTab.compliance.failedCount", { count: failCount })}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{passCount} passing, {failCount} failing
|
||||
{t("mcpServers.submissionsTab.compliance.summary", { passCount, failCount })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
|
|
@ -366,7 +385,7 @@ function ComplianceChecksPanel({ checks, approvalStatus, onApprove, onReject }:
|
|||
onClick={onApprove}
|
||||
className="text-xs bg-success hover:bg-success/80 text-success-foreground px-3 py-1.5 rounded-md transition-colors font-medium"
|
||||
>
|
||||
Approve
|
||||
{t("mcpServers.submissionsTab.reviewActions.approve")}
|
||||
</button>
|
||||
)}
|
||||
{approvalStatus === "rejected" && (
|
||||
|
|
@ -375,7 +394,7 @@ function ComplianceChecksPanel({ checks, approvalStatus, onApprove, onReject }:
|
|||
onClick={onApprove}
|
||||
className="text-xs bg-success hover:bg-success/80 text-success-foreground px-3 py-1.5 rounded-md transition-colors font-medium"
|
||||
>
|
||||
Re-approve
|
||||
{t("mcpServers.submissionsTab.reviewActions.reapprove")}
|
||||
</button>
|
||||
)}
|
||||
{approvalStatus !== "rejected" && (
|
||||
|
|
@ -384,7 +403,7 @@ function ComplianceChecksPanel({ checks, approvalStatus, onApprove, onReject }:
|
|||
onClick={onReject}
|
||||
className="text-xs border border-destructive/30 text-destructive hover:bg-destructive/10 bg-card px-3 py-1.5 rounded-md transition-colors font-medium"
|
||||
>
|
||||
Reject
|
||||
{t("mcpServers.submissionsTab.reviewActions.reject")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -406,7 +425,9 @@ function ComplianceChecksPanel({ checks, approvalStatus, onApprove, onReject }:
|
|||
</div>
|
||||
<span className={`text-sm flex-1 ${c.passed ? "text-foreground" : "text-foreground"}`}>{c.label}</span>
|
||||
<span className={`text-xs ${c.passed ? "text-success" : "text-destructive"}`}>
|
||||
{c.passed ? "Passes" : "Missing"}
|
||||
{c.passed
|
||||
? t("mcpServers.submissionsTab.compliance.passes")
|
||||
: t("mcpServers.submissionsTab.compliance.missing")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -416,6 +437,7 @@ function ComplianceChecksPanel({ checks, approvalStatus, onApprove, onReject }:
|
|||
}
|
||||
|
||||
function MCPServerCard({ server, onApprove, onReject, requiredFields }: MCPServerCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const approvalStatus = (server.approval_status ?? "active") as MCPStatus;
|
||||
const statusCfg = STATUS_CONFIG[approvalStatus] ?? STATUS_CONFIG["active"];
|
||||
|
||||
|
|
@ -436,7 +458,7 @@ function MCPServerCard({ server, onApprove, onReject, requiredFields }: MCPServe
|
|||
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full ${statusCfg.bg} ${statusCfg.text}`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${statusCfg.dot}`} />
|
||||
{statusCfg.label}
|
||||
{t(statusCfg.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
|
|
@ -453,17 +475,21 @@ function MCPServerCard({ server, onApprove, onReject, requiredFields }: MCPServe
|
|||
)}
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Transport: <span className="text-muted-foreground">{server.transport ?? "sse"}</span>
|
||||
{t("mcpServers.submissionsTab.card.transport")}{" "}
|
||||
<span className="text-muted-foreground">{server.transport ?? "sse"}</span>
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>
|
||||
Submitted by: <span className="text-muted-foreground">{server.submitted_by ?? "—"}</span>
|
||||
{t("mcpServers.submissionsTab.card.submittedBy")}{" "}
|
||||
<span className="text-muted-foreground">{server.submitted_by ?? "—"}</span>
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{formatDate(server.submitted_at)}</span>
|
||||
</div>
|
||||
{approvalStatus === "rejected" && server.review_notes && (
|
||||
<p className="text-xs text-destructive mt-1.5">Rejection reason: {server.review_notes}</p>
|
||||
<p className="text-xs text-destructive mt-1.5">
|
||||
{t("mcpServers.submissionsTab.card.rejectionReason", { reason: server.review_notes })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{checks.length === 0 && (
|
||||
|
|
@ -489,6 +515,7 @@ interface MCPSubmissionsTabProps {
|
|||
}
|
||||
|
||||
export function MCPSubmissionsTab({ accessToken }: MCPSubmissionsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const [summary, setSummary] = useState<MCPSubmissionsSummary>(EMPTY_SUMMARY);
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | MCPStatus>("all");
|
||||
|
|
@ -526,11 +553,11 @@ export function MCPSubmissionsTab({ accessToken }: MCPSubmissionsTabProps) {
|
|||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load submissions");
|
||||
setError(err instanceof Error ? err.message : t("mcpServers.submissionsTab.errors.loadFailed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [accessToken]);
|
||||
}, [accessToken, t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
|
|
@ -541,9 +568,9 @@ export function MCPSubmissionsTab({ accessToken }: MCPSubmissionsTabProps) {
|
|||
setIsSavingRules(true);
|
||||
try {
|
||||
await updateConfigFieldSetting(accessToken, SETTINGS_KEY, requiredFields);
|
||||
toast.success("Submission rules saved");
|
||||
toast.success(t("mcpServers.submissionsTab.toasts.rulesSaved"));
|
||||
} catch {
|
||||
toast.fromError("Failed to save submission rules");
|
||||
toast.fromError(t("mcpServers.submissionsTab.toasts.rulesSaveFailed"));
|
||||
} finally {
|
||||
setIsSavingRules(false);
|
||||
}
|
||||
|
|
@ -567,9 +594,9 @@ export function MCPSubmissionsTab({ accessToken }: MCPSubmissionsTabProps) {
|
|||
try {
|
||||
await approveMCPServer(accessToken, serverId);
|
||||
await fetchData();
|
||||
toast.success(`MCP server "${serverName}" approved`);
|
||||
toast.success(t("mcpServers.submissionsTab.toasts.approved", { serverName }));
|
||||
} catch {
|
||||
toast.fromError("Failed to approve MCP server");
|
||||
toast.fromError(t("mcpServers.submissionsTab.toasts.approveFailed"));
|
||||
} finally {
|
||||
setConfirmAction(null);
|
||||
}
|
||||
|
|
@ -580,9 +607,9 @@ export function MCPSubmissionsTab({ accessToken }: MCPSubmissionsTabProps) {
|
|||
try {
|
||||
await rejectMCPServer(accessToken, serverId, reviewNotes);
|
||||
await fetchData();
|
||||
toast.success(`MCP server "${serverName}" rejected`);
|
||||
toast.success(t("mcpServers.submissionsTab.toasts.rejected", { serverName }));
|
||||
} catch {
|
||||
toast.fromError("Failed to reject MCP server");
|
||||
toast.fromError(t("mcpServers.submissionsTab.toasts.rejectFailed"));
|
||||
} finally {
|
||||
setConfirmAction(null);
|
||||
}
|
||||
|
|
@ -599,10 +626,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-foreground" />
|
||||
<StatCard label="Pending Review" value={summary.pending_review} color="text-warning" />
|
||||
<StatCard label="Active" value={summary.active} color="text-success" />
|
||||
<StatCard label="Rejected" value={summary.rejected} color="text-destructive" />
|
||||
<StatCard label={t("mcpServers.submissionsTab.stats.total")} value={summary.total} color="text-foreground" />
|
||||
<StatCard
|
||||
label={t("mcpServers.submissionsTab.status.pendingReview")}
|
||||
value={summary.pending_review}
|
||||
color="text-warning"
|
||||
/>
|
||||
<StatCard label={t("mcpServers.submissionsTab.status.active")} value={summary.active} color="text-success" />
|
||||
<StatCard
|
||||
label={t("mcpServers.submissionsTab.status.rejected")}
|
||||
value={summary.rejected}
|
||||
color="text-destructive"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
|
|
@ -610,7 +645,7 @@ export function MCPSubmissionsTab({ accessToken }: MCPSubmissionsTabProps) {
|
|||
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search MCP servers..."
|
||||
placeholder={t("mcpServers.submissionsTab.filters.searchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 border border-border rounded-md text-sm text-foreground placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-ring focus:border-info"
|
||||
|
|
@ -621,20 +656,22 @@ export function MCPSubmissionsTab({ accessToken }: MCPSubmissionsTabProps) {
|
|||
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
|
||||
className="border border-border rounded-md px-3 py-2 text-sm text-foreground focus:outline-hidden focus:ring-1 focus:ring-ring focus:border-info bg-card"
|
||||
>
|
||||
<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("mcpServers.submissionsTab.filters.allStatuses")}</option>
|
||||
<option value="pending_review">{t("mcpServers.submissionsTab.status.pendingReview")}</option>
|
||||
<option value="active">{t("mcpServers.submissionsTab.status.active")}</option>
|
||||
<option value="rejected">{t("mcpServers.submissionsTab.status.rejected")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{isLoading && <div className="text-center py-12 text-muted-foreground text-sm">Loading submissions…</div>}
|
||||
{isLoading && (
|
||||
<div className="text-center py-12 text-muted-foreground text-sm">
|
||||
{t("mcpServers.submissionsTab.loading")}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="text-center py-12 text-destructive text-sm">{error}</div>}
|
||||
{showEmptyState && (
|
||||
<div className="text-center py-12 text-muted-foreground text-sm">
|
||||
No MCP server submissions match your filters.
|
||||
</div>
|
||||
<div className="text-center py-12 text-muted-foreground text-sm">{t("mcpServers.submissionsTab.empty")}</div>
|
||||
)}
|
||||
{!isLoading &&
|
||||
!error &&
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Copy, Link2, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdCell, IdentityCell } from "@/components/shared/table_cells";
|
||||
|
|
@ -17,6 +19,7 @@ import { cn } from "@/lib/cva.config";
|
|||
import { getGatewayBaseUrl } from "@/components/networking";
|
||||
import { MCPToolset } from "@/components/mcp_tools/types";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
// Display-only. Toolsets persist {server_id, bare tool_name}; the gateway serves
|
||||
// each tool prefixed as "{server-prefix}-{tool}". Render that qualified form so
|
||||
|
|
@ -41,10 +44,11 @@ interface ToolsetRowActionsProps {
|
|||
}
|
||||
|
||||
function ToolsetRowActions({ toolset, isAdmin, onEditClick, onDeleteClick }: ToolsetRowActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open toolset actions"
|
||||
aria-label={t("mcpServers.toolsetTableColumns.actions.open")}
|
||||
data-testid={`toolset-actions-${toolset.toolset_id}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
|
|
@ -53,24 +57,29 @@ function ToolsetRowActions({ toolset, isAdmin, onEditClick, onDeleteClick }: Too
|
|||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem
|
||||
data-testid="toolset-action-copy-url"
|
||||
onClick={() => void copyToClipboard(toolsetEndpointUrl(toolset.toolset_name), "Endpoint URL copied")}
|
||||
onClick={() =>
|
||||
void copyToClipboard(
|
||||
toolsetEndpointUrl(toolset.toolset_name),
|
||||
t("mcpServers.toolsetTableColumns.actions.urlCopied"),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Link2 />
|
||||
Copy endpoint URL
|
||||
{t("mcpServers.toolsetTableColumns.actions.copyUrl")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="toolset-action-copy-id"
|
||||
onClick={() => void copyToClipboard(toolset.toolset_id, "Toolset ID copied")}
|
||||
onClick={() => void copyToClipboard(toolset.toolset_id, t("mcpServers.toolsetTableColumns.actions.idCopied"))}
|
||||
>
|
||||
<Copy />
|
||||
Copy toolset ID
|
||||
{t("mcpServers.toolsetTableColumns.actions.copyId")}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem data-testid="toolset-action-edit" onClick={() => onEditClick(toolset)}>
|
||||
<Pencil />
|
||||
Edit
|
||||
{t("mcpServers.toolsetTableColumns.actions.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
|
|
@ -78,7 +87,7 @@ function ToolsetRowActions({ toolset, isAdmin, onEditClick, onDeleteClick }: Too
|
|||
onClick={() => onDeleteClick(toolset.toolset_id)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
{t("mcpServers.toolsetTableColumns.actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -92,6 +101,7 @@ interface MCPToolsetTableColumnsDeps {
|
|||
serverPrefixById: Map<string, string>;
|
||||
onEditClick: (toolset: MCPToolset) => void;
|
||||
onDeleteClick: (toolsetId: string) => void;
|
||||
t?: TFunction;
|
||||
}
|
||||
|
||||
export const getMCPToolsetTableColumns = ({
|
||||
|
|
@ -99,12 +109,13 @@ export const getMCPToolsetTableColumns = ({
|
|||
serverPrefixById,
|
||||
onEditClick,
|
||||
onDeleteClick,
|
||||
t = i18n.t.bind(i18n),
|
||||
}: MCPToolsetTableColumnsDeps): ColumnDef<MCPToolset>[] => [
|
||||
{
|
||||
id: "toolset_id",
|
||||
accessorKey: "toolset_id",
|
||||
meta: { title: "Toolset ID" },
|
||||
header: "Toolset ID",
|
||||
meta: { title: t("mcpServers.toolsetTableColumns.columns.toolsetId") },
|
||||
header: t("mcpServers.toolsetTableColumns.columns.toolsetId"),
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <IdCell value={row.original.toolset_id} />,
|
||||
|
|
@ -112,8 +123,10 @@ export const getMCPToolsetTableColumns = ({
|
|||
{
|
||||
id: "toolset_name",
|
||||
accessorKey: "toolset_name",
|
||||
meta: { title: "Name" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Name" />,
|
||||
meta: { title: t("mcpServers.toolsetTableColumns.columns.name") },
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader column={column} title={t("mcpServers.toolsetTableColumns.columns.name")} />
|
||||
),
|
||||
size: 260,
|
||||
enableSorting: true,
|
||||
sortingFn: "alphanumeric",
|
||||
|
|
@ -129,8 +142,8 @@ export const getMCPToolsetTableColumns = ({
|
|||
{
|
||||
id: "description",
|
||||
accessorKey: "description",
|
||||
meta: { title: "Description" },
|
||||
header: "Description",
|
||||
meta: { title: t("mcpServers.toolsetTableColumns.columns.description") },
|
||||
header: t("mcpServers.toolsetTableColumns.columns.description"),
|
||||
size: 200,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -141,8 +154,8 @@ export const getMCPToolsetTableColumns = ({
|
|||
},
|
||||
{
|
||||
id: "tools",
|
||||
meta: { title: "Tools", skeleton: "chips" },
|
||||
header: "Tools",
|
||||
meta: { title: t("mcpServers.toolsetTableColumns.columns.tools"), skeleton: "chips" },
|
||||
header: t("mcpServers.toolsetTableColumns.columns.tools"),
|
||||
size: 260,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -158,7 +171,9 @@ export const getMCPToolsetTableColumns = ({
|
|||
</span>
|
||||
))}
|
||||
{tools.length > 4 && (
|
||||
<span className="self-center text-xs text-muted-foreground">+{tools.length - 4} more</span>
|
||||
<span className="self-center text-xs text-muted-foreground">
|
||||
{t("mcpServers.toolsetTableColumns.moreTools", { count: tools.length - 4 })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -167,8 +182,10 @@ export const getMCPToolsetTableColumns = ({
|
|||
{
|
||||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
meta: { title: "Created" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created" />,
|
||||
meta: { title: t("mcpServers.toolsetTableColumns.columns.created") },
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader column={column} title={t("mcpServers.toolsetTableColumns.columns.created")} />
|
||||
),
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
|
||||
|
|
@ -176,7 +193,7 @@ export const getMCPToolsetTableColumns = ({
|
|||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
header: () => <span className="sr-only">{t("mcpServers.toolsetTableColumns.columns.actions")}</span>,
|
||||
size: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import React, { useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { SortingState } from "@tanstack/react-table";
|
||||
import { Inbox, Plus, X } from "lucide-react";
|
||||
|
|
@ -41,12 +43,13 @@ interface MCPToolsetsTabProps {
|
|||
userRole: string | null;
|
||||
}
|
||||
|
||||
const toolsetSchema = z.object({
|
||||
toolset_name: z.string().min(1, "Please enter a toolset name"),
|
||||
description: z.string(),
|
||||
});
|
||||
const buildToolsetSchema = (t: TFunction) =>
|
||||
z.object({
|
||||
toolset_name: z.string().min(1, t("mcpServers.toolsetsTab.form.errors.nameRequired")),
|
||||
description: z.string(),
|
||||
});
|
||||
|
||||
type ToolsetFormValues = z.infer<typeof toolsetSchema>;
|
||||
type ToolsetFormValues = z.infer<ReturnType<typeof buildToolsetSchema>>;
|
||||
|
||||
const SEARCH_DEBOUNCE_WAIT_MS = 300;
|
||||
|
||||
|
|
@ -117,11 +120,13 @@ const selectedToolIssue = (
|
|||
tool: MCPToolsetTool,
|
||||
serverNameById: ReadonlyMap<string, string>,
|
||||
toolsByServer: ReadonlyMap<string, MCPServerToolsState>,
|
||||
t: TFunction,
|
||||
): string | undefined => {
|
||||
if (!serverNameById.has(tool.server_id)) return "server not found";
|
||||
if (!serverNameById.has(tool.server_id)) return t("mcpServers.toolsetsTab.toolIssues.serverNotFound");
|
||||
const state = toolsByServer.get(tool.server_id);
|
||||
if (state?.status === "error") return "server unreachable";
|
||||
if (state?.status === "success" && !state.tools.some((t) => t.name === tool.tool_name)) return "tool not found";
|
||||
if (state?.status === "error") return t("mcpServers.toolsetsTab.toolIssues.serverUnreachable");
|
||||
if (state?.status === "success" && !state.tools.some((entry) => entry.name === tool.tool_name))
|
||||
return t("mcpServers.toolsetsTab.toolIssues.toolNotFound");
|
||||
return undefined;
|
||||
};
|
||||
|
||||
|
|
@ -150,7 +155,8 @@ function ServerToolGroup({
|
|||
onToggleTool,
|
||||
onSetToolsSelected,
|
||||
}: ServerToolGroupProps) {
|
||||
const selectedCount = selectedTools.filter((t) => t.server_id === serverId).length;
|
||||
const { t } = useTranslation();
|
||||
const selectedCount = selectedTools.filter((tool) => tool.server_id === serverId).length;
|
||||
const visibleRefs = visibleTools.map((tool) => ({ server_id: serverId, tool_name: tool.name }));
|
||||
const groupState = selectionState(selectedTools, visibleRefs);
|
||||
|
||||
|
|
@ -163,12 +169,14 @@ function ServerToolGroup({
|
|||
);
|
||||
}
|
||||
if (state.status === "error") {
|
||||
return <p className="text-xs text-destructive px-2 py-2">Couldn't load tools for this server.</p>;
|
||||
return <p className="text-xs text-destructive px-2 py-2">{t("mcpServers.toolsetsTab.serverGroup.loadError")}</p>;
|
||||
}
|
||||
if (visibleTools.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground px-2 py-2">
|
||||
{searchActive ? "No matching tools on this server." : "No tools found for this server."}
|
||||
{searchActive
|
||||
? t("mcpServers.toolsetsTab.serverGroup.noMatchingTools")
|
||||
: t("mcpServers.toolsetsTab.serverGroup.noTools")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
|
@ -212,7 +220,11 @@ function ServerToolGroup({
|
|||
disabled={visibleRefs.length === 0}
|
||||
// Base UI renders a span, which the global button cursor rule does not cover.
|
||||
className="cursor-pointer data-disabled:cursor-not-allowed"
|
||||
aria-label={`${searchActive ? "Select matching tools" : "Select all tools"} on ${serverName}`}
|
||||
aria-label={
|
||||
searchActive
|
||||
? t("mcpServers.toolsetsTab.serverGroup.selectMatching", { serverName })
|
||||
: t("mcpServers.toolsetsTab.serverGroup.selectAll", { serverName })
|
||||
}
|
||||
onCheckedChange={(checked) => onSetToolsSelected(visibleRefs, checked)}
|
||||
/>
|
||||
<button
|
||||
|
|
@ -225,7 +237,7 @@ function ServerToolGroup({
|
|||
{serverName}
|
||||
{selectedCount > 0 && (
|
||||
<span className="ml-1 text-xs text-purple-600 font-semibold dark:text-purple-400">
|
||||
{selectedCount} selected
|
||||
{t("mcpServers.toolsetsTab.serverGroup.selectedCount", { count: selectedCount })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
|
@ -250,6 +262,8 @@ const toolsetFormDefaults = (toolset?: MCPToolset): ToolsetFormValues => ({
|
|||
});
|
||||
|
||||
function CreateToolsetModal({ open, onClose, onSave, initialToolset }: CreateToolsetModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const toolsetSchema = React.useMemo(() => buildToolsetSchema(t), [t]);
|
||||
const form = useZodForm(toolsetSchema, { defaultValues: toolsetFormDefaults(initialToolset) });
|
||||
const [selectedTools, setSelectedTools] = useState<MCPToolsetTool[]>(initialToolset?.tools || []);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
|
@ -321,15 +335,29 @@ function CreateToolsetModal({ open, onClose, onSave, initialToolset }: CreateToo
|
|||
<Dialog open={open} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[960px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{initialToolset ? "Edit Toolset" : "New Toolset"}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{initialToolset
|
||||
? t("mcpServers.toolsetsTab.modal.editTitle")
|
||||
: t("mcpServers.toolsetsTab.modal.createTitle")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={(event) => event.preventDefault()} className="mt-2">
|
||||
<FieldGroup className="mb-4 flex-row gap-4">
|
||||
<FormField control={form.control} name="toolset_name" label="Toolset Name" className="flex-1">
|
||||
{(field) => <Input {...field} placeholder="e.g. github-linear-tools" />}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="toolset_name"
|
||||
label={t("mcpServers.toolsetsTab.modal.nameLabel")}
|
||||
className="flex-1"
|
||||
>
|
||||
{(field) => <Input {...field} placeholder={t("mcpServers.toolsetsTab.modal.namePlaceholder")} />}
|
||||
</FormField>
|
||||
<FormField control={form.control} name="description" label="Description" className="flex-1">
|
||||
{(field) => <Input {...field} placeholder="Optional description" />}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
label={t("mcpServers.toolsetsTab.modal.descriptionLabel")}
|
||||
className="flex-1"
|
||||
>
|
||||
{(field) => <Input {...field} placeholder={t("mcpServers.toolsetsTab.modal.descriptionPlaceholder")} />}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
|
@ -338,18 +366,24 @@ function CreateToolsetModal({ open, onClose, onSave, initialToolset }: CreateToo
|
|||
{/* Left panel: Available Tools */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-semibold text-foreground">Available Tools</p>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{t("mcpServers.toolsetsTab.modal.availableTools")}
|
||||
</p>
|
||||
</div>
|
||||
<InputGroup className="mb-2">
|
||||
<InputGroupInput
|
||||
placeholder="Search tools or servers..."
|
||||
aria-label="Search tools or servers"
|
||||
placeholder={t("mcpServers.toolsetsTab.modal.searchPlaceholder")}
|
||||
aria-label={t("mcpServers.toolsetsTab.modal.searchLabel")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{search && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={() => setSearch("")}>
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={t("mcpServers.toolsetsTab.modal.clearSearch")}
|
||||
onClick={() => setSearch("")}
|
||||
>
|
||||
<X />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
|
|
@ -358,9 +392,9 @@ function CreateToolsetModal({ open, onClose, onSave, initialToolset }: CreateToo
|
|||
{searchActive && (
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground" aria-live="polite">
|
||||
{matchingTools.length} matching {matchingTools.length === 1 ? "tool" : "tools"}
|
||||
{t("mcpServers.toolsetsTab.modal.matchingTools", { count: matchingTools.length })}
|
||||
{pendingServerCount > 0 &&
|
||||
` · loading ${pendingServerCount} ${pendingServerCount === 1 ? "server" : "servers"}…`}
|
||||
` · ${t("mcpServers.toolsetsTab.modal.loadingServers", { count: pendingServerCount })}`}
|
||||
</p>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button
|
||||
|
|
@ -369,7 +403,7 @@ function CreateToolsetModal({ open, onClose, onSave, initialToolset }: CreateToo
|
|||
disabled={matchingTools.length === 0 || matchingState === "all"}
|
||||
onClick={() => handleSetToolsSelected(matchingTools, true)}
|
||||
>
|
||||
Select all matching
|
||||
{t("mcpServers.toolsetsTab.modal.selectAllMatching")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
|
|
@ -377,7 +411,7 @@ function CreateToolsetModal({ open, onClose, onSave, initialToolset }: CreateToo
|
|||
disabled={matchingState === "none"}
|
||||
onClick={() => handleSetToolsSelected(matchingTools, false)}
|
||||
>
|
||||
Deselect all matching
|
||||
{t("mcpServers.toolsetsTab.modal.deselectAllMatching")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -385,7 +419,9 @@ function CreateToolsetModal({ open, onClose, onSave, initialToolset }: CreateToo
|
|||
<div className="space-y-2 overflow-y-auto" style={{ maxHeight: 300 }}>
|
||||
{serverViews.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{mcpServers.length === 0 ? "No MCP servers configured" : "No tools or servers match your search"}
|
||||
{mcpServers.length === 0
|
||||
? t("mcpServers.toolsetsTab.modal.noServers")
|
||||
: t("mcpServers.toolsetsTab.modal.noSearchMatches")}
|
||||
</p>
|
||||
) : (
|
||||
serverViews.map(({ server, serverName, state, visibleTools }) => (
|
||||
|
|
@ -414,8 +450,10 @@ function CreateToolsetModal({ open, onClose, onSave, initialToolset }: CreateToo
|
|||
<div className="w-72 shrink-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Your Toolset{" "}
|
||||
<span className="text-xs font-normal text-muted-foreground">({selectedTools.length} tools)</span>
|
||||
{t("mcpServers.toolsetsTab.modal.yourToolset")}{" "}
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{t("mcpServers.toolsetsTab.modal.toolCount", { count: selectedTools.length })}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
size="xs"
|
||||
|
|
@ -423,21 +461,21 @@ function CreateToolsetModal({ open, onClose, onSave, initialToolset }: CreateToo
|
|||
disabled={selectedTools.length === 0}
|
||||
onClick={() => setSelectedTools([])}
|
||||
>
|
||||
Clear all
|
||||
{t("mcpServers.toolsetsTab.modal.clearAll")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1 overflow-y-auto" style={{ maxHeight: 340 }}>
|
||||
{selectedTools.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">No tools added yet</p>
|
||||
<p className="text-muted-foreground text-sm">{t("mcpServers.toolsetsTab.modal.noToolsAdded")}</p>
|
||||
) : (
|
||||
selectedTools.map((tool) => {
|
||||
const toolName = displayToolName(serverPrefixById.get(tool.server_id), tool.tool_name);
|
||||
const issue = serversLoaded ? selectedToolIssue(tool, serverPrefixById, toolsByServer) : undefined;
|
||||
const issue = serversLoaded ? selectedToolIssue(tool, serverPrefixById, toolsByServer, t) : undefined;
|
||||
return (
|
||||
<button
|
||||
key={`${tool.server_id}:${tool.tool_name}`}
|
||||
type="button"
|
||||
aria-label={`Remove ${toolName}`}
|
||||
aria-label={t("mcpServers.toolsetsTab.modal.removeTool", { toolName })}
|
||||
onClick={() => handleToggleTool(tool)}
|
||||
className="w-full flex items-center justify-between px-3 py-1.5 rounded-lg border border-purple-200 bg-purple-50 hover:bg-destructive/10 hover:border-destructive/20 group transition-colors dark:border-purple-800 dark:bg-purple-950"
|
||||
>
|
||||
|
|
@ -466,11 +504,11 @@ function CreateToolsetModal({ open, onClose, onSave, initialToolset }: CreateToo
|
|||
|
||||
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-border">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("mcpServers.toolsetsTab.modal.cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => void form.handleSubmit(handleSubmit)()} disabled={saving} aria-busy={saving}>
|
||||
{saving && <UiLoadingSpinner className="size-4" />}
|
||||
{initialToolset ? "Save Changes" : "Create Toolset"}
|
||||
{initialToolset ? t("mcpServers.toolsetsTab.modal.saveChanges") : t("mcpServers.toolsetsTab.modal.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
@ -479,20 +517,20 @@ function CreateToolsetModal({ open, onClose, onSave, initialToolset }: CreateToo
|
|||
}
|
||||
|
||||
function ToolsetsEmptyState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Inbox className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No toolsets yet</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Create a toolset to give keys and teams a curated set of MCP tools.
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">{t("mcpServers.toolsetsTab.emptyState.title")}</div>
|
||||
<div className="text-sm text-muted-foreground">{t("mcpServers.toolsetsTab.emptyState.body")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolsetUsageGuide() {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const gatewayBaseUrl = getGatewayBaseUrl();
|
||||
|
||||
|
|
@ -517,13 +555,14 @@ function ToolsetUsageGuide() {
|
|||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-border bg-muted px-5 py-4">
|
||||
<p className="text-sm font-medium text-foreground mb-1">How toolsets work</p>
|
||||
<p className="text-sm font-medium text-foreground mb-1">{t("mcpServers.toolsetsTab.usageGuide.title")}</p>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Create a toolset, assign it to a key via{" "}
|
||||
<span className="font-medium text-foreground">API Keys → Edit Key → MCP Servers</span>, then point your MCP
|
||||
client at the toolset URL. The client only sees the tools you picked.
|
||||
<Trans
|
||||
i18nKey="mcpServers.toolsetsTab.usageGuide.body"
|
||||
components={{ menu: <span className="font-medium text-foreground" /> }}
|
||||
/>
|
||||
</p>
|
||||
<div className="text-xs text-muted-foreground mb-1">Claude Code / Cursor config</div>
|
||||
<div className="text-xs text-muted-foreground mb-1">{t("mcpServers.toolsetsTab.usageGuide.configLabel")}</div>
|
||||
<div className="relative">
|
||||
<pre className="bg-card border border-border rounded-sm px-4 py-3 text-xs font-mono text-foreground overflow-x-auto leading-relaxed pr-14">
|
||||
{snippet}
|
||||
|
|
@ -533,7 +572,7 @@ function ToolsetUsageGuide() {
|
|||
onClick={copy}
|
||||
className="absolute top-2 right-2 px-2 py-1 text-xs rounded-sm border bg-card hover:bg-muted text-muted-foreground hover:text-foreground border-border transition-colors"
|
||||
>
|
||||
{copied ? "✓" : "copy"}
|
||||
{copied ? "✓" : t("mcpServers.toolsetsTab.usageGuide.copy")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -541,6 +580,7 @@ function ToolsetUsageGuide() {
|
|||
}
|
||||
|
||||
export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: toolsets = [], isLoading } = useMCPToolsets();
|
||||
const { data: mcpServers = [] } = useMCPServers();
|
||||
|
|
@ -554,14 +594,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 });
|
||||
toast.success("Toolset created");
|
||||
toast.success(t("mcpServers.toolsetsTab.toasts.created"));
|
||||
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 });
|
||||
toast.success("Toolset updated");
|
||||
toast.success(t("mcpServers.toolsetsTab.toasts.updated"));
|
||||
queryClient.invalidateQueries({ queryKey: ["mcpToolsets"] });
|
||||
setEditToolset(null);
|
||||
};
|
||||
|
|
@ -571,7 +611,7 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
|
|||
setDeleting(true);
|
||||
try {
|
||||
await deleteMCPToolset(accessToken, deleteId);
|
||||
toast.success("Toolset deleted");
|
||||
toast.success(t("mcpServers.toolsetsTab.toasts.deleted"));
|
||||
queryClient.invalidateQueries({ queryKey: ["mcpToolsets"] });
|
||||
setDeleteId(null);
|
||||
} finally {
|
||||
|
|
@ -590,24 +630,22 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
|
|||
serverPrefixById,
|
||||
onEditClick: setEditToolset,
|
||||
onDeleteClick: setDeleteId,
|
||||
t,
|
||||
};
|
||||
return getMCPToolsetTableColumns(deps);
|
||||
}, [isAdmin, serverPrefixById]);
|
||||
}, [isAdmin, serverPrefixById, t]);
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-foreground">MCP Toolsets</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Curated collections of tools from one or more MCP servers. Assign toolsets to keys and teams via the MCP
|
||||
permissions dropdown.
|
||||
</p>
|
||||
<h3 className="text-lg font-medium text-foreground">{t("mcpServers.toolsetsTab.header.title")}</h3>
|
||||
<p className="text-muted-foreground text-sm">{t("mcpServers.toolsetsTab.header.description")}</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus />
|
||||
New Toolset
|
||||
{t("mcpServers.toolsetsTab.header.newToolset")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -623,7 +661,7 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
|
|||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading toolsets…"
|
||||
loadingMessage={t("mcpServers.toolsetsTab.loading")}
|
||||
noDataMessage={<ToolsetsEmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
|
|
@ -642,17 +680,15 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
|
|||
<Dialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Toolset</DialogTitle>
|
||||
<DialogTitle>{t("mcpServers.toolsetsTab.deleteDialog.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<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("mcpServers.toolsetsTab.deleteDialog.body")}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||
Cancel
|
||||
{t("mcpServers.toolsetsTab.deleteDialog.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleDelete} variant="destructive" disabled={deleting} aria-busy={deleting}>
|
||||
Delete
|
||||
{t("mcpServers.toolsetsTab.deleteDialog.delete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
import { Info } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
|
|
@ -39,18 +41,11 @@ interface OAuthFormFieldsProps {
|
|||
|
||||
const fieldClassName = "rounded-lg border-border focus:border-info focus:ring-ring";
|
||||
|
||||
const OAUTH_FLOW_ITEMS = [
|
||||
{ value: OAUTH_FLOW.M2M, label: "Machine-to-Machine (M2M)" },
|
||||
{ value: OAUTH_FLOW.INTERACTIVE, label: "Interactive (PKCE)" },
|
||||
const OAUTH_FLOW_ITEMS: ReadonlyArray<{ value: string; labelKey: ParseKeys }> = [
|
||||
{ value: OAUTH_FLOW.M2M, labelKey: "mcpServers.oauthFormFields.flowType.m2m.label" },
|
||||
{ value: OAUTH_FLOW.INTERACTIVE, labelKey: "mcpServers.oauthFormFields.flowType.interactive.label" },
|
||||
];
|
||||
|
||||
const UPSTREAM_RESOURCE_TOOLTIP =
|
||||
"RFC 8707 resource indicator sent to the authorization server so it mints a token audienced for this MCP server. " +
|
||||
"Leave blank to send nothing, which is the default and what most providers expect. Use 'auto' to send this server's " +
|
||||
"own URL. Set an exact identifier when the authorization server expects a specific one. Some providers reject this " +
|
||||
"parameter and take the audience from scopes instead; if you see AADSTS901002, leave it blank. If you see " +
|
||||
"invalid_target, the authorization server needs it set.";
|
||||
|
||||
const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, tooltip }) => (
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
{label}
|
||||
|
|
@ -60,16 +55,28 @@ const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, toolt
|
|||
</span>
|
||||
);
|
||||
|
||||
const UpstreamResourceField: React.FC = () => (
|
||||
<MountedFormField
|
||||
label={<FieldLabel label="Resource Indicator (optional)" tooltip={UPSTREAM_RESOURCE_TOOLTIP} />}
|
||||
name={["credentials", "upstream_resource"]}
|
||||
>
|
||||
{(control) => (
|
||||
<Input {...textControl(control)} placeholder="auto, or https://mcp.example.com/mcp" className={fieldClassName} />
|
||||
)}
|
||||
</MountedFormField>
|
||||
);
|
||||
const UpstreamResourceField: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label={t("mcpServers.oauthFormFields.upstreamResource.label")}
|
||||
tooltip={t("mcpServers.oauthFormFields.upstreamResource.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={["credentials", "upstream_resource"]}
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder={t("mcpServers.oauthFormFields.upstreamResource.placeholder")}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
);
|
||||
};
|
||||
|
||||
const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
||||
isM2M,
|
||||
|
|
@ -78,7 +85,8 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
initialFlowType,
|
||||
docsUrl,
|
||||
}) => {
|
||||
const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : "";
|
||||
const { t } = useTranslation();
|
||||
const oauthFlowItems = OAUTH_FLOW_ITEMS.map((item) => ({ value: item.value, label: t(item.labelKey) }));
|
||||
const requiredWhenCreating = (message: string) =>
|
||||
isEditing ? undefined : { validate: { required: requiredRule(message) } };
|
||||
|
||||
|
|
@ -87,29 +95,33 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="OAuth Flow Type"
|
||||
tooltip="Choose how the proxy authenticates with this MCP server. M2M is for server-to-server communication using client credentials. Interactive (PKCE) is for user-facing flows that require browser-based authorization."
|
||||
label={t("mcpServers.oauthFormFields.flowType.label")}
|
||||
tooltip={t("mcpServers.oauthFormFields.flowType.tooltip")}
|
||||
/>
|
||||
}
|
||||
name="oauth_flow_type"
|
||||
{...(initialFlowType ? { defaultValue: initialFlowType } : {})}
|
||||
>
|
||||
{(control) => (
|
||||
<Select {...selectControl<string>(control)} items={OAUTH_FLOW_ITEMS}>
|
||||
<Select {...selectControl<string>(control)} items={oauthFlowItems}>
|
||||
<SelectTrigger {...selectTriggerControl(control)} className="w-full rounded-lg">
|
||||
<SelectValue placeholder="Select OAuth flow" />
|
||||
<SelectValue placeholder={t("mcpServers.oauthFormFields.flowType.placeholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={OAUTH_FLOW.M2M}>
|
||||
<div>
|
||||
<span className="font-medium">Machine-to-Machine (M2M)</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">server-to-server, no user interaction</span>
|
||||
<span className="font-medium">{t("mcpServers.oauthFormFields.flowType.m2m.label")}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{t("mcpServers.oauthFormFields.flowType.m2m.description")}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value={OAUTH_FLOW.INTERACTIVE}>
|
||||
<div>
|
||||
<span className="font-medium">Interactive (PKCE)</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">browser-based user authorization</span>
|
||||
<span className="font-medium">{t("mcpServers.oauthFormFields.flowType.interactive.label")}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{t("mcpServers.oauthFormFields.flowType.interactive.description")}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
|
|
@ -120,40 +132,61 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
{isM2M ? (
|
||||
<>
|
||||
<MountedFormField
|
||||
label={<FieldLabel label="Client ID" tooltip="OAuth2 client ID for the client_credentials grant." />}
|
||||
label={
|
||||
<FieldLabel
|
||||
label={t("mcpServers.oauthFormFields.m2m.clientId.label")}
|
||||
tooltip={t("mcpServers.oauthFormFields.m2m.clientId.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={["credentials", "client_id"]}
|
||||
required={!isEditing}
|
||||
rules={requiredWhenCreating("Client ID is required for M2M OAuth")}
|
||||
rules={requiredWhenCreating(t("mcpServers.oauthFormFields.m2m.clientId.required"))}
|
||||
>
|
||||
{(control) => (
|
||||
<PasswordInput
|
||||
{...textControl(control)}
|
||||
placeholder={`Enter OAuth client ID${placeholderSuffix}`}
|
||||
placeholder={
|
||||
isEditing
|
||||
? t("mcpServers.oauthFormFields.m2m.clientId.placeholderEditing")
|
||||
: t("mcpServers.oauthFormFields.m2m.clientId.placeholder")
|
||||
}
|
||||
groupClassName={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel label="Client Secret" tooltip="OAuth2 client secret for the client_credentials grant." />
|
||||
<FieldLabel
|
||||
label={t("mcpServers.oauthFormFields.m2m.clientSecret.label")}
|
||||
tooltip={t("mcpServers.oauthFormFields.m2m.clientSecret.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={["credentials", "client_secret"]}
|
||||
required={!isEditing}
|
||||
rules={requiredWhenCreating("Client Secret is required for M2M OAuth")}
|
||||
rules={requiredWhenCreating(t("mcpServers.oauthFormFields.m2m.clientSecret.required"))}
|
||||
>
|
||||
{(control) => (
|
||||
<PasswordInput
|
||||
{...textControl(control)}
|
||||
placeholder={`Enter OAuth client secret${placeholderSuffix}`}
|
||||
placeholder={
|
||||
isEditing
|
||||
? t("mcpServers.oauthFormFields.m2m.clientSecret.placeholderEditing")
|
||||
: t("mcpServers.oauthFormFields.m2m.clientSecret.placeholder")
|
||||
}
|
||||
groupClassName={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={<FieldLabel label="Token URL" tooltip="Token endpoint URL for the client_credentials grant." />}
|
||||
label={
|
||||
<FieldLabel
|
||||
label={t("mcpServers.oauthFormFields.m2m.tokenUrl.label")}
|
||||
tooltip={t("mcpServers.oauthFormFields.m2m.tokenUrl.tooltip")}
|
||||
/>
|
||||
}
|
||||
name="token_url"
|
||||
required={!isEditing}
|
||||
rules={requiredWhenCreating("Token URL is required for M2M OAuth")}
|
||||
rules={requiredWhenCreating(t("mcpServers.oauthFormFields.m2m.tokenUrl.required"))}
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
|
|
@ -167,13 +200,19 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Scopes (optional)"
|
||||
tooltip="Optional scopes to request with the client_credentials grant."
|
||||
label={t("mcpServers.oauthFormFields.m2m.scopes.label")}
|
||||
tooltip={t("mcpServers.oauthFormFields.m2m.scopes.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={["credentials", "scopes"]}
|
||||
>
|
||||
{(control) => <MultiSelect {...tagsControl(control)} placeholder="Add scopes" className="rounded-lg" />}
|
||||
{(control) => (
|
||||
<MultiSelect
|
||||
{...tagsControl(control, t)}
|
||||
placeholder={t("mcpServers.oauthFormFields.m2m.scopes.placeholder")}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<UpstreamResourceField />
|
||||
<UpstreamTokenHeaderField />
|
||||
|
|
@ -184,8 +223,8 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
label={
|
||||
<span className="flex items-center justify-between w-full">
|
||||
<FieldLabel
|
||||
label="Client ID (optional)"
|
||||
tooltip="Provide only if your MCP server cannot handle dynamic client registration."
|
||||
label={t("mcpServers.oauthFormFields.interactive.clientId.label")}
|
||||
tooltip={t("mcpServers.oauthFormFields.interactive.clientId.tooltip")}
|
||||
/>
|
||||
{docsUrl && (
|
||||
<a
|
||||
|
|
@ -195,7 +234,7 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
className="text-xs text-info hover:text-info/80 ml-2 font-normal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Create OAuth App →
|
||||
{t("mcpServers.oauthFormFields.interactive.clientId.createOAuthApp")}
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
|
|
@ -205,7 +244,11 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
{(control) => (
|
||||
<PasswordInput
|
||||
{...textControl(control)}
|
||||
placeholder={`Enter client ID${placeholderSuffix}`}
|
||||
placeholder={
|
||||
isEditing
|
||||
? t("mcpServers.oauthFormFields.interactive.clientId.placeholderEditing")
|
||||
: t("mcpServers.oauthFormFields.interactive.clientId.placeholder")
|
||||
}
|
||||
groupClassName={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -213,8 +256,8 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Client Secret (optional)"
|
||||
tooltip="Provide only if your MCP server cannot handle dynamic client registration."
|
||||
label={t("mcpServers.oauthFormFields.interactive.clientSecret.label")}
|
||||
tooltip={t("mcpServers.oauthFormFields.interactive.clientSecret.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={["credentials", "client_secret"]}
|
||||
|
|
@ -222,7 +265,11 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
{(control) => (
|
||||
<PasswordInput
|
||||
{...textControl(control)}
|
||||
placeholder={`Enter client secret${placeholderSuffix}`}
|
||||
placeholder={
|
||||
isEditing
|
||||
? t("mcpServers.oauthFormFields.interactive.clientSecret.placeholderEditing")
|
||||
: t("mcpServers.oauthFormFields.interactive.clientSecret.placeholder")
|
||||
}
|
||||
groupClassName={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -230,21 +277,27 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Scopes (optional)"
|
||||
tooltip="Optional scopes requested during token exchange. Separate multiple scopes with enter or commas."
|
||||
label={t("mcpServers.oauthFormFields.interactive.scopes.label")}
|
||||
tooltip={t("mcpServers.oauthFormFields.interactive.scopes.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={["credentials", "scopes"]}
|
||||
>
|
||||
{(control) => <MultiSelect {...tagsControl(control)} placeholder="Add scopes" className="rounded-lg" />}
|
||||
{(control) => (
|
||||
<MultiSelect
|
||||
{...tagsControl(control, t)}
|
||||
placeholder={t("mcpServers.oauthFormFields.interactive.scopes.placeholder")}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<UpstreamResourceField />
|
||||
<UpstreamTokenHeaderField />
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Issuer (optional)"
|
||||
tooltip="OAuth 2.0 authorization server issuer (RFC 8414). Leave empty to discover endpoints from the upstream resource; set it to pin the trust anchor, which makes this issuer's document the only endpoint source (RFC 8414 §3.3), overriding the Authorization/Token/Registration URLs above and failing closed if its metadata cannot be fetched."
|
||||
label={t("mcpServers.oauthFormFields.interactive.issuer.label")}
|
||||
tooltip={t("mcpServers.oauthFormFields.interactive.issuer.tooltip")}
|
||||
/>
|
||||
}
|
||||
name="issuer"
|
||||
|
|
@ -256,8 +309,8 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Authorization URL (optional)"
|
||||
tooltip="Optional override for the authorization endpoint."
|
||||
label={t("mcpServers.oauthFormFields.interactive.authorizationUrl.label")}
|
||||
tooltip={t("mcpServers.oauthFormFields.interactive.authorizationUrl.tooltip")}
|
||||
/>
|
||||
}
|
||||
name="authorization_url"
|
||||
|
|
@ -271,7 +324,12 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={<FieldLabel label="Token URL (optional)" tooltip="Optional override for the token endpoint." />}
|
||||
label={
|
||||
<FieldLabel
|
||||
label={t("mcpServers.oauthFormFields.interactive.tokenUrl.label")}
|
||||
tooltip={t("mcpServers.oauthFormFields.interactive.tokenUrl.tooltip")}
|
||||
/>
|
||||
}
|
||||
name="token_url"
|
||||
>
|
||||
{(control) => (
|
||||
|
|
@ -286,8 +344,8 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Registration URL (optional)"
|
||||
tooltip="Optional override for the dynamic client registration endpoint."
|
||||
label={t("mcpServers.oauthFormFields.interactive.registrationUrl.label")}
|
||||
tooltip={t("mcpServers.oauthFormFields.interactive.registrationUrl.tooltip")}
|
||||
/>
|
||||
}
|
||||
name="registration_url"
|
||||
|
|
@ -303,12 +361,14 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Token Validation Rules (optional)"
|
||||
tooltip='JSON object of key-value rules checked against the OAuth token response before storing. Supports dot-notation for nested fields (e.g. {"organization": "my-org", "team.id": "123"}). Tokens that fail validation are rejected with HTTP 403.'
|
||||
label={t("mcpServers.oauthFormFields.interactive.tokenValidation.label")}
|
||||
tooltip={t("mcpServers.oauthFormFields.interactive.tokenValidation.tooltip")}
|
||||
/>
|
||||
}
|
||||
name="token_validation_json"
|
||||
rules={{ validate: { json: parsesAsJson("Must be valid JSON") } }}
|
||||
rules={{
|
||||
validate: { json: parsesAsJson(t("mcpServers.oauthFormFields.interactive.tokenValidation.invalidJson")) },
|
||||
}}
|
||||
>
|
||||
{(control) => (
|
||||
<Textarea
|
||||
|
|
@ -322,21 +382,25 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Token Storage TTL (seconds, optional)"
|
||||
tooltip="How long to cache each user's OAuth access token in Redis before evicting it (never longer than the token's own expires_in). Leave blank to derive the TTL from the token's expires_in, or fall back to the 12-hour default."
|
||||
label={t("mcpServers.oauthFormFields.interactive.tokenStorageTtl.label")}
|
||||
tooltip={t("mcpServers.oauthFormFields.interactive.tokenStorageTtl.tooltip")}
|
||||
/>
|
||||
}
|
||||
name="token_storage_ttl_seconds"
|
||||
>
|
||||
{(control) => (
|
||||
<Input {...numberControl(control)} min={1} placeholder="e.g. 3600" className="w-full rounded-lg" />
|
||||
<Input
|
||||
{...numberControl(control)}
|
||||
min={1}
|
||||
placeholder={t("mcpServers.oauthFormFields.interactive.tokenStorageTtl.placeholder")}
|
||||
className="w-full rounded-lg"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
{oauthFlow && (
|
||||
<div className="rounded-lg border border-dashed border-border p-4 space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use OAuth to fetch a fresh access token and temporarily save it in the session as the authentication
|
||||
value.
|
||||
{t("mcpServers.oauthFormFields.interactive.authorize.description")}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
|
@ -344,15 +408,17 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
disabled={oauthFlow.status === "authorizing" || oauthFlow.status === "exchanging"}
|
||||
>
|
||||
{oauthFlow.status === "authorizing"
|
||||
? "Waiting for authorization..."
|
||||
? t("mcpServers.oauthFormFields.interactive.authorize.authorizing")
|
||||
: oauthFlow.status === "exchanging"
|
||||
? "Exchanging authorization code..."
|
||||
: "Authorize & Fetch Token"}
|
||||
? t("mcpServers.oauthFormFields.interactive.authorize.exchanging")
|
||||
: t("mcpServers.oauthFormFields.interactive.authorize.start")}
|
||||
</Button>
|
||||
{oauthFlow.error && <p className="text-sm text-destructive">{oauthFlow.error}</p>}
|
||||
{oauthFlow.status === "success" && oauthFlow.tokenResponse?.access_token && (
|
||||
<p className="text-sm text-success">
|
||||
Token fetched. Expires in {oauthFlow.tokenResponse.expires_in ?? "?"} seconds.
|
||||
{t("mcpServers.oauthFormFields.interactive.authorize.success", {
|
||||
expiresIn: oauthFlow.tokenResponse.expires_in ?? "?",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Info } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AUTH_TYPE, OAUTH_FLOW } from "@/components/mcp_tools/types";
|
||||
|
|
@ -35,6 +36,7 @@ const OpenAPIFormSection: React.FC<OpenAPIFormSectionProps> = ({
|
|||
onLogoUrlChange,
|
||||
onOAuthDocsUrlChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
||||
|
||||
const handlePresetSelect = (entry: OpenAPIRegistryEntry) => {
|
||||
|
|
@ -68,15 +70,15 @@ const OpenAPIFormSection: React.FC<OpenAPIFormSectionProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
OpenAPI Spec URL
|
||||
<SimpleTooltip content="URL to an OpenAPI specification (JSON or YAML). MCP tools will be automatically generated from the API endpoints defined in the spec.">
|
||||
{t("mcpServers.openApiFormSection.specUrl.label")}
|
||||
<SimpleTooltip content={t("mcpServers.openApiFormSection.specUrl.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="spec_path"
|
||||
required
|
||||
rules={{ validate: { required: requiredRule("Please enter an OpenAPI spec URL") } }}
|
||||
rules={{ validate: { required: requiredRule(t("mcpServers.openApiFormSection.specUrl.required")) } }}
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { fetchOpenAPIRegistry } from "@/components/networking";
|
||||
|
|
@ -30,6 +31,7 @@ interface OpenAPIQuickPickerProps {
|
|||
}
|
||||
|
||||
const OpenAPIQuickPicker: React.FC<OpenAPIQuickPickerProps> = ({ accessToken, selectedName, onSelect }) => {
|
||||
const { t } = useTranslation();
|
||||
const [apis, setApis] = useState<OpenAPIRegistryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [imgErrors, setImgErrors] = useState<Set<string>>(new Set());
|
||||
|
|
@ -50,7 +52,7 @@ const OpenAPIQuickPicker: React.FC<OpenAPIQuickPickerProps> = ({ accessToken, se
|
|||
if (loading) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<span className="text-sm font-medium">Popular APIs</span>
|
||||
<span className="text-sm font-medium">{t("mcpServers.openApiQuickPicker.title")}</span>
|
||||
<div className="flex justify-center py-6">
|
||||
<UiLoadingSpinner className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
|
@ -62,7 +64,7 @@ const OpenAPIQuickPicker: React.FC<OpenAPIQuickPickerProps> = ({ accessToken, se
|
|||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<span className="mb-2 block text-sm font-medium">Popular APIs</span>
|
||||
<span className="mb-2 block text-sm font-medium">{t("mcpServers.openApiQuickPicker.title")}</span>
|
||||
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{apis.map((api) => {
|
||||
|
|
@ -97,9 +99,7 @@ const OpenAPIQuickPicker: React.FC<OpenAPIQuickPickerProps> = ({ accessToken, se
|
|||
})}
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Select an API to pre-fill the spec URL and OAuth 2.0 settings, or enter your own spec URL below.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t("mcpServers.openApiQuickPicker.hint")}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import React from "react";
|
|||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
|
@ -18,6 +19,7 @@ const AUTH_HEADER_FORMATS: Readonly<Record<string, string>> = {
|
|||
};
|
||||
|
||||
const OpenApiByokFields: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const isByok = Boolean(useWatch({ name: "is_byok" }));
|
||||
const authType = useWatch({ name: "auth_type" }) as string | undefined;
|
||||
const hasAuthType = Boolean(authType) && authType !== "none";
|
||||
|
|
@ -27,8 +29,8 @@ const OpenApiByokFields: React.FC = () => {
|
|||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
BYOK (Bring Your Own Key)
|
||||
<SimpleTooltip content="When enabled, each user provides their own API key for this service. Keys are stored per-user and never shared.">
|
||||
{t("mcpServers.openApiByokFields.byok.label")}
|
||||
<SimpleTooltip content={t("mcpServers.openApiByokFields.byok.tooltip")}>
|
||||
<Info className="size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
|
|
@ -44,7 +46,7 @@ const OpenApiByokFields: React.FC = () => {
|
|||
<div className="mb-4 p-3 bg-info/10 rounded-lg text-sm text-info flex items-start gap-2">
|
||||
<Info className="mt-0.5 size-4 shrink-0" />
|
||||
<span>
|
||||
User keys will be sent as:{" "}
|
||||
{t("mcpServers.openApiByokFields.headerFormat")}{" "}
|
||||
<code className="font-mono bg-info/15 px-1 rounded-sm">
|
||||
{authType === undefined ? "" : AUTH_HEADER_FORMATS[authType]}
|
||||
</code>
|
||||
|
|
@ -55,16 +57,15 @@ const OpenApiByokFields: React.FC = () => {
|
|||
<div className="mb-4 p-3 bg-warning/10 rounded-lg text-sm text-warning flex items-start gap-2">
|
||||
<Info className="mt-0.5 size-4 shrink-0" />
|
||||
<span>
|
||||
Set the <strong>Authentication Type</strong> below to specify how user keys are sent (e.g., Bearer
|
||||
Token, API Key header).
|
||||
<Trans i18nKey="mcpServers.openApiByokFields.authTypeMissing" components={{ strong: <strong /> }} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Access Description
|
||||
<SimpleTooltip content="List of permissions shown to users in the connection modal (e.g. 'Create and manage Jira issues')">
|
||||
{t("mcpServers.openApiByokFields.accessDescription.label")}
|
||||
<SimpleTooltip content={t("mcpServers.openApiByokFields.accessDescription.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
|
|
@ -73,8 +74,8 @@ const OpenApiByokFields: React.FC = () => {
|
|||
>
|
||||
{(control) => (
|
||||
<MultiSelect
|
||||
{...tagsControl(control)}
|
||||
placeholder="Add access description items (press Enter after each)"
|
||||
{...tagsControl(control, t)}
|
||||
placeholder={t("mcpServers.openApiByokFields.accessDescription.placeholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -83,8 +84,8 @@ const OpenApiByokFields: React.FC = () => {
|
|||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
API Key Help URL
|
||||
<SimpleTooltip content="Optional link shown to users to help them find their API key">
|
||||
{t("mcpServers.openApiByokFields.apiKeyHelpUrl.label")}
|
||||
<SimpleTooltip content={t("mcpServers.openApiByokFields.apiKeyHelpUrl.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -56,41 +57,39 @@ export default function PassthroughAuthorizeSection({
|
|||
onRemoveStoredAppChange?: (remove: boolean) => void;
|
||||
appMayNotMatchUpstream?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!isClientForwardedTokenMode(authType)) return null;
|
||||
const authorizeButtonLabels: Record<string, string> = {
|
||||
authorizing: "Waiting for authorization...",
|
||||
exchanging: "Exchanging authorization code...",
|
||||
authorizing: t("mcpServers.passthroughAuthorizeSection.authorize.authorizing"),
|
||||
exchanging: t("mcpServers.passthroughAuthorizeSection.authorize.exchanging"),
|
||||
};
|
||||
const authorizeButtonLabel = authorizeButtonLabels[oauthFlow.status] ?? "Authorize & Fetch Tools (browser-only)";
|
||||
const authorizeButtonLabel =
|
||||
authorizeButtonLabels[oauthFlow.status] ?? t("mcpServers.passthroughAuthorizeSection.authorize.start");
|
||||
// On edit, "keep existing" only holds when the stored credential class is unchanged; a cross-class
|
||||
// switch (e.g. oauth2 -> true_passthrough) replaces credentials, so blanks then mean "no app".
|
||||
const classUnchanged = isEditing && credentialAuthClass(savedAuthType) === credentialAuthClass(authType);
|
||||
const clientIdPlaceholder = classUnchanged
|
||||
? "Leave blank to keep the currently saved app (if any)"
|
||||
: "Leave blank to use dynamic client registration";
|
||||
? t("mcpServers.passthroughAuthorizeSection.clientId.placeholderKeepSaved")
|
||||
: t("mcpServers.passthroughAuthorizeSection.clientId.placeholderDcr");
|
||||
const clientSecretPlaceholder = classUnchanged
|
||||
? "Leave blank to keep the currently saved secret (if any)"
|
||||
: "Leave blank for public clients / PKCE";
|
||||
? t("mcpServers.passthroughAuthorizeSection.clientSecret.placeholderKeepSaved")
|
||||
: t("mcpServers.passthroughAuthorizeSection.clientSecret.placeholderPublicClient");
|
||||
const fieldClassName = "rounded-lg border-border focus:border-info focus:ring-ring";
|
||||
const clientIdExtra = classUnchanged
|
||||
? "Set this to make everyone authorize through a specific app; required for upstreams without dynamic client registration (e.g. a pre-registered Slack app)."
|
||||
: "Switching the auth type discards the previously saved app; enter a client ID here or leave blank to use dynamic client registration.";
|
||||
? t("mcpServers.passthroughAuthorizeSection.clientId.helpKeepSaved")
|
||||
: t("mcpServers.passthroughAuthorizeSection.clientId.helpClassChanged");
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border p-4 space-y-2 mb-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Callers bring their own upstream token for this auth type, so LiteLLM never stores tokens. To preview tools and
|
||||
configure the tool allowlist, authorize against the upstream here: the token stays in this browser session only
|
||||
and is never saved to LiteLLM. An OAuth app configured below IS saved with the server, so internal users who
|
||||
authorize from the Tools page go through it.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("mcpServers.passthroughAuthorizeSection.description")}</p>
|
||||
{appMayNotMatchUpstream && (
|
||||
<p className="text-sm text-warning">
|
||||
You changed the upstream URL or endpoints; the OAuth app entered here was registered for the previous upstream
|
||||
and may not be valid. Update the client ID, or clear it to use dynamic client registration.
|
||||
</p>
|
||||
<p className="text-sm text-warning">{t("mcpServers.passthroughAuthorizeSection.appMayNotMatchUpstream")}</p>
|
||||
)}
|
||||
<MountedFormField
|
||||
label={<span className="text-sm font-medium text-foreground">OAuth Client ID (optional)</span>}
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t("mcpServers.passthroughAuthorizeSection.clientId.label")}
|
||||
</span>
|
||||
}
|
||||
name={["credentials", "client_id"]}
|
||||
help={clientIdExtra}
|
||||
>
|
||||
|
|
@ -104,7 +103,11 @@ export default function PassthroughAuthorizeSection({
|
|||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={<span className="text-sm font-medium text-foreground">OAuth Client Secret (optional)</span>}
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t("mcpServers.passthroughAuthorizeSection.clientSecret.label")}
|
||||
</span>
|
||||
}
|
||||
name={["credentials", "client_secret"]}
|
||||
>
|
||||
{(control) => (
|
||||
|
|
@ -117,9 +120,13 @@ export default function PassthroughAuthorizeSection({
|
|||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={<span className="text-sm font-medium text-foreground">Authorization URL (optional)</span>}
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t("mcpServers.passthroughAuthorizeSection.authorizationUrl.label")}
|
||||
</span>
|
||||
}
|
||||
name="authorization_url"
|
||||
help="Set this when the upstream publishes no OAuth metadata, for example an internal server behind Microsoft Entra. Leave blank to discover it from the upstream."
|
||||
help={t("mcpServers.passthroughAuthorizeSection.authorizationUrl.help")}
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
|
|
@ -130,9 +137,13 @@ export default function PassthroughAuthorizeSection({
|
|||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={<span className="text-sm font-medium text-foreground">Token URL (optional)</span>}
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t("mcpServers.passthroughAuthorizeSection.tokenUrl.label")}
|
||||
</span>
|
||||
}
|
||||
name="token_url"
|
||||
help="Token endpoint of the same authorization server. Leave blank to discover it from the upstream."
|
||||
help={t("mcpServers.passthroughAuthorizeSection.tokenUrl.help")}
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
|
|
@ -143,17 +154,27 @@ export default function PassthroughAuthorizeSection({
|
|||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={<span className="text-sm font-medium text-foreground">Scopes (optional)</span>}
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t("mcpServers.passthroughAuthorizeSection.scopes.label")}
|
||||
</span>
|
||||
}
|
||||
name={["credentials", "scopes"]}
|
||||
help="Scopes requested from the upstream when a user authorizes. Press enter or comma between scopes."
|
||||
help={t("mcpServers.passthroughAuthorizeSection.scopes.help")}
|
||||
>
|
||||
{(control) => <MultiSelect {...tagsControl(control)} placeholder="Add scopes" className="rounded-lg" />}
|
||||
{(control) => (
|
||||
<MultiSelect
|
||||
{...tagsControl(control, t)}
|
||||
placeholder={t("mcpServers.passthroughAuthorizeSection.scopes.placeholder")}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<DcrBridgeToggle authType={authType} initialChecked={dcrBridgeInitialChecked} />
|
||||
{isEditing && onRemoveStoredAppChange && (
|
||||
<Label className="items-start leading-normal font-normal text-foreground">
|
||||
<Checkbox className="mt-0.5" checked={removeStoredApp} onCheckedChange={onRemoveStoredAppChange} />
|
||||
Remove the saved OAuth app on save (the server goes back to dynamic client registration)
|
||||
{t("mcpServers.passthroughAuthorizeSection.removeStoredApp")}
|
||||
</Label>
|
||||
)}
|
||||
<Button
|
||||
|
|
@ -165,10 +186,7 @@ export default function PassthroughAuthorizeSection({
|
|||
</Button>
|
||||
{oauthFlow.error && <p className="text-sm text-destructive">{oauthFlow.error}</p>}
|
||||
{oauthFlow.status === "success" && oauthFlow.tokenResponse?.access_token && (
|
||||
<p className="text-sm text-success">
|
||||
Token held for this browser session. Tools can now be previewed and configured; the token was not saved to
|
||||
LiteLLM.
|
||||
</p>
|
||||
<p className="text-sm text-success">{t("mcpServers.passthroughAuthorizeSection.authorize.success")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,37 +1,47 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
import { type FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { SelectItem } from "@/components/ui/select";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { TRANSPORT, TRANSPORT_ITEMS } from "@/components/mcp_tools/types";
|
||||
import { $api } from "@/lib/http/api";
|
||||
import en from "@/i18n/locales/en.json";
|
||||
|
||||
export const STDIO_DISABLED_MESSAGE =
|
||||
"stdio MCP servers are disabled on this proxy. Set LITELLM_ENABLE_MCP_STDIO=true on the proxy and restart to enable them";
|
||||
export const STDIO_DISABLED_MESSAGE_KEY: ParseKeys = "mcpServers.stdioAvailability.disabledMessage";
|
||||
|
||||
export const STDIO_DISABLED_MESSAGE = en.mcpServers.stdioAvailability.disabledMessage;
|
||||
|
||||
export const useMcpStdioEnabled = (): boolean =>
|
||||
$api.useQuery("get", "/.well-known/litellm-ui-config").data?.mcp_stdio_enabled !== false;
|
||||
|
||||
export const TransportSelectItems: FC<{ stdioEnabled: boolean }> = ({ stdioEnabled }) => (
|
||||
<>
|
||||
{TRANSPORT_ITEMS.map((item) => {
|
||||
const disabled = item.value === TRANSPORT.STDIO && !stdioEnabled;
|
||||
return (
|
||||
<SelectItem key={item.value} value={item.value} disabled={disabled}>
|
||||
{item.label}
|
||||
{disabled && <SimpleTooltip content={STDIO_DISABLED_MESSAGE} className="pointer-events-auto" />}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
export const TransportSelectItems: FC<{ stdioEnabled: boolean }> = ({ stdioEnabled }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{TRANSPORT_ITEMS.map((item) => {
|
||||
const disabled = item.value === TRANSPORT.STDIO && !stdioEnabled;
|
||||
return (
|
||||
<SelectItem key={item.value} value={item.value} disabled={disabled}>
|
||||
{item.label}
|
||||
{disabled && <SimpleTooltip content={t(STDIO_DISABLED_MESSAGE_KEY)} className="pointer-events-auto" />}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const StdioDisabledBanner: FC = () => (
|
||||
<Alert variant="warning" className="mb-4 rounded-lg">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>stdio is disabled on this proxy</AlertTitle>
|
||||
<AlertDescription>
|
||||
{STDIO_DISABLED_MESSAGE}. Until then this server cannot start or be saved as stdio.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
export const StdioDisabledBanner: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Alert variant="warning" className="mb-4 rounded-lg">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>{t("mcpServers.stdioAvailability.banner.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("mcpServers.stdioAvailability.banner.description", { disabledMessage: t(STDIO_DISABLED_MESSAGE_KEY) })}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Info } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
|
||||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||
|
|
@ -30,14 +31,15 @@ const PLACEHOLDER = `{
|
|||
}`;
|
||||
|
||||
const StdioConfiguration: React.FC<StdioConfigurationProps> = ({ isVisible, required = true }) => {
|
||||
const { t } = useTranslation();
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
Stdio Configuration (JSON)
|
||||
<SimpleTooltip content="Paste your stdio MCP server configuration in JSON format. You can use the full mcpServers structure from config.yaml or just the inner server configuration.">
|
||||
{t("mcpServers.stdioConfiguration.label")}
|
||||
<SimpleTooltip content={t("mcpServers.stdioConfiguration.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
|
|
@ -46,8 +48,8 @@ const StdioConfiguration: React.FC<StdioConfigurationProps> = ({ isVisible, requ
|
|||
required={required}
|
||||
rules={{
|
||||
validate: {
|
||||
...(required ? { required: requiredRule("Please enter stdio configuration") } : {}),
|
||||
json: parsesAsJson("Please enter valid JSON"),
|
||||
...(required ? { required: requiredRule(t("mcpServers.stdioConfiguration.errors.required")) } : {}),
|
||||
json: parsesAsJson(t("mcpServers.stdioConfiguration.errors.invalidJson")),
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,53 +1,62 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
import { Info } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
|
||||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||
import { selectControl, selectTriggerControl } from "./mcpFieldRules";
|
||||
|
||||
const TOKEN_ENDPOINT_AUTH_METHOD_OPTIONS = [
|
||||
{ value: "client_secret_basic", label: "Client Secret Basic" },
|
||||
{ value: "client_secret_post", label: "Client Secret Post" },
|
||||
const TOKEN_ENDPOINT_AUTH_METHOD_OPTIONS: ReadonlyArray<{ value: string; labelKey: ParseKeys }> = [
|
||||
{ value: "client_secret_basic", labelKey: "mcpServers.tokenEndpointAuthMethodField.methods.clientSecretBasic" },
|
||||
{ value: "client_secret_post", labelKey: "mcpServers.tokenEndpointAuthMethodField.methods.clientSecretPost" },
|
||||
];
|
||||
|
||||
interface TokenEndpointAuthMethodFieldProps {
|
||||
isEditing?: boolean;
|
||||
}
|
||||
|
||||
const TokenEndpointAuthMethodField: React.FC<TokenEndpointAuthMethodFieldProps> = ({ isEditing = false }) => (
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
Token Endpoint Auth Method (optional)
|
||||
<SimpleTooltip content="How the proxy authenticates to the upstream OAuth token endpoint. Client Secret Basic sends the client credentials in an HTTP Basic Authorization header; leave blank to use the default, Client Secret Post, which sends them in the request body.">
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name={["credentials", "token_endpoint_auth_method"]}
|
||||
>
|
||||
{(control) => {
|
||||
const placeholder = isEditing
|
||||
? "Leave blank to keep existing (default Client Secret Post)"
|
||||
: "Default (Client Secret Post)";
|
||||
return (
|
||||
<Select {...selectControl<string>(control)} items={TOKEN_ENDPOINT_AUTH_METHOD_OPTIONS}>
|
||||
<SelectTrigger {...selectTriggerControl(control)} className="w-full rounded-lg">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null}>{placeholder}</SelectItem>
|
||||
{TOKEN_ENDPOINT_AUTH_METHOD_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}}
|
||||
</MountedFormField>
|
||||
);
|
||||
const TokenEndpointAuthMethodField: React.FC<TokenEndpointAuthMethodFieldProps> = ({ isEditing = false }) => {
|
||||
const { t } = useTranslation();
|
||||
const authMethodOptions = TOKEN_ENDPOINT_AUTH_METHOD_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey),
|
||||
}));
|
||||
return (
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
{t("mcpServers.tokenEndpointAuthMethodField.label")}
|
||||
<SimpleTooltip content={t("mcpServers.tokenEndpointAuthMethodField.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name={["credentials", "token_endpoint_auth_method"]}
|
||||
>
|
||||
{(control) => {
|
||||
const placeholder = isEditing
|
||||
? t("mcpServers.tokenEndpointAuthMethodField.placeholderEditing")
|
||||
: t("mcpServers.tokenEndpointAuthMethodField.placeholder");
|
||||
return (
|
||||
<Select {...selectControl<string>(control)} items={authMethodOptions}>
|
||||
<SelectTrigger {...selectTriggerControl(control)} className="w-full rounded-lg">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null}>{placeholder}</SelectItem>
|
||||
{authMethodOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}}
|
||||
</MountedFormField>
|
||||
);
|
||||
};
|
||||
|
||||
export default TokenEndpointAuthMethodField;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { MultiSelect } from "@/components/shared/MultiSelect";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||
import UpstreamTokenHeaderField from "./UpstreamTokenHeaderField";
|
||||
|
|
@ -18,11 +19,6 @@ interface TokenExchangeFormFieldsProps {
|
|||
|
||||
const fieldClassName = "rounded-lg border-border focus:border-info focus:ring-ring";
|
||||
|
||||
const TOKEN_EXCHANGE_PROFILE_ITEMS = [
|
||||
{ value: "rfc8693", label: "RFC 8693 (standard)" },
|
||||
{ value: "entra_obo", label: "Microsoft Entra OBO" },
|
||||
];
|
||||
|
||||
const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, tooltip }) => (
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
{label}
|
||||
|
|
@ -33,7 +29,11 @@ const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, toolt
|
|||
);
|
||||
|
||||
const TokenExchangeFormFields: React.FC<TokenExchangeFormFieldsProps> = ({ isEditing = false }) => {
|
||||
const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : "";
|
||||
const { t } = useTranslation();
|
||||
const tokenExchangeProfileItems = [
|
||||
{ value: "rfc8693", label: t("mcpServers.tokenExchangeFormFields.profile.rfc8693") },
|
||||
{ value: "entra_obo", label: "Microsoft Entra OBO" },
|
||||
];
|
||||
const isEntraObo = useWatch({ name: "token_exchange_profile" }) === "entra_obo";
|
||||
const requiredWhenCreating = (message: string) =>
|
||||
isEditing ? undefined : { validate: { required: requiredRule(message) } };
|
||||
|
|
@ -43,20 +43,20 @@ const TokenExchangeFormFields: React.FC<TokenExchangeFormFieldsProps> = ({ isEdi
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Profile"
|
||||
tooltip="Token-exchange wire dialect. RFC 8693 is the standard token-exchange grant. Microsoft Entra OBO uses Entra's On-Behalf-Of dialect (the RFC 7523 jwt-bearer grant with requested_token_use=on_behalf_of) and carries the target resource in a scope like api://<app-id>/.default."
|
||||
label={t("mcpServers.tokenExchangeFormFields.profile.label")}
|
||||
tooltip={t("mcpServers.tokenExchangeFormFields.profile.tooltip")}
|
||||
/>
|
||||
}
|
||||
name="token_exchange_profile"
|
||||
{...(isEditing ? {} : { defaultValue: "rfc8693" })}
|
||||
>
|
||||
{(control) => (
|
||||
<Select {...selectControl<string>(control)} items={TOKEN_EXCHANGE_PROFILE_ITEMS}>
|
||||
<Select {...selectControl<string>(control)} items={tokenExchangeProfileItems}>
|
||||
<SelectTrigger {...selectTriggerControl(control)} className="w-full rounded-lg">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TOKEN_EXCHANGE_PROFILE_ITEMS.map((item) => (
|
||||
{tokenExchangeProfileItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</SelectItem>
|
||||
|
|
@ -68,8 +68,8 @@ const TokenExchangeFormFields: React.FC<TokenExchangeFormFieldsProps> = ({ isEdi
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Token Exchange Endpoint (optional)"
|
||||
tooltip="RFC 8693 token endpoint. The proxy exchanges the user's incoming token here for a scoped token used to call the upstream MCP server. Leave blank to auto-discover it from the upstream's protected-resource metadata (RFC 9728 then RFC 8414)."
|
||||
label={t("mcpServers.tokenExchangeFormFields.endpoint.label")}
|
||||
tooltip={t("mcpServers.tokenExchangeFormFields.endpoint.tooltip")}
|
||||
/>
|
||||
}
|
||||
name="token_exchange_endpoint"
|
||||
|
|
@ -85,18 +85,22 @@ const TokenExchangeFormFields: React.FC<TokenExchangeFormFieldsProps> = ({ isEdi
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Client ID"
|
||||
tooltip="OAuth2 client ID used to authenticate to the token exchange endpoint."
|
||||
label={t("mcpServers.tokenExchangeFormFields.clientId.label")}
|
||||
tooltip={t("mcpServers.tokenExchangeFormFields.clientId.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={["credentials", "client_id"]}
|
||||
required={!isEditing}
|
||||
rules={requiredWhenCreating("Client ID is required for token exchange")}
|
||||
rules={requiredWhenCreating(t("mcpServers.tokenExchangeFormFields.clientId.required"))}
|
||||
>
|
||||
{(control) => (
|
||||
<PasswordInput
|
||||
{...textControl(control)}
|
||||
placeholder={`Enter OAuth client ID${placeholderSuffix}`}
|
||||
placeholder={
|
||||
isEditing
|
||||
? t("mcpServers.tokenExchangeFormFields.clientId.placeholderEditing")
|
||||
: t("mcpServers.tokenExchangeFormFields.clientId.placeholder")
|
||||
}
|
||||
groupClassName={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -104,18 +108,22 @@ const TokenExchangeFormFields: React.FC<TokenExchangeFormFieldsProps> = ({ isEdi
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Client Secret"
|
||||
tooltip="OAuth2 client secret used to authenticate to the token exchange endpoint."
|
||||
label={t("mcpServers.tokenExchangeFormFields.clientSecret.label")}
|
||||
tooltip={t("mcpServers.tokenExchangeFormFields.clientSecret.tooltip")}
|
||||
/>
|
||||
}
|
||||
name={["credentials", "client_secret"]}
|
||||
required={!isEditing}
|
||||
rules={requiredWhenCreating("Client Secret is required for token exchange")}
|
||||
rules={requiredWhenCreating(t("mcpServers.tokenExchangeFormFields.clientSecret.required"))}
|
||||
>
|
||||
{(control) => (
|
||||
<PasswordInput
|
||||
{...textControl(control)}
|
||||
placeholder={`Enter OAuth client secret${placeholderSuffix}`}
|
||||
placeholder={
|
||||
isEditing
|
||||
? t("mcpServers.tokenExchangeFormFields.clientSecret.placeholderEditing")
|
||||
: t("mcpServers.tokenExchangeFormFields.clientSecret.placeholder")
|
||||
}
|
||||
groupClassName={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -125,8 +133,8 @@ const TokenExchangeFormFields: React.FC<TokenExchangeFormFieldsProps> = ({ isEdi
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Audience (optional)"
|
||||
tooltip="Target audience for the exchanged token (RFC 8693 audience). Identifies the upstream MCP server the token is for."
|
||||
label={t("mcpServers.tokenExchangeFormFields.audience.label")}
|
||||
tooltip={t("mcpServers.tokenExchangeFormFields.audience.tooltip")}
|
||||
/>
|
||||
}
|
||||
name="audience"
|
||||
|
|
@ -138,8 +146,8 @@ const TokenExchangeFormFields: React.FC<TokenExchangeFormFieldsProps> = ({ isEdi
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Subject Token Type (optional)"
|
||||
tooltip="Type of the user's incoming token (RFC 8693 subject_token_type). Defaults to urn:ietf:params:oauth:token-type:access_token."
|
||||
label={t("mcpServers.tokenExchangeFormFields.subjectTokenType.label")}
|
||||
tooltip={t("mcpServers.tokenExchangeFormFields.subjectTokenType.tooltip")}
|
||||
/>
|
||||
}
|
||||
name="subject_token_type"
|
||||
|
|
@ -157,11 +165,15 @@ const TokenExchangeFormFields: React.FC<TokenExchangeFormFieldsProps> = ({ isEdi
|
|||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
label={isEntraObo ? "Scopes" : "Scopes (optional)"}
|
||||
label={
|
||||
isEntraObo
|
||||
? t("mcpServers.tokenExchangeFormFields.scopes.labelEntraObo")
|
||||
: t("mcpServers.tokenExchangeFormFields.scopes.label")
|
||||
}
|
||||
tooltip={
|
||||
isEntraObo
|
||||
? "Microsoft Entra OBO carries the target resource in the scope, so at least one is required (e.g. api://<app-id>/.default)."
|
||||
: "Optional scopes to request during the token exchange."
|
||||
? t("mcpServers.tokenExchangeFormFields.scopes.tooltipEntraObo")
|
||||
: t("mcpServers.tokenExchangeFormFields.scopes.tooltip")
|
||||
}
|
||||
/>
|
||||
}
|
||||
|
|
@ -171,7 +183,7 @@ const TokenExchangeFormFields: React.FC<TokenExchangeFormFieldsProps> = ({ isEdi
|
|||
isEntraObo
|
||||
? {
|
||||
validate: {
|
||||
required: requiredRule("Microsoft Entra OBO requires a scope, e.g. api://<app-id>/.default"),
|
||||
required: requiredRule(t("mcpServers.tokenExchangeFormFields.scopes.requiredEntraObo")),
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
|
|
@ -179,8 +191,10 @@ const TokenExchangeFormFields: React.FC<TokenExchangeFormFieldsProps> = ({ isEdi
|
|||
>
|
||||
{(control) => (
|
||||
<MultiSelect
|
||||
{...tagsControl(control)}
|
||||
placeholder={isEntraObo ? "api://<app-id>/.default" : "Add scopes"}
|
||||
{...tagsControl(control, t)}
|
||||
placeholder={
|
||||
isEntraObo ? "api://<app-id>/.default" : t("mcpServers.tokenExchangeFormFields.scopes.placeholder")
|
||||
}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import React from "react";
|
||||
import { useForm, type Control } from "react-hook-form";
|
||||
import type { ParseKeys, TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -33,14 +35,14 @@ const argumentLabel = (field: ToolArgumentField): React.ReactNode => (
|
|||
</span>
|
||||
);
|
||||
|
||||
const BOOLEAN_ITEMS = [
|
||||
{ value: true, label: "True" },
|
||||
{ value: false, label: "False" },
|
||||
const BOOLEAN_ITEMS: readonly { readonly value: boolean; readonly labelKey: ParseKeys }[] = [
|
||||
{ value: true, labelKey: "mcpServers.toolArgumentsForm.boolean.true" },
|
||||
{ value: false, labelKey: "mcpServers.toolArgumentsForm.boolean.false" },
|
||||
];
|
||||
|
||||
const booleanTitle = (value: unknown): string | undefined => {
|
||||
if (value === true) return "True";
|
||||
if (value === false) return "False";
|
||||
const booleanTitle = (value: unknown, t: TFunction): string | undefined => {
|
||||
if (value === true) return t("mcpServers.toolArgumentsForm.boolean.true");
|
||||
if (value === false) return t("mcpServers.toolArgumentsForm.boolean.false");
|
||||
return undefined;
|
||||
};
|
||||
|
||||
|
|
@ -49,8 +51,11 @@ const JsonArgumentControl: React.FC<{
|
|||
prop: InputSchemaProperty;
|
||||
control: FormFieldControlProps<ToolArgumentsFormValues, `args.${number}`>;
|
||||
}> = ({ field, prop, control }) => {
|
||||
const { t } = useTranslation();
|
||||
const isObject = prop.type === "object";
|
||||
const fallbackPlaceholder = isObject ? `Enter JSON object for ${field.key}` : `Enter JSON array for ${field.key}`;
|
||||
const fallbackPlaceholder = isObject
|
||||
? t("mcpServers.toolArgumentsForm.jsonObjectPlaceholder", { field: field.key })
|
||||
: t("mcpServers.toolArgumentsForm.jsonArrayPlaceholder", { field: field.key });
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
|
|
@ -63,7 +68,7 @@ const JsonArgumentControl: React.FC<{
|
|||
className="rounded-lg font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isObject ? "Provide a valid JSON object." : "Provide a valid JSON array."}
|
||||
{isObject ? t("mcpServers.toolArgumentsForm.jsonObjectHint") : t("mcpServers.toolArgumentsForm.jsonArrayHint")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -73,6 +78,7 @@ const ToolArgumentControl: React.FC<{
|
|||
field: ToolArgumentField;
|
||||
control: FormFieldControlProps<ToolArgumentsFormValues, `args.${number}`>;
|
||||
}> = ({ field, control }) => {
|
||||
const { t } = useTranslation();
|
||||
const prop = resolveSchemaProperty(field.prop);
|
||||
|
||||
if (prop.type === "string" && prop.enum) {
|
||||
|
|
@ -84,11 +90,11 @@ const ToolArgumentControl: React.FC<{
|
|||
className="w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm shadow-xs transition-colors focus:border-ring focus:ring-3 focus:ring-ring/50 focus:outline-hidden"
|
||||
>
|
||||
<option value={-1} disabled={field.required}>
|
||||
Select {field.key}
|
||||
{t("mcpServers.toolArgumentsForm.selectPlaceholder", { field: field.key })}
|
||||
</option>
|
||||
{prop.enum.map((option, index) => (
|
||||
<option key={option} value={index}>
|
||||
{option === "" ? "Empty string" : option}
|
||||
{option === "" ? t("mcpServers.toolArgumentsForm.emptyString") : option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -102,31 +108,33 @@ const ToolArgumentControl: React.FC<{
|
|||
type="number"
|
||||
step={prop.type === "integer" ? 1 : "any"}
|
||||
value={(control.value as number | string) ?? ""}
|
||||
placeholder={prop.description || `Enter ${field.key}`}
|
||||
placeholder={prop.description || t("mcpServers.toolArgumentsForm.enterPlaceholder", { field: field.key })}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (prop.type === "boolean") {
|
||||
const booleanItems = BOOLEAN_ITEMS.map(({ value, labelKey }) => ({ value, label: t(labelKey) }));
|
||||
const selectPlaceholder = t("mcpServers.toolArgumentsForm.selectPlaceholder", { field: field.key });
|
||||
return (
|
||||
<Select
|
||||
items={field.required ? BOOLEAN_ITEMS : [{ value: null, label: `Select ${field.key}` }, ...BOOLEAN_ITEMS]}
|
||||
items={field.required ? booleanItems : [{ value: null, label: selectPlaceholder }, ...booleanItems]}
|
||||
value={control.value ?? null}
|
||||
onValueChange={control.onChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={control.id}
|
||||
aria-invalid={control["aria-invalid"]}
|
||||
title={booleanTitle(control.value)}
|
||||
title={booleanTitle(control.value, t)}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue placeholder={`Select ${field.key}`} />
|
||||
<SelectValue placeholder={selectPlaceholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{!field.required && <SelectItem value={null}>Select {field.key}</SelectItem>}
|
||||
<SelectItem value={true}>True</SelectItem>
|
||||
<SelectItem value={false}>False</SelectItem>
|
||||
{!field.required && <SelectItem value={null}>{selectPlaceholder}</SelectItem>}
|
||||
<SelectItem value={true}>{t("mcpServers.toolArgumentsForm.boolean.true")}</SelectItem>
|
||||
<SelectItem value={false}>{t("mcpServers.toolArgumentsForm.boolean.false")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
|
@ -140,7 +148,7 @@ const ToolArgumentControl: React.FC<{
|
|||
<Input
|
||||
{...control}
|
||||
value={(control.value as string) ?? ""}
|
||||
placeholder={prop.description || `Enter ${field.key}`}
|
||||
placeholder={prop.description || t("mcpServers.toolArgumentsForm.enterPlaceholder", { field: field.key })}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
);
|
||||
|
|
@ -151,6 +159,7 @@ const ToolArgumentFields: React.FC<{
|
|||
control: Control<ToolArgumentsFormValues>;
|
||||
singleInputFallback: boolean;
|
||||
}> = ({ fields, control, singleInputFallback }) => {
|
||||
const { t } = useTranslation();
|
||||
if (singleInputFallback) {
|
||||
return (
|
||||
<FieldGroup>
|
||||
|
|
@ -159,7 +168,7 @@ const ToolArgumentFields: React.FC<{
|
|||
name="args.0"
|
||||
label={
|
||||
<span>
|
||||
Input <span className="text-destructive">*</span>
|
||||
{t("mcpServers.toolArgumentsForm.singleInput.label")} <span className="text-destructive">*</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
|
|
@ -167,7 +176,7 @@ const ToolArgumentFields: React.FC<{
|
|||
<Input
|
||||
{...field}
|
||||
value={(field.value as string) ?? ""}
|
||||
placeholder="Enter input for this tool"
|
||||
placeholder={t("mcpServers.toolArgumentsForm.singleInput.placeholder")}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -180,8 +189,10 @@ const ToolArgumentFields: React.FC<{
|
|||
return (
|
||||
<div className="rounded-lg border border-border bg-muted py-6 text-center">
|
||||
<div className="mx-auto max-w-sm">
|
||||
<h4 className="mb-1 text-sm font-medium text-foreground">No Parameters Required</h4>
|
||||
<p className="text-xs text-muted-foreground">This tool can be called without any input parameters.</p>
|
||||
<h4 className="mb-1 text-sm font-medium text-foreground">
|
||||
{t("mcpServers.toolArgumentsForm.noParameters.title")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">{t("mcpServers.toolArgumentsForm.noParameters.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -203,9 +214,11 @@ const ToolArgumentFields: React.FC<{
|
|||
);
|
||||
};
|
||||
|
||||
const callButtonLabel = (isLoading: boolean, hasRun: boolean): string => {
|
||||
if (isLoading) return "Calling Tool...";
|
||||
return hasRun ? "Call Again" : "Call Tool";
|
||||
const callButtonLabel = (isLoading: boolean, hasRun: boolean, t: TFunction): string => {
|
||||
if (isLoading) return t("mcpServers.toolArgumentsForm.callButton.calling");
|
||||
return hasRun
|
||||
? t("mcpServers.toolArgumentsForm.callButton.callAgain")
|
||||
: t("mcpServers.toolArgumentsForm.callButton.call");
|
||||
};
|
||||
|
||||
export const ToolArgumentsForm: React.FC<{
|
||||
|
|
@ -215,9 +228,10 @@ export const ToolArgumentsForm: React.FC<{
|
|||
hasRun: boolean;
|
||||
onRun: (args: Record<string, unknown>) => void;
|
||||
}> = ({ fields, singleInputFallback, isLoading, hasRun, onRun }) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useForm<ToolArgumentsFormValues>({
|
||||
defaultValues: { args: initialArgumentValues(fields) },
|
||||
resolver: toolArgumentsResolver(fields),
|
||||
resolver: toolArgumentsResolver(fields, t),
|
||||
});
|
||||
|
||||
const submit = form.handleSubmit((values) => onRun(buildToolCallArguments(fields, values.args)));
|
||||
|
|
@ -236,7 +250,7 @@ export const ToolArgumentsForm: React.FC<{
|
|||
className="w-full"
|
||||
>
|
||||
{isLoading && <UiLoadingSpinner className="size-4" />}
|
||||
{callButtonLabel(isLoading, hasRun)}
|
||||
{callButtonLabel(isLoading, hasRun, t)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { CircleHelp, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
|
@ -24,6 +25,7 @@ export function ToolTestPanel({
|
|||
error: Error | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [viewMode, setViewMode] = React.useState<"formatted" | "json">("formatted");
|
||||
const [startTime, setStartTime] = React.useState<number | null>(null);
|
||||
const [duration, setDuration] = React.useState<number | null>(null);
|
||||
|
|
@ -37,14 +39,14 @@ export function ToolTestPanel({
|
|||
properties: {
|
||||
input: {
|
||||
type: "string",
|
||||
description: "Input for this tool",
|
||||
description: t("mcpServers.toolTestPanel.defaultInputDescription"),
|
||||
},
|
||||
},
|
||||
required: ["input"],
|
||||
};
|
||||
}
|
||||
return tool.inputSchema as InputSchema;
|
||||
}, [tool.inputSchema]);
|
||||
}, [tool.inputSchema, t]);
|
||||
|
||||
// Check if this is a nested params structure and extract the actual parameters
|
||||
const actualSchema: InputSchema = React.useMemo(() => {
|
||||
|
|
@ -118,18 +120,18 @@ export function ToolTestPanel({
|
|||
const handleCopyResult = async () => {
|
||||
const success = await copyToClipboard(JSON.stringify(result, null, 2));
|
||||
if (success) {
|
||||
toast.success("Result copied to clipboard");
|
||||
toast.success(t("mcpServers.toolTestPanel.toasts.resultCopied"));
|
||||
} else {
|
||||
toast.fromError("Failed to copy result");
|
||||
toast.fromError(t("mcpServers.toolTestPanel.toasts.resultCopyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyToolName = async () => {
|
||||
const success = await copyToClipboard(tool.name);
|
||||
if (success) {
|
||||
toast.success("Tool name copied to clipboard");
|
||||
toast.success(t("mcpServers.toolTestPanel.toasts.toolNameCopied"));
|
||||
} else {
|
||||
toast.fromError("Failed to copy tool name");
|
||||
toast.fromError(t("mcpServers.toolTestPanel.toasts.toolNameCopyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -142,17 +144,17 @@ export function ToolTestPanel({
|
|||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolveLogoSrc(tool.mcp_info.logo_url)}
|
||||
alt={`${tool.mcp_info.server_name} logo`}
|
||||
alt={t("mcpServers.toolTestPanel.logoAlt", { serverName: tool.mcp_info.server_name })}
|
||||
className="w-6 h-6 object-contain"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<h2 className="text-lg font-semibold text-foreground">Test Tool:</h2>
|
||||
<h2 className="text-lg font-semibold text-foreground">{t("mcpServers.toolTestPanel.title")}</h2>
|
||||
<div
|
||||
className="group inline-flex items-center space-x-1 bg-muted hover:bg-accent px-3 py-1 rounded-md cursor-pointer transition-colors border border-border"
|
||||
onClick={handleCopyToolName}
|
||||
title="Click to copy tool name"
|
||||
title={t("mcpServers.toolTestPanel.copyToolNameTitle")}
|
||||
>
|
||||
<span className="font-mono text-foreground font-medium text-sm">{tool.name}</span>
|
||||
<svg
|
||||
|
|
@ -171,14 +173,16 @@ export function ToolTestPanel({
|
|||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{tool.description}</p>
|
||||
<p className="text-xs text-muted-foreground">Provider: {tool.mcp_info.server_name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("mcpServers.toolTestPanel.provider", { serverName: tool.mcp_info.server_name })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Close"
|
||||
aria-label={t("mcpServers.toolTestPanel.close")}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
|
|
@ -191,13 +195,13 @@ export function ToolTestPanel({
|
|||
<div className="bg-card border border-border rounded-lg">
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground">Input Parameters</h3>
|
||||
<h3 className="text-sm font-semibold text-foreground">{t("mcpServers.toolTestPanel.inputParameters")}</h3>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<CircleHelp className="size-4 cursor-help text-muted-foreground hover:text-foreground" />}
|
||||
/>
|
||||
<TooltipContent>Configure the input parameters for this tool call</TooltipContent>
|
||||
<TooltipContent>{t("mcpServers.toolTestPanel.inputParametersTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
|
@ -218,7 +222,7 @@ export function ToolTestPanel({
|
|||
{/* Right Column - Tool Result */}
|
||||
<div className="bg-card border border-border rounded-lg">
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">Tool Result</h3>
|
||||
<h3 className="text-sm font-semibold text-foreground">{t("mcpServers.toolTestPanel.toolResult")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
|
|
@ -241,9 +245,11 @@ export function ToolTestPanel({
|
|||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 className="text-sm font-medium text-foreground mb-1">Ready to Call Tool</h4>
|
||||
<h4 className="text-sm font-medium text-foreground mb-1">
|
||||
{t("mcpServers.toolTestPanel.empty.title")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Configure the input parameters and click "Call Tool" to see the results here.
|
||||
{t("mcpServers.toolTestPanel.empty.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -262,9 +268,11 @@ export function ToolTestPanel({
|
|||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<h4 className="text-xs font-medium text-success">Tool executed successfully</h4>
|
||||
<h4 className="text-xs font-medium text-success">{t("mcpServers.toolTestPanel.success")}</h4>
|
||||
{duration !== null && (
|
||||
<span className="text-xs text-success ml-1">• {(duration / 1000).toFixed(2)}s</span>
|
||||
<span className="text-xs text-success ml-1">
|
||||
{t("mcpServers.toolTestPanel.duration", { seconds: (duration / 1000).toFixed(2) })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -278,7 +286,7 @@ export function ToolTestPanel({
|
|||
: "text-success hover:text-success/80"
|
||||
}`}
|
||||
>
|
||||
Formatted
|
||||
{t("mcpServers.toolTestPanel.formatted")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("json")}
|
||||
|
|
@ -293,7 +301,7 @@ export function ToolTestPanel({
|
|||
<button
|
||||
onClick={handleCopyResult}
|
||||
className="p-1 hover:bg-success/15 rounded-sm text-success"
|
||||
title="Copy response"
|
||||
title={t("mcpServers.toolTestPanel.copyResponse")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
@ -322,8 +330,10 @@ export function ToolTestPanel({
|
|||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-border"></div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-info border-t-transparent absolute top-0"></div>
|
||||
</div>
|
||||
<p className="text-sm font-medium mt-3">Calling tool...</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Please wait while we process your request</p>
|
||||
<p className="text-sm font-medium mt-3">{t("mcpServers.toolTestPanel.calling.title")}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("mcpServers.toolTestPanel.calling.description")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -347,9 +357,13 @@ export function ToolTestPanel({
|
|||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<h4 className="text-xs font-medium text-destructive">Tool Call Failed</h4>
|
||||
<h4 className="text-xs font-medium text-destructive">
|
||||
{t("mcpServers.toolTestPanel.failed")}
|
||||
</h4>
|
||||
{duration !== null && (
|
||||
<span className="text-xs text-destructive">• {(duration / 1000).toFixed(2)}s</span>
|
||||
<span className="text-xs text-destructive">
|
||||
{t("mcpServers.toolTestPanel.duration", { seconds: (duration / 1000).toFixed(2) })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-card border border-destructive/20 rounded-sm p-2 max-h-48 overflow-y-auto">
|
||||
|
|
@ -374,7 +388,7 @@ export function ToolTestPanel({
|
|||
<div>
|
||||
<div className="bg-muted px-3 py-1 border-b border-border">
|
||||
<span className="text-xs font-medium text-foreground uppercase tracking-wide">
|
||||
Text Response
|
||||
{t("mcpServers.toolTestPanel.textResponse")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
|
|
@ -463,7 +477,7 @@ export function ToolTestPanel({
|
|||
<div>
|
||||
<div className="bg-muted px-3 py-1 border-b border-border">
|
||||
<span className="text-xs font-medium text-foreground uppercase tracking-wide">
|
||||
Image Response
|
||||
{t("mcpServers.toolTestPanel.imageResponse")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
|
|
@ -471,7 +485,7 @@ export function ToolTestPanel({
|
|||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={content.url}
|
||||
alt="Tool result"
|
||||
alt={t("mcpServers.toolTestPanel.imageAlt")}
|
||||
className="max-w-full h-auto rounded-sm shadow-xs"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -483,7 +497,7 @@ export function ToolTestPanel({
|
|||
<div>
|
||||
<div className="bg-muted px-3 py-1 border-b border-border">
|
||||
<span className="text-xs font-medium text-foreground uppercase tracking-wide">
|
||||
Embedded Resource
|
||||
{t("mcpServers.toolTestPanel.embeddedResource")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
|
|
@ -505,7 +519,9 @@ export function ToolTestPanel({
|
|||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-medium text-info">
|
||||
Resource Type: {content.resource_type}
|
||||
{t("mcpServers.toolTestPanel.resourceType", {
|
||||
resourceType: content.resource_type,
|
||||
})}
|
||||
</p>
|
||||
{content.url && (
|
||||
<a
|
||||
|
|
@ -514,7 +530,7 @@ export function ToolTestPanel({
|
|||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center text-xs text-info hover:underline mt-1"
|
||||
>
|
||||
View Resource
|
||||
{t("mcpServers.toolTestPanel.viewResource")}
|
||||
<svg className="ml-1 h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
|
||||
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { AUTH_TYPE } from "@/components/mcp_tools/types";
|
||||
|
|
@ -9,17 +10,13 @@ import { AUTH_TYPE } from "@/components/mcp_tools/types";
|
|||
* that server, so callers reach the upstream without a LiteLLM identity.
|
||||
*/
|
||||
export default function TruePassthroughWarning({ authType }: { authType?: string | null }) {
|
||||
const { t } = useTranslation();
|
||||
if (authType !== AUTH_TYPE.TRUE_PASSTHROUGH) return null;
|
||||
return (
|
||||
<Alert className="mb-4">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>True Passthrough disables LiteLLM authentication for this server</AlertTitle>
|
||||
<AlertDescription>
|
||||
Anyone who can reach the gateway can call this server without a LiteLLM key. The caller's Authorization
|
||||
header is forwarded to the upstream verbatim, per-key and per-team rate limits and spend tracking do not apply,
|
||||
and the upstream is fully responsible for authenticating callers. Choose OAuth Delegate instead if callers
|
||||
should still authenticate to LiteLLM.
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t("mcpServers.truePassthroughWarning.title")}</AlertTitle>
|
||||
<AlertDescription>{t("mcpServers.truePassthroughWarning.description")}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,35 @@
|
|||
import { Info } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||
import { textControl } from "./mcpFieldRules";
|
||||
|
||||
const UpstreamTokenHeaderField: React.FC = () => (
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
Token Header (optional)
|
||||
<SimpleTooltip content="Which upstream header carries the token LiteLLM resolves for this server. Leave blank to send it as 'Authorization: Bearer <token>', which is the default and what most servers expect. Set a header name when the upstream expects it elsewhere, for example an API gateway that terminates its own credential on 'esb-oauth' while a separate Authorization from Static Headers passes through to the server behind it.">
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name={["credentials", "upstream_token_header"]}
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="Authorization"
|
||||
className="rounded-lg border-border focus:border-info focus:ring-ring"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
);
|
||||
const UpstreamTokenHeaderField: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
{t("mcpServers.upstreamTokenHeaderField.label")}
|
||||
<SimpleTooltip content={t("mcpServers.upstreamTokenHeaderField.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name={["credentials", "upstream_token_header"]}
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="Authorization"
|
||||
className="rounded-lg border-border focus:border-info focus:ring-ring"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpstreamTokenHeaderField;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import React from "react";
|
|||
import { CircleAlert, Info } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MCPServer, MCPUserEnvVarsStatus, MCPUserEnvVarSpec } from "@/components/mcp_tools/types";
|
||||
import { clearMCPUserEnvVars, getMCPUserEnvVars, storeMCPUserEnvVars } from "@/components/networking";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
|
@ -40,10 +42,15 @@ interface UserEnvVarsFormProps {
|
|||
onSubmit: (values: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
const buildSchema = (required: readonly MCPUserEnvVarSpec[]) =>
|
||||
const buildSchema = (required: readonly MCPUserEnvVarSpec[], t: TFunction) =>
|
||||
z.object(
|
||||
Object.fromEntries(
|
||||
required.map((spec) => [spec.name, spec.is_set ? z.string() : z.string().min(1, `${spec.name} is required`)]),
|
||||
required.map((spec) => [
|
||||
spec.name,
|
||||
spec.is_set
|
||||
? z.string()
|
||||
: z.string().min(1, t("mcpServers.userEnvVarsModal.form.required", { name: spec.name })),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -51,7 +58,8 @@ const emptyValues = (required: readonly MCPUserEnvVarSpec[]): Record<string, str
|
|||
Object.fromEntries(required.map((spec) => [spec.name, ""]));
|
||||
|
||||
const UserEnvVarsForm: React.FC<UserEnvVarsFormProps> = ({ required, isSaving, onCancel, onClear, onSubmit }) => {
|
||||
const form = useZodForm(buildSchema(required), { defaultValues: emptyValues(required) });
|
||||
const { t } = useTranslation();
|
||||
const form = useZodForm(buildSchema(required, t), { defaultValues: emptyValues(required) });
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
|
|
@ -65,7 +73,7 @@ const UserEnvVarsForm: React.FC<UserEnvVarsFormProps> = ({ required, isSaving, o
|
|||
label={
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-semibold">{spec.name}</span>
|
||||
{spec.is_set && <Badge variant="secondary">Set</Badge>}
|
||||
{spec.is_set && <Badge variant="secondary">{t("mcpServers.userEnvVarsModal.form.setBadge")}</Badge>}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
|
|
@ -74,7 +82,9 @@ const UserEnvVarsForm: React.FC<UserEnvVarsFormProps> = ({ required, isSaving, o
|
|||
{...field}
|
||||
disabled={isSaving}
|
||||
placeholder={
|
||||
spec.is_set ? "Enter a new value to overwrite" : spec.description || `Enter your ${spec.name}`
|
||||
spec.is_set
|
||||
? t("mcpServers.userEnvVarsModal.form.overwritePlaceholder")
|
||||
: spec.description || t("mcpServers.userEnvVarsModal.form.placeholder", { name: spec.name })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -84,15 +94,15 @@ const UserEnvVarsForm: React.FC<UserEnvVarsFormProps> = ({ required, isSaving, o
|
|||
<div className="mt-6 flex items-center justify-end gap-2 border-t border-border pt-2">
|
||||
{onClear && (
|
||||
<Button type="button" variant="destructive" className="mr-auto" onClick={onClear} disabled={isSaving}>
|
||||
Clear
|
||||
{t("mcpServers.userEnvVarsModal.form.clear")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
{t("mcpServers.userEnvVarsModal.form.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving && <UiLoadingSpinner className="mr-2 size-4" />}
|
||||
Save Credentials
|
||||
{t("mcpServers.userEnvVarsModal.form.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -107,6 +117,7 @@ const UserEnvVarsForm: React.FC<UserEnvVarsFormProps> = ({ required, isSaving, o
|
|||
* description as the placeholder.
|
||||
*/
|
||||
const UserEnvVarsModal: React.FC<UserEnvVarsModalProps> = ({ server, open, accessToken, onClose, onSaved }) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [confirmingClear, setConfirmingClear] = React.useState(false);
|
||||
const close = () => {
|
||||
|
|
@ -128,12 +139,14 @@ const UserEnvVarsModal: React.FC<UserEnvVarsModalProps> = ({ server, open, acces
|
|||
mutationFn: (values: Record<string, string>) => storeMCPUserEnvVars(accessToken!, server!.server_id, values),
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(queryKey, saved);
|
||||
toast.success("Credentials saved");
|
||||
toast.success(t("mcpServers.userEnvVarsModal.toasts.saved"));
|
||||
onSaved?.(saved);
|
||||
close();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.fromError(`Failed to save env vars: ${err instanceof Error ? err.message : String(err)}`);
|
||||
toast.fromError(
|
||||
t("mcpServers.userEnvVarsModal.toasts.saveFailed", { error: err instanceof Error ? err.message : String(err) }),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -141,12 +154,16 @@ const UserEnvVarsModal: React.FC<UserEnvVarsModalProps> = ({ server, open, acces
|
|||
mutationFn: () => clearMCPUserEnvVars(accessToken!, server!.server_id),
|
||||
onSuccess: (cleared) => {
|
||||
queryClient.setQueryData(queryKey, cleared);
|
||||
toast.success("Credentials cleared");
|
||||
toast.success(t("mcpServers.userEnvVarsModal.toasts.cleared"));
|
||||
onSaved?.(cleared);
|
||||
close();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.fromError(`Failed to clear env vars: ${err instanceof Error ? err.message : String(err)}`);
|
||||
toast.fromError(
|
||||
t("mcpServers.userEnvVarsModal.toasts.clearFailed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -159,7 +176,8 @@ const UserEnvVarsModal: React.FC<UserEnvVarsModalProps> = ({ server, open, acces
|
|||
saveMutation.mutate(trimmed);
|
||||
};
|
||||
|
||||
const displayName = server?.server_name || server?.alias || server?.server_id || "MCP Server";
|
||||
const displayName =
|
||||
server?.server_name || server?.alias || server?.server_id || t("mcpServers.userEnvVarsModal.fallbackServerName");
|
||||
const required = status?.required ?? [];
|
||||
const isSaving = saveMutation.isPending || clearMutation.isPending;
|
||||
const canClear = !!server && !!accessToken && required.some((spec) => spec.is_set);
|
||||
|
|
@ -173,8 +191,8 @@ const UserEnvVarsModal: React.FC<UserEnvVarsModalProps> = ({ server, open, acces
|
|||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<DialogTitle className="text-base font-semibold">Set your credentials</DialogTitle>
|
||||
<StatusBadge tone="info" label="Per-user" />
|
||||
<DialogTitle className="text-base font-semibold">{t("mcpServers.userEnvVarsModal.title")}</DialogTitle>
|
||||
<StatusBadge tone="info" label={t("mcpServers.userEnvVarsModal.perUserBadge")} />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{displayName}</span>
|
||||
</DialogHeader>
|
||||
|
|
@ -187,20 +205,16 @@ const UserEnvVarsModal: React.FC<UserEnvVarsModalProps> = ({ server, open, acces
|
|||
) : isError ? (
|
||||
<Alert variant="error">
|
||||
<CircleAlert />
|
||||
<AlertTitle>Failed to load env vars</AlertTitle>
|
||||
<AlertTitle>{t("mcpServers.userEnvVarsModal.loadFailed")}</AlertTitle>
|
||||
</Alert>
|
||||
) : required.length === 0 ? (
|
||||
<Alert variant="info">
|
||||
<Info />
|
||||
<AlertTitle>No per-user fields configured for this server.</AlertTitle>
|
||||
<AlertTitle>{t("mcpServers.userEnvVarsModal.noFields")}</AlertTitle>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
These values are private to you. Your admin configured this MCP server to require these per-user
|
||||
credentials. Saved values are never shown back; leave an already-set field blank to keep it, or enter a
|
||||
value to set or change it.
|
||||
</span>
|
||||
<span className="block text-sm text-muted-foreground">{t("mcpServers.userEnvVarsModal.intro")}</span>
|
||||
<UserEnvVarsForm
|
||||
required={required}
|
||||
isSaving={isSaving}
|
||||
|
|
@ -214,18 +228,17 @@ const UserEnvVarsModal: React.FC<UserEnvVarsModalProps> = ({ server, open, acces
|
|||
<AlertDialog open={confirmingClear} onOpenChange={(opened) => !opened && setConfirmingClear(false)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Clear saved credentials</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("mcpServers.userEnvVarsModal.clearDialog.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This deletes every per-user value you saved for {displayName}. Your next MCP request to this server
|
||||
fails until you set them again.
|
||||
{t("mcpServers.userEnvVarsModal.clearDialog.description", { server: displayName })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmingClear(false)}>
|
||||
Cancel
|
||||
{t("mcpServers.userEnvVarsModal.clearDialog.cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmClear}>
|
||||
Clear credentials
|
||||
{t("mcpServers.userEnvVarsModal.clearDialog.confirm")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
import { MCPServer } from "@/components/mcp_tools/types";
|
||||
import { ApiError, deriveErrorMessage, unwrapProxyErrorMessage } from "@/lib/http/client";
|
||||
|
||||
|
|
@ -38,7 +39,7 @@ export function findDuplicateMcpServer(
|
|||
return null;
|
||||
}
|
||||
|
||||
export const DUPLICATE_IDENTIFIER_MESSAGE = "An MCP server with this name/alias already exists.";
|
||||
export const DUPLICATE_IDENTIFIER_MESSAGE_KEY: ParseKeys = "mcpServers.duplicateServerCheck.duplicateIdentifier";
|
||||
|
||||
export const mcpSubmitErrorReason = (error: unknown): string => {
|
||||
if (error instanceof ApiError) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { TFunction } from "i18next";
|
||||
import type { McpDcrCredentials } from "@/hooks/useMcpOAuthFlow";
|
||||
import {
|
||||
ADMIN_CONFIG_CREDENTIAL_KEYS,
|
||||
|
|
@ -114,20 +115,23 @@ const assertNever = (value: never): never => {
|
|||
throw new Error(`unhandled edit payload result: ${JSON.stringify(value)}`);
|
||||
};
|
||||
|
||||
export const editPayloadErrorMessage = (result: Exclude<BuildEditPayloadResult, { kind: "ok" }>): string => {
|
||||
export const editPayloadErrorMessage = (
|
||||
result: Exclude<BuildEditPayloadResult, { kind: "ok" }>,
|
||||
t: TFunction,
|
||||
): string => {
|
||||
switch (result.kind) {
|
||||
case "invalid_tool_display_name":
|
||||
return `Tool display name "${result.displayName}" is invalid. Only letters, digits, underscores, and hyphens are allowed (no spaces).`;
|
||||
return t("mcpServers.editServerPayload.errors.invalidToolDisplayName", { displayName: result.displayName });
|
||||
case "stdio_config_missing_command":
|
||||
return "Stdio configuration must include a command";
|
||||
return t("mcpServers.editServerPayload.errors.stdioConfigMissingCommand");
|
||||
case "invalid_stdio_json":
|
||||
return "Invalid JSON in stdio configuration";
|
||||
return t("mcpServers.editServerPayload.errors.invalidStdioJson");
|
||||
case "invalid_stdio_env_json":
|
||||
return "Invalid JSON in stdio env configuration";
|
||||
return t("mcpServers.editServerPayload.errors.invalidStdioEnvJson");
|
||||
case "stdio_command_required":
|
||||
return "Stdio transport requires a command";
|
||||
return t("mcpServers.editServerPayload.errors.stdioCommandRequired");
|
||||
case "invalid_token_validation_json":
|
||||
return "Invalid JSON in Token Validation Rules";
|
||||
return t("mcpServers.editServerPayload.errors.invalidTokenValidationJson");
|
||||
default:
|
||||
return assertNever(result);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { TFunction } from "i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { AUTH_TYPE, TRANSPORT } from "@/components/mcp_tools/types";
|
||||
import { AUTH_TYPES_REQUIRING_AUTH_VALUE, reduceStaticHeaders } from "./createServerPayload";
|
||||
|
||||
|
|
@ -27,6 +29,7 @@ type EditToolPreview =
|
|||
export const getEditToolPreview = (
|
||||
values: Readonly<Record<string, unknown>>,
|
||||
initialValues: Readonly<Record<string, unknown>>,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): EditToolPreview => {
|
||||
const staticAuth =
|
||||
values.auth_type === AUTH_TYPE.NONE ||
|
||||
|
|
@ -59,8 +62,7 @@ export const getEditToolPreview = (
|
|||
if (changedOrigin && (needsSavedCredential || reusesHeader)) {
|
||||
return {
|
||||
kind: "incomplete",
|
||||
message:
|
||||
"The server origin changed. Enter credentials and replace or remove saved static headers to preview tools.",
|
||||
message: t("mcpServers.editToolPreview.originChanged"),
|
||||
};
|
||||
}
|
||||
return { kind: "preview", config };
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import type { TFunction } from "i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
export interface MCPConnectorImportResult {
|
||||
name: string;
|
||||
server_id: string;
|
||||
|
|
@ -24,41 +27,41 @@ export type ParsedConnectorConfig =
|
|||
| { ok: true; payload: Record<string, unknown>; connectorCount: number }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export const parseConnectorConfig = (text: string): ParsedConnectorConfig => {
|
||||
export const parseConnectorConfig = (text: string, t: TFunction = i18n.t.bind(i18n)): ParsedConnectorConfig => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return { ok: false, error: "Paste your connector JSON before importing." };
|
||||
return { ok: false, error: t("mcpServers.importConnectorConfig.errors.emptyInput") };
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { ok: false, error: "Invalid JSON. Check for missing quotes, commas, or brackets." };
|
||||
return { ok: false, error: t("mcpServers.importConnectorConfig.errors.invalidJson") };
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
return { ok: false, error: "Expected a JSON object with an mcpServers or mcp_servers key." };
|
||||
return { ok: false, error: t("mcpServers.importConnectorConfig.errors.unrecognizedShape") };
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const mapping = record.mcpServers;
|
||||
if (mapping !== undefined) {
|
||||
if (typeof mapping !== "object" || mapping === null || Array.isArray(mapping)) {
|
||||
return { ok: false, error: "mcpServers must be an object mapping connector names to definitions." };
|
||||
return { ok: false, error: t("mcpServers.importConnectorConfig.errors.mappingNotObject") };
|
||||
}
|
||||
const connectorCount = Object.keys(mapping).length;
|
||||
if (connectorCount === 0) {
|
||||
return { ok: false, error: "mcpServers contains no connectors." };
|
||||
return { ok: false, error: t("mcpServers.importConnectorConfig.errors.mappingEmpty") };
|
||||
}
|
||||
return { ok: true, payload: { mcpServers: mapping }, connectorCount };
|
||||
}
|
||||
const list = record.mcp_servers;
|
||||
if (list !== undefined) {
|
||||
if (!Array.isArray(list)) {
|
||||
return { ok: false, error: "mcp_servers must be an array of connector definitions." };
|
||||
return { ok: false, error: t("mcpServers.importConnectorConfig.errors.listNotArray") };
|
||||
}
|
||||
if (list.length === 0) {
|
||||
return { ok: false, error: "mcp_servers contains no connectors." };
|
||||
return { ok: false, error: t("mcpServers.importConnectorConfig.errors.listEmpty") };
|
||||
}
|
||||
return { ok: true, payload: { mcp_servers: list }, connectorCount: list.length };
|
||||
}
|
||||
return { ok: false, error: "Expected a JSON object with an mcpServers or mcp_servers key." };
|
||||
return { ok: false, error: t("mcpServers.importConnectorConfig.errors.unrecognizedShape") };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import type { TFunction } from "i18next";
|
||||
import type React from "react";
|
||||
import type { Validate } from "react-hook-form";
|
||||
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
import type { MountedFieldControlProps, MountedFormValues } from "@/components/common_components/MountedFormField";
|
||||
|
||||
type McpValidate = Validate<unknown, MountedFormValues>;
|
||||
|
|
@ -35,14 +38,14 @@ const toTags = (value: unknown): string[] =>
|
|||
(entry): entry is string => typeof entry === "string" && entry !== "",
|
||||
);
|
||||
|
||||
export const tagsControl = (control: MountedFieldControlProps) => {
|
||||
export const tagsControl = (control: MountedFieldControlProps, t: TFunction = i18n.t.bind(i18n)) => {
|
||||
const value = toTags(control.value);
|
||||
return {
|
||||
id: control.id,
|
||||
options: [...new Set(value)].map((tag) => ({ label: tag, value: tag })),
|
||||
value,
|
||||
onValueChange: control.onChange,
|
||||
emptyText: "Type to add",
|
||||
emptyText: t("mcpServers.fieldRules.tagsControl.emptyText"),
|
||||
allowCustomValues: true,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable react/no-unescaped-entities */
|
||||
|
||||
import { useTerminology } from "@/contexts/TerminologyContext";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import React, { useId, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
|
@ -33,7 +33,7 @@ interface CodeBlockProps {
|
|||
|
||||
interface FeatureCardProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
titleKey: ParseKeys;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
serverName?: string;
|
||||
|
|
@ -42,12 +42,13 @@ interface FeatureCardProps {
|
|||
|
||||
const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||
icon,
|
||||
title,
|
||||
titleKey,
|
||||
description,
|
||||
children,
|
||||
serverName,
|
||||
accessGroups = ["dev-group"],
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [useServerHeader, setUseServerHeader] = useState(false);
|
||||
const serverHeaderToggleId = useId();
|
||||
|
||||
|
|
@ -70,44 +71,61 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
|
|||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="p-2 rounded-lg bg-muted">{icon}</span>
|
||||
<div>
|
||||
<h5 className="mb-0 text-base font-semibold text-foreground">{title}</h5>
|
||||
<h5 className="mb-0 text-base font-semibold text-foreground">{t(titleKey)}</h5>
|
||||
<span className="text-muted-foreground">{description}</span>
|
||||
</div>
|
||||
</div>
|
||||
{serverName && (title === "Implementation Example" || title === "Configuration") && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Switch
|
||||
id={serverHeaderToggleId}
|
||||
size="sm"
|
||||
checked={useServerHeader}
|
||||
onCheckedChange={setUseServerHeader}
|
||||
/>
|
||||
<Label htmlFor={serverHeaderToggleId} className="font-normal leading-normal">
|
||||
Limit tools to specific MCP servers or MCP groups by passing the <code>x-mcp-servers</code> header
|
||||
</Label>
|
||||
{serverName &&
|
||||
(titleKey === "mcpServers.connect.implementationExampleCard.title" ||
|
||||
titleKey === "mcpServers.connect.cursorTab.configurationCard.title") && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Switch
|
||||
id={serverHeaderToggleId}
|
||||
size="sm"
|
||||
checked={useServerHeader}
|
||||
onCheckedChange={setUseServerHeader}
|
||||
/>
|
||||
<Label htmlFor={serverHeaderToggleId} className="font-normal leading-normal">
|
||||
<Trans i18nKey="mcpServers.connect.featureCard.serverHeaderToggle" components={{ code: <code /> }} />
|
||||
</Label>
|
||||
</div>
|
||||
{useServerHeader && (
|
||||
<Alert className="mt-2" variant="info">
|
||||
<Info />
|
||||
<AlertTitle>{t("mcpServers.connect.featureCard.optionsTitle")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="mcpServers.connect.featureCard.optionServer"
|
||||
components={{
|
||||
strong: <strong />,
|
||||
code: <code />,
|
||||
server: <>{serverName.replace(/\s+/g, "_")}</>,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="mcpServers.connect.featureCard.optionGroup"
|
||||
values={{ group: "dev-group" }}
|
||||
components={{ strong: <strong />, code: <code /> }}
|
||||
/>
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<Trans
|
||||
i18nKey="mcpServers.connect.featureCard.optionMix"
|
||||
values={{ example: "Server1,dev-group" }}
|
||||
components={{ code: <code /> }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
{useServerHeader && (
|
||||
<Alert className="mt-2" variant="info">
|
||||
<Info />
|
||||
<AlertTitle>Two Options</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div>
|
||||
<p>
|
||||
<strong>Option 1:</strong> Get a specific server: <code>"{serverName.replace(/\s+/g, "_")}"</code>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Option 2:</strong> Get a group of MCPs: <code>"dev-group"</code>
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
You can also mix both: <code>"Server1,dev-group"</code>
|
||||
</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{React.Children.map(children, (child) => {
|
||||
if (
|
||||
React.isValidElement<CodeBlockProps>(child) &&
|
||||
|
|
@ -134,6 +152,7 @@ interface MCPConnectProps {
|
|||
|
||||
const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = [] }) => {
|
||||
const term = useTerminology();
|
||||
const { t } = useTranslation();
|
||||
const proxyBaseUrl = getProxyBaseUrl();
|
||||
const gatewayBaseUrl = getGatewayBaseUrl();
|
||||
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
|
||||
|
|
@ -205,39 +224,45 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
<div className="bg-linear-to-r from-success/15 to-success/5 p-6 rounded-lg border border-success/15">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Zap className="text-success" size={24} />
|
||||
<h4 className="mb-0 text-xl font-semibold text-success">LiteLLM Proxy API Integration</h4>
|
||||
<h4 className="mb-0 text-xl font-semibold text-success">{t("mcpServers.connect.litellmTab.title")}</h4>
|
||||
</div>
|
||||
<span className="text-success">
|
||||
Connect to LiteLLM Proxy Responses API for seamless tool integration with multiple model providers
|
||||
</span>
|
||||
<span className="text-success">{t("mcpServers.connect.litellmTab.description")}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<FeatureCard
|
||||
icon={<KeyIcon className="text-success" size={16} />}
|
||||
title="Virtual Key Setup"
|
||||
description="Configure your LiteLLM Proxy Virtual Key for authentication"
|
||||
titleKey="mcpServers.connect.litellmTab.virtualKeyCard.title"
|
||||
description={t("mcpServers.connect.litellmTab.virtualKeyCard.description")}
|
||||
>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div>
|
||||
<span>Get your Virtual Key from your LiteLLM Proxy dashboard or contact your administrator</span>
|
||||
<span>{t("mcpServers.connect.litellmTab.virtualKeyCard.instructions")}</span>
|
||||
</div>
|
||||
<CodeBlock title="Environment Variable" code='export LITELLM_API_KEY="sk-..."' copyKey="litellm-env" />
|
||||
<CodeBlock
|
||||
title={t("mcpServers.connect.codeBlocks.environmentVariable")}
|
||||
code='export LITELLM_API_KEY="sk-..."'
|
||||
copyKey="litellm-env"
|
||||
/>
|
||||
</div>
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard
|
||||
icon={<ServerIcon className="text-success" size={16} />}
|
||||
title="MCP Server Information"
|
||||
description="Connection details for your LiteLLM MCP server"
|
||||
titleKey="mcpServers.connect.serverInfoCard.title"
|
||||
description={t("mcpServers.connect.serverInfoCard.description")}
|
||||
>
|
||||
<CodeBlock title="Server URL" code={`${proxyBaseUrl}/mcp`} copyKey="litellm-server-url" />
|
||||
<CodeBlock
|
||||
title={t("mcpServers.connect.codeBlocks.serverUrl")}
|
||||
code={`${proxyBaseUrl}/mcp`}
|
||||
copyKey="litellm-server-url"
|
||||
/>
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard
|
||||
icon={<Code className="text-success" size={16} />}
|
||||
title="Implementation Example"
|
||||
description="Complete cURL example for using the LiteLLM Proxy Responses API"
|
||||
titleKey="mcpServers.connect.implementationExampleCard.title"
|
||||
description={t("mcpServers.connect.litellmTab.exampleDescription")}
|
||||
serverName={currentServer}
|
||||
accessGroups={["dev-group"]}
|
||||
>
|
||||
|
|
@ -275,49 +300,60 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
<div className="bg-linear-to-r from-info/15 to-info/5 p-6 rounded-lg border border-info/15">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Code className="text-info" size={24} />
|
||||
<h4 className="mb-0 text-xl font-semibold text-info">OpenAI Responses API Integration</h4>
|
||||
<h4 className="mb-0 text-xl font-semibold text-info">{t("mcpServers.connect.openaiTab.title")}</h4>
|
||||
</div>
|
||||
<span className="text-info">
|
||||
Connect OpenAI Responses API to your LiteLLM MCP server for seamless tool integration
|
||||
</span>
|
||||
<span className="text-info">{t("mcpServers.connect.openaiTab.description")}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<FeatureCard
|
||||
icon={<KeyIcon className="text-info" size={16} />}
|
||||
title="API Key Setup"
|
||||
description="Configure your OpenAI API key for authentication"
|
||||
titleKey="mcpServers.connect.openaiTab.apiKeyCard.title"
|
||||
description={t("mcpServers.connect.openaiTab.apiKeyCard.description")}
|
||||
>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div>
|
||||
<span>
|
||||
Get your API key from the{" "}
|
||||
<a
|
||||
href="https://platform.openai.com/api-keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-info hover:text-info/80 inline-flex items-center gap-1"
|
||||
>
|
||||
OpenAI platform <ExternalLinkIcon size={12} />
|
||||
</a>
|
||||
<Trans
|
||||
i18nKey="mcpServers.connect.openaiTab.apiKeyCard.instructions"
|
||||
components={{
|
||||
docs: (
|
||||
<a
|
||||
href="https://platform.openai.com/api-keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-info hover:text-info/80 inline-flex items-center gap-1"
|
||||
/>
|
||||
),
|
||||
icon: <ExternalLinkIcon size={12} />,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<CodeBlock title="Environment Variable" code='export OPENAI_API_KEY="sk-..."' copyKey="openai-env" />
|
||||
<CodeBlock
|
||||
title={t("mcpServers.connect.codeBlocks.environmentVariable")}
|
||||
code='export OPENAI_API_KEY="sk-..."'
|
||||
copyKey="openai-env"
|
||||
/>
|
||||
</div>
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard
|
||||
icon={<ServerIcon className="text-info" size={16} />}
|
||||
title="MCP Server Information"
|
||||
description="Connection details for your LiteLLM MCP server"
|
||||
titleKey="mcpServers.connect.serverInfoCard.title"
|
||||
description={t("mcpServers.connect.serverInfoCard.description")}
|
||||
>
|
||||
<CodeBlock title="Server URL" code={`${proxyBaseUrl}/mcp`} copyKey="openai-server-url" />
|
||||
<CodeBlock
|
||||
title={t("mcpServers.connect.codeBlocks.serverUrl")}
|
||||
code={`${proxyBaseUrl}/mcp`}
|
||||
copyKey="openai-server-url"
|
||||
/>
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard
|
||||
icon={<Code className="text-info" size={16} />}
|
||||
title="Implementation Example"
|
||||
description="Complete cURL example for using the Responses API"
|
||||
titleKey="mcpServers.connect.implementationExampleCard.title"
|
||||
description={t("mcpServers.connect.openaiTab.exampleDescription")}
|
||||
serverName="Zapier Gmail"
|
||||
accessGroups={["dev-group"]}
|
||||
>
|
||||
|
|
@ -355,38 +391,42 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
<div className="bg-linear-to-r from-purple-50 to-blue-50 p-6 rounded-lg border border-purple-100 dark:from-purple-950 dark:to-blue-950 dark:border-purple-900">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Terminal className="text-purple-600 dark:text-purple-400" size={24} />
|
||||
<h4 className="mb-0 text-xl font-semibold text-purple-900 dark:text-purple-100">Cursor IDE Integration</h4>
|
||||
<h4 className="mb-0 text-xl font-semibold text-purple-900 dark:text-purple-100">
|
||||
{t("mcpServers.connect.cursorTab.title")}
|
||||
</h4>
|
||||
</div>
|
||||
<span className="text-purple-700 dark:text-purple-300">
|
||||
{term(
|
||||
"Use tools directly from Cursor IDE with LiteLLM MCP. Enable your AI assistant to perform real-world tasks without leaving your coding environment.",
|
||||
)}
|
||||
{term(t("mcpServers.connect.cursorTab.description"))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<h5 className="mb-4 text-base font-semibold text-foreground">Setup Instructions</h5>
|
||||
<h5 className="mb-4 text-base font-semibold text-foreground">
|
||||
{t("mcpServers.connect.cursorTab.setupTitle")}
|
||||
</h5>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<StepCard step={1} title="Open Cursor Settings">
|
||||
<StepCard step={1} title={t("mcpServers.connect.cursorTab.steps.openSettings.title")}>
|
||||
<span className="text-muted-foreground">
|
||||
Use the keyboard shortcut <Kbd>⇧+⌘+J</Kbd> (Mac) or <Kbd>Ctrl+Shift+J</Kbd> (Windows/Linux)
|
||||
<Trans i18nKey="mcpServers.connect.cursorTab.steps.openSettings.body" components={{ kbd: <Kbd /> }} />
|
||||
</span>
|
||||
</StepCard>
|
||||
|
||||
<StepCard step={2} title="Navigate to MCP Tools">
|
||||
<span className="text-muted-foreground">Go to the "MCP Tools" tab and click "New MCP Server"</span>
|
||||
<StepCard step={2} title={t("mcpServers.connect.cursorTab.steps.navigate.title")}>
|
||||
<span className="text-muted-foreground">{t("mcpServers.connect.cursorTab.steps.navigate.body")}</span>
|
||||
</StepCard>
|
||||
|
||||
<StepCard step={3} title="Add Configuration">
|
||||
<StepCard step={3} title={t("mcpServers.connect.cursorTab.steps.addConfiguration.title")}>
|
||||
<span className="mb-3 text-muted-foreground">
|
||||
Copy the JSON configuration below and paste it into Cursor, then save with <Kbd>Cmd+S</Kbd> or{" "}
|
||||
<Kbd>Ctrl+S</Kbd>
|
||||
<Trans
|
||||
i18nKey="mcpServers.connect.cursorTab.steps.addConfiguration.body"
|
||||
components={{ kbd: <Kbd /> }}
|
||||
/>
|
||||
</span>
|
||||
<FeatureCard
|
||||
icon={<Code className="text-purple-600 dark:text-purple-400" size={16} />}
|
||||
title="Configuration"
|
||||
description="Cursor MCP configuration"
|
||||
titleKey="mcpServers.connect.cursorTab.configurationCard.title"
|
||||
description={t("mcpServers.connect.cursorTab.configurationCard.description")}
|
||||
serverName="Zapier Gmail"
|
||||
accessGroups={["dev-group"]}
|
||||
>
|
||||
|
|
@ -418,28 +458,27 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
<div className="bg-linear-to-r from-success/15 to-success/5 p-6 rounded-lg border border-success/15">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Globe className="text-success" size={24} />
|
||||
<h4 className="mb-0 text-xl font-semibold text-success">Streamable HTTP Transport</h4>
|
||||
<h4 className="mb-0 text-xl font-semibold text-success">{t("mcpServers.connect.httpTab.title")}</h4>
|
||||
</div>
|
||||
<span className="text-success">
|
||||
Connect to LiteLLM MCP using HTTP transport. Compatible with any MCP client that supports HTTP streaming.
|
||||
</span>
|
||||
<span className="text-success">{t("mcpServers.connect.httpTab.description")}</span>
|
||||
</div>
|
||||
|
||||
<FeatureCard
|
||||
icon={<Globe className="text-success" size={16} />}
|
||||
title="Universal MCP Connection"
|
||||
description="Use this URL with any MCP client that supports HTTP transport"
|
||||
titleKey="mcpServers.connect.httpTab.connectionCard.title"
|
||||
description={t("mcpServers.connect.httpTab.connectionCard.description")}
|
||||
>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div>
|
||||
<span>
|
||||
Each MCP client supports different transports. Refer to your client documentation to determine the
|
||||
appropriate transport method.
|
||||
</span>
|
||||
<span>{t("mcpServers.connect.httpTab.connectionCard.instructions")}</span>
|
||||
</div>
|
||||
<CodeBlock title="Server URL" code={`${proxyBaseUrl}/mcp`} copyKey="http-server-url" />
|
||||
<CodeBlock
|
||||
title="Headers Configuration"
|
||||
title={t("mcpServers.connect.codeBlocks.serverUrl")}
|
||||
code={`${proxyBaseUrl}/mcp`}
|
||||
copyKey="http-server-url"
|
||||
/>
|
||||
<CodeBlock
|
||||
title={t("mcpServers.connect.codeBlocks.headersConfiguration")}
|
||||
code={JSON.stringify(
|
||||
{
|
||||
"x-litellm-api-key": "Bearer YOUR_LITELLM_API_KEY",
|
||||
|
|
@ -463,7 +502,7 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
}
|
||||
>
|
||||
<ExternalLinkIcon size={14} />
|
||||
Learn more about MCP transports
|
||||
{t("mcpServers.connect.httpTab.learnMore")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -475,12 +514,8 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
<div>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-foreground mb-3">Connect to your MCP client</h2>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
{term(
|
||||
"Use tools directly from any MCP client with LiteLLM MCP. Enable your AI assistant to perform real-world tasks through a simple, secure connection.",
|
||||
)}
|
||||
</p>
|
||||
<h2 className="text-3xl font-bold text-foreground mb-3">{t("mcpServers.connect.title")}</h2>
|
||||
<p className="text-lg text-muted-foreground">{term(t("mcpServers.connect.description"))}</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="openai" className="w-full">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CircleCheck, CircleAlert, RefreshCw, Wrench, Info } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -27,6 +28,7 @@ const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({
|
|||
canFetchTools,
|
||||
fetchTools,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isPreviewForbidden = toolsErrorStatus === 403;
|
||||
// Don't show anything if required fields aren't filled
|
||||
if (!canFetchTools && !formValues.url && !formValues.spec_path) {
|
||||
|
|
@ -38,14 +40,14 @@ const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({
|
|||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleCheck className="size-4 text-muted-foreground" />
|
||||
<h3 className="text-lg font-medium">Connection Status</h3>
|
||||
<h3 className="text-lg font-medium">{t("mcpServers.connectionStatus.title")}</h3>
|
||||
</div>
|
||||
|
||||
{!canFetchTools && (formValues.url || formValues.spec_path) && (
|
||||
<div className="rounded-lg border border-dashed py-6 text-center text-muted-foreground">
|
||||
<Wrench className="mx-auto mb-2 size-6" />
|
||||
<p className="text-sm">Complete required fields to test connection</p>
|
||||
<p className="text-sm">Fill in URL, Transport, and Authentication to test MCP server connection</p>
|
||||
<p className="text-sm">{t("mcpServers.connectionStatus.incomplete.title")}</p>
|
||||
<p className="text-sm">{t("mcpServers.connectionStatus.incomplete.description")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -55,36 +57,38 @@ const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({
|
|||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{isLoadingTools
|
||||
? "Testing connection to MCP server..."
|
||||
? t("mcpServers.connectionStatus.state.testing")
|
||||
: tools.length > 0
|
||||
? "Connection successful"
|
||||
? t("mcpServers.connectionStatus.state.successful")
|
||||
: toolsError
|
||||
? isPreviewForbidden
|
||||
? "Ready to submit"
|
||||
: "Connection failed"
|
||||
: "Ready to test connection"}
|
||||
? t("mcpServers.connectionStatus.state.readyToSubmit")
|
||||
: t("mcpServers.connectionStatus.state.failed")
|
||||
: t("mcpServers.connectionStatus.state.readyToTest")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("mcpServers.connectionStatus.server", { server: formValues.url || formValues.spec_path })}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Server: {formValues.url || formValues.spec_path}</p>
|
||||
</div>
|
||||
|
||||
{isLoadingTools && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<UiLoadingSpinner className="size-4" />
|
||||
<p className="text-sm">Connecting...</p>
|
||||
<p className="text-sm">{t("mcpServers.connectionStatus.badge.connecting")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingTools && !toolsError && tools.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<CircleCheck className="size-4" />
|
||||
<p className="text-sm font-medium">Connected</p>
|
||||
<p className="text-sm font-medium">{t("mcpServers.connectionStatus.badge.connected")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolsError && !isPreviewForbidden && (
|
||||
<div className="flex items-center gap-1 text-destructive">
|
||||
<CircleAlert className="size-4" />
|
||||
<p className="text-sm font-medium">Failed</p>
|
||||
<p className="text-sm font-medium">{t("mcpServers.connectionStatus.badge.failed")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -92,14 +96,14 @@ const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({
|
|||
{isLoadingTools && (
|
||||
<div className="flex items-center justify-center gap-3 py-6">
|
||||
<UiLoadingSpinner className="size-6 text-muted-foreground" />
|
||||
<p className="text-sm">Testing connection and loading tools...</p>
|
||||
<p className="text-sm">{t("mcpServers.connectionStatus.loadingTools")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolsError && isPreviewForbidden && (
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertTitle>Tool preview unavailable</AlertTitle>
|
||||
<AlertTitle>{t("mcpServers.connectionStatus.previewUnavailable")}</AlertTitle>
|
||||
<AlertDescription>{toolsError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
|
@ -107,7 +111,7 @@ const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({
|
|||
{toolsError && !isPreviewForbidden && (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert />
|
||||
<AlertTitle>Connection Failed</AlertTitle>
|
||||
<AlertTitle>{t("mcpServers.connectionStatus.error.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div>{toolsError}</div>
|
||||
{toolsErrorStackTrace && (
|
||||
|
|
@ -115,7 +119,7 @@ const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({
|
|||
<CollapsibleTrigger
|
||||
render={
|
||||
<Button variant="link" size="sm" className="h-auto p-0">
|
||||
Stack Trace
|
||||
{t("mcpServers.connectionStatus.error.stackTrace")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
|
@ -130,7 +134,7 @@ const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({
|
|||
<div className="mt-3">
|
||||
<Button variant="outline" size="sm" onClick={fetchTools}>
|
||||
<RefreshCw />
|
||||
Retry
|
||||
{t("mcpServers.connectionStatus.error.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
|
|
@ -139,8 +143,8 @@ const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({
|
|||
{!isLoadingTools && tools.length === 0 && !toolsError && (
|
||||
<div className="rounded-lg border border-dashed py-6 text-center">
|
||||
<CircleCheck className="mx-auto mb-2 size-6" />
|
||||
<p className="text-sm font-medium">Connection successful!</p>
|
||||
<p className="text-sm text-muted-foreground">No tools found for this MCP server</p>
|
||||
<p className="text-sm font-medium">{t("mcpServers.connectionStatus.noTools.title")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("mcpServers.connectionStatus.noTools.description")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
|
|
@ -42,6 +43,7 @@ const MCPDiscovery: React.FC<MCPDiscoveryProps> = ({
|
|||
onCustomServer,
|
||||
accessToken,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [servers, setServers] = useState<DiscoverableMCPServer[]>([]);
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -59,13 +61,13 @@ const MCPDiscovery: React.FC<MCPDiscoveryProps> = ({
|
|||
setCategories(data.categories || []);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setError(err.message || "Failed to load MCP servers");
|
||||
setError(err.message || t("mcpServers.discovery.errors.loadFailed"));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [isVisible, accessToken]);
|
||||
}, [isVisible, accessToken, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
|
|
@ -107,11 +109,15 @@ const MCPDiscovery: React.FC<MCPDiscoveryProps> = ({
|
|||
<DialogHeader>
|
||||
<div className="flex items-center justify-between border-b border-border pb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<img src={resolveLogoSrc(mcpLogoImg)} alt="MCP Logo" className="mr-2 size-5 object-contain" />
|
||||
<DialogTitle className="text-xl font-semibold">Add MCP Server</DialogTitle>
|
||||
<img
|
||||
src={resolveLogoSrc(mcpLogoImg)}
|
||||
alt={t("mcpServers.discovery.logoAlt")}
|
||||
className="mr-2 size-5 object-contain"
|
||||
/>
|
||||
<DialogTitle className="text-xl font-semibold">{t("mcpServers.discovery.title")}</DialogTitle>
|
||||
</div>
|
||||
<Button variant="link" size="sm" className="mr-8" onClick={onCustomServer}>
|
||||
+ Custom Server
|
||||
{t("mcpServers.discovery.customServer")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
|
@ -128,7 +134,7 @@ const MCPDiscovery: React.FC<MCPDiscoveryProps> = ({
|
|||
variant={isSelected ? "default" : "outline"}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
>
|
||||
{cat}
|
||||
{cat === "All" ? t("mcpServers.discovery.allCategories") : cat}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -140,7 +146,7 @@ const MCPDiscovery: React.FC<MCPDiscoveryProps> = ({
|
|||
<Search className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search servers..."
|
||||
placeholder={t("mcpServers.discovery.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
|
@ -157,16 +163,16 @@ const MCPDiscovery: React.FC<MCPDiscoveryProps> = ({
|
|||
|
||||
{error && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p className="text-sm">Failed to load servers: {error}</p>
|
||||
<p className="text-sm">{t("mcpServers.discovery.loadError", { error })}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && filteredServers.length === 0 && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p className="text-sm">
|
||||
No servers found.{" "}
|
||||
{t("mcpServers.discovery.noServers")}{" "}
|
||||
<Button variant="link" size="sm" onClick={onCustomServer}>
|
||||
Add a custom server
|
||||
{t("mcpServers.discovery.addCustomServer")}
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -178,7 +184,7 @@ const MCPDiscovery: React.FC<MCPDiscoveryProps> = ({
|
|||
Object.entries(groupedServers).map(([category, categoryServers]) => (
|
||||
<div key={category} className="mb-4">
|
||||
<div className="mb-1 border-b border-border py-1.5 text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
|
||||
{category}
|
||||
{category === "Other" ? t("mcpServers.discovery.otherCategory") : category}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4">
|
||||
{categoryServers.map((server) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Info, DollarSign, Wrench } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
|
@ -61,6 +62,7 @@ const MCPServerCostConfig: React.FC<MCPServerCostConfigProps> = ({
|
|||
tools = [],
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const handleDefaultCostChange = (defaultCost: number | null) => {
|
||||
const updated = {
|
||||
...value,
|
||||
|
|
@ -86,28 +88,34 @@ const MCPServerCostConfig: React.FC<MCPServerCostConfigProps> = ({
|
|||
<div className="space-y-6">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<DollarSign className="size-4 text-muted-foreground" />
|
||||
<h3 className="text-lg font-medium">Cost Configuration</h3>
|
||||
<h3 className="text-lg font-medium">{t("mcpServers.serverCostConfig.title")}</h3>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<Info className="size-4 text-muted-foreground" aria-label="About cost configuration" />}
|
||||
render={
|
||||
<Info
|
||||
className="size-4 text-muted-foreground"
|
||||
aria-label={t("mcpServers.serverCostConfig.titleTooltipLabel")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
Configure costs for this MCP server's tool calls. Set a default rate and per-tool overrides.
|
||||
</TooltipContent>
|
||||
<TooltipContent>{t("mcpServers.serverCostConfig.titleTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">
|
||||
Default Cost per Query ($)
|
||||
{t("mcpServers.serverCostConfig.defaultCost.label")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Info className="ml-1 inline size-4 text-muted-foreground" aria-label="About the default cost" />
|
||||
<Info
|
||||
className="ml-1 inline size-4 text-muted-foreground"
|
||||
aria-label={t("mcpServers.serverCostConfig.defaultCost.tooltipLabel")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Default cost charged for each tool call to this server.</TooltipContent>
|
||||
<TooltipContent>{t("mcpServers.serverCostConfig.defaultCost.tooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<CostInput
|
||||
|
|
@ -118,23 +126,24 @@ const MCPServerCostConfig: React.FC<MCPServerCostConfigProps> = ({
|
|||
onChange={handleDefaultCostChange}
|
||||
/>
|
||||
<p className="mt-1 block text-sm text-muted-foreground">
|
||||
Set a default cost for all tool calls to this server
|
||||
{t("mcpServers.serverCostConfig.defaultCost.help")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{tools.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-medium">
|
||||
Tool-Specific Costs ($)
|
||||
{t("mcpServers.serverCostConfig.toolCosts.label")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Info className="ml-1 inline size-4 text-muted-foreground" aria-label="About per-tool costs" />
|
||||
<Info
|
||||
className="ml-1 inline size-4 text-muted-foreground"
|
||||
aria-label={t("mcpServers.serverCostConfig.toolCosts.tooltipLabel")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
Override the default cost for specific tools. Leave blank to use the default rate.
|
||||
</TooltipContent>
|
||||
<TooltipContent>{t("mcpServers.serverCostConfig.toolCosts.tooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Collapsible className="rounded-lg border border-border">
|
||||
|
|
@ -142,7 +151,7 @@ const MCPServerCostConfig: React.FC<MCPServerCostConfigProps> = ({
|
|||
render={
|
||||
<button type="button" className="flex w-full items-center gap-2 p-3 text-left">
|
||||
<Wrench className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium">Available Tools</span>
|
||||
<span className="font-medium">{t("mcpServers.serverCostConfig.toolCosts.availableTools")}</span>
|
||||
<Badge variant="secondary">{tools.length}</Badge>
|
||||
</button>
|
||||
}
|
||||
|
|
@ -160,7 +169,7 @@ const MCPServerCostConfig: React.FC<MCPServerCostConfigProps> = ({
|
|||
<div className="ml-4">
|
||||
<CostInput
|
||||
value={value.tool_name_to_cost_per_query?.[tool.name]}
|
||||
placeholder="Use default"
|
||||
placeholder={t("mcpServers.serverCostConfig.toolCosts.useDefaultPlaceholder")}
|
||||
disabled={disabled}
|
||||
className="w-40"
|
||||
onChange={(cost) => handleToolCostChange(tool.name, cost)}
|
||||
|
|
@ -178,11 +187,13 @@ const MCPServerCostConfig: React.FC<MCPServerCostConfigProps> = ({
|
|||
{(value.default_cost_per_query ||
|
||||
(value.tool_name_to_cost_per_query && Object.keys(value.tool_name_to_cost_per_query).length > 0)) && (
|
||||
<div className="mt-6 rounded-lg border border-border bg-muted p-4">
|
||||
<p className="text-sm font-medium">Cost Summary:</p>
|
||||
<p className="text-sm font-medium">{t("mcpServers.serverCostConfig.summary.title")}</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
{value.default_cost_per_query && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
• Default cost: ${value.default_cost_per_query.toFixed(4)} per query
|
||||
{t("mcpServers.serverCostConfig.summary.defaultCost", {
|
||||
cost: value.default_cost_per_query.toFixed(4),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{value.tool_name_to_cost_per_query &&
|
||||
|
|
@ -191,7 +202,7 @@ const MCPServerCostConfig: React.FC<MCPServerCostConfigProps> = ({
|
|||
cost !== null &&
|
||||
cost !== undefined && (
|
||||
<p key={toolName} className="text-sm text-muted-foreground">
|
||||
• {toolName}: ${cost.toFixed(4)} per query
|
||||
{t("mcpServers.serverCostConfig.summary.toolCost", { toolName, cost: cost.toFixed(4) })}
|
||||
</p>
|
||||
),
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MCPServerCostInfo } from "@/components/mcp_tools/types";
|
||||
|
||||
interface MCPServerCostDisplayProps {
|
||||
|
|
@ -6,6 +7,7 @@ interface MCPServerCostDisplayProps {
|
|||
}
|
||||
|
||||
const MCPServerCostDisplay: React.FC<MCPServerCostDisplayProps> = ({ costConfig }) => {
|
||||
const { t } = useTranslation();
|
||||
const hasDefaultCost =
|
||||
costConfig?.default_cost_per_query !== undefined && costConfig?.default_cost_per_query !== null;
|
||||
const hasToolCosts =
|
||||
|
|
@ -17,9 +19,7 @@ const MCPServerCostDisplay: React.FC<MCPServerCostDisplayProps> = ({ costConfig
|
|||
<div className="mt-6 border-t border-border pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border bg-muted p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No cost configuration set for this server. Tool calls will be charged at $0.00 per tool call.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("mcpServers.serverCostDisplay.noConfig")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -33,14 +33,14 @@ const MCPServerCostDisplay: React.FC<MCPServerCostDisplayProps> = ({ costConfig
|
|||
costConfig?.default_cost_per_query !== undefined &&
|
||||
costConfig?.default_cost_per_query !== null && (
|
||||
<div>
|
||||
<p className="text-sm font-medium">Default Cost per Query</p>
|
||||
<p className="text-sm font-medium">{t("mcpServers.serverCostDisplay.defaultCostTitle")}</p>
|
||||
<div className="font-mono text-sm">${costConfig.default_cost_per_query.toFixed(4)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasToolCosts && costConfig?.tool_name_to_cost_per_query && (
|
||||
<div>
|
||||
<p className="text-sm font-medium">Tool-Specific Costs</p>
|
||||
<p className="text-sm font-medium">{t("mcpServers.serverCostDisplay.toolCostsTitle")}</p>
|
||||
<div className="mt-2 space-y-2">
|
||||
{Object.entries(costConfig.tool_name_to_cost_per_query).map(
|
||||
([toolName, cost]) =>
|
||||
|
|
@ -48,7 +48,9 @@ const MCPServerCostDisplay: React.FC<MCPServerCostDisplayProps> = ({ costConfig
|
|||
cost !== undefined && (
|
||||
<div key={toolName} className="flex items-center justify-between rounded-lg bg-muted p-3">
|
||||
<p className="text-sm font-medium">{toolName}</p>
|
||||
<p className="font-mono text-sm">${cost.toFixed(4)} per query</p>
|
||||
<p className="font-mono text-sm">
|
||||
{t("mcpServers.serverCostDisplay.costPerQuery", { cost: cost.toFixed(4) })}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
|
@ -57,18 +59,22 @@ const MCPServerCostDisplay: React.FC<MCPServerCostDisplayProps> = ({ costConfig
|
|||
)}
|
||||
|
||||
<div className="mt-4 rounded-lg border border-border bg-muted p-4">
|
||||
<p className="text-sm font-medium">Cost Summary:</p>
|
||||
<p className="text-sm font-medium">{t("mcpServers.serverCostDisplay.summaryTitle")}</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
{hasDefaultCost &&
|
||||
costConfig?.default_cost_per_query !== undefined &&
|
||||
costConfig?.default_cost_per_query !== null && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
• Default cost: ${costConfig.default_cost_per_query.toFixed(4)} per query
|
||||
{t("mcpServers.serverCostDisplay.summaryDefaultCost", {
|
||||
cost: costConfig.default_cost_per_query.toFixed(4),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{hasToolCosts && costConfig?.tool_name_to_cost_per_query && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
• {Object.keys(costConfig.tool_name_to_cost_per_query).length} tool(s) with custom pricing
|
||||
{t("mcpServers.serverCostDisplay.summaryToolCount", {
|
||||
toolCount: Object.keys(costConfig.tool_name_to_cost_per_query).length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
|
|
@ -53,7 +54,7 @@ import EnvVarsSection from "./EnvVarsSection";
|
|||
import { validateMCPServerUrl, validateMCPServerName, normalizeToolOverrideMap } from "./utils";
|
||||
import { AUTH_TYPES_REQUIRING_AUTH_VALUE } from "./createServerPayload";
|
||||
import { EditServerFormValues, buildEditServerPayload, editPayloadErrorMessage } from "./editServerPayload";
|
||||
import { DUPLICATE_IDENTIFIER_MESSAGE, findDuplicateMcpServer, mcpSubmitErrorReason } from "./duplicateServerCheck";
|
||||
import { DUPLICATE_IDENTIFIER_MESSAGE_KEY, findDuplicateMcpServer, mcpSubmitErrorReason } from "./duplicateServerCheck";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { getEditToolPreview } from "./editToolPreview";
|
||||
import { useMcpOAuthFlow, type McpDcrCredentials } from "@/hooks/useMcpOAuthFlow";
|
||||
|
|
@ -107,6 +108,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
existingServers,
|
||||
stdioEnabled = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const initialStaticHeaders = React.useMemo(() => {
|
||||
if (!mcpServer.static_headers) {
|
||||
return [];
|
||||
|
|
@ -314,9 +316,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
token_type: token.token_type,
|
||||
};
|
||||
setToken(mcpServer.server_id, browserHeldToken, userID);
|
||||
toast.success(
|
||||
"Token held for this browser session. Tools can now be loaded and configured; the token is not saved to LiteLLM.",
|
||||
);
|
||||
toast.success(t("mcpServers.serverEdit.toasts.browserTokenHeld"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -336,7 +336,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
// Re-capture after writing credentials so the token is not invalidated by its own credential write.
|
||||
authorizedIdentityRef.current = getOAuthAuthorizationIdentity(allFieldsValue(form));
|
||||
|
||||
toast.success("OAuth authorization successful! Please click 'Update MCP Server' to save the credentials.");
|
||||
toast.success(t("mcpServers.serverEdit.toasts.oauthAuthorized"));
|
||||
},
|
||||
onBeforeRedirect: persistEditUiState,
|
||||
flowSource: "edit",
|
||||
|
|
@ -460,7 +460,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
}
|
||||
}, [mcpServer]);
|
||||
|
||||
const toolPreview = getEditToolPreview(allFieldsValue(form), initialValues);
|
||||
const toolPreview = getEditToolPreview(allFieldsValue(form), initialValues, t);
|
||||
const toolPreviewKey = JSON.stringify(toolPreview);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -470,7 +470,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
setIsLoadingTools(false);
|
||||
if (!accessToken || !mcpServer.server_id) return;
|
||||
if (toolPreview.kind === "incomplete") {
|
||||
setToolsError(toolPreview.message ?? "Complete the URL, authentication, and header settings to load tools.");
|
||||
setToolsError(toolPreview.message ?? t("mcpServers.serverEdit.tools.incompleteSettings"));
|
||||
return;
|
||||
}
|
||||
setIsLoadingTools(true);
|
||||
|
|
@ -584,12 +584,12 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
setTools(toolsResponse.tools);
|
||||
} else {
|
||||
setTools([]);
|
||||
setToolsError(toolsResponse.message || "Failed to load tools");
|
||||
setToolsError(toolsResponse.message || t("mcpServers.serverEdit.tools.loadFailed"));
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return true;
|
||||
setTools([]);
|
||||
setToolsError(error instanceof Error ? error.message : "Failed to load tools");
|
||||
setToolsError(error instanceof Error ? error.message : t("mcpServers.serverEdit.tools.loadFailed"));
|
||||
} finally {
|
||||
if (isCurrent()) setIsLoadingTools(false);
|
||||
}
|
||||
|
|
@ -628,8 +628,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
setTools([]);
|
||||
setToolsError(
|
||||
isBrowserHeldTokenMode
|
||||
? "Authorize with the upstream (browser-only, in the Authentication section) to load and configure this server's tools."
|
||||
: "Authenticate with this server in the Tools tab to load and configure its tools.",
|
||||
? t("mcpServers.serverEdit.tools.authorizeUpstream")
|
||||
: t("mcpServers.serverEdit.tools.authenticateInToolsTab"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -656,12 +656,12 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
setTools(toolsResponse.tools);
|
||||
} else {
|
||||
setTools([]);
|
||||
setToolsError(toolsResponse.message || "Failed to load tools");
|
||||
setToolsError(toolsResponse.message || t("mcpServers.serverEdit.tools.loadFailed"));
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return;
|
||||
setTools([]);
|
||||
setToolsError(error instanceof Error ? error.message : "Failed to load tools");
|
||||
setToolsError(error instanceof Error ? error.message : t("mcpServers.serverEdit.tools.loadFailed"));
|
||||
} finally {
|
||||
if (isCurrent()) setIsLoadingTools(false);
|
||||
}
|
||||
|
|
@ -751,8 +751,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
mcpServer.server_id,
|
||||
);
|
||||
if (duplicate) {
|
||||
form.setError(duplicate.field, { type: "duplicate", message: DUPLICATE_IDENTIFIER_MESSAGE });
|
||||
toast.fromError(DUPLICATE_IDENTIFIER_MESSAGE);
|
||||
form.setError(duplicate.field, { type: "duplicate", message: t(DUPLICATE_IDENTIFIER_MESSAGE_KEY) });
|
||||
toast.fromError(t(DUPLICATE_IDENTIFIER_MESSAGE_KEY));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -770,7 +770,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
};
|
||||
const built = buildEditServerPayload(values, uiState);
|
||||
if (built.kind !== "ok") {
|
||||
toast.fromError(editPayloadErrorMessage(built));
|
||||
toast.fromError(editPayloadErrorMessage(built, t));
|
||||
return;
|
||||
}
|
||||
const payload = built.payload;
|
||||
|
|
@ -807,17 +807,25 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
toast.fromError("MCP Server updated, but failed to persist OAuth token" + (message ? `: ${message}` : ""));
|
||||
toast.fromError(
|
||||
message
|
||||
? t("mcpServers.serverEdit.toasts.persistTokenFailedWithReason", { reason: message })
|
||||
: t("mcpServers.serverEdit.toasts.persistTokenFailed"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
toast.success("MCP Server updated successfully");
|
||||
toast.success(t("mcpServers.serverEdit.toasts.updated"));
|
||||
setAppMayNotMatchUpstream(false);
|
||||
onSuccess(updated);
|
||||
} catch (error: any) {
|
||||
const reason = mcpSubmitErrorReason(error);
|
||||
toast.fromError("Failed to update MCP Server" + (reason ? `: ${reason}` : ""));
|
||||
toast.fromError(
|
||||
reason
|
||||
? t("mcpServers.serverEdit.toasts.updateFailedWithReason", { reason })
|
||||
: t("mcpServers.serverEdit.toasts.updateFailed"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -825,10 +833,10 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
<Tabs defaultValue="server">
|
||||
<TabsList variant="line" className="grid h-auto w-full grid-cols-2 rounded-none border-b p-0">
|
||||
<TabsTrigger value="server" className="rounded-none py-2">
|
||||
Server Configuration
|
||||
{t("mcpServers.serverEdit.tabs.server")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="cost" className="rounded-none py-2">
|
||||
Cost Configuration
|
||||
{t("mcpServers.serverEdit.tabs.cost")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="mt-6">
|
||||
|
|
@ -836,7 +844,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
<FormProvider {...form}>
|
||||
<MountedFormProvider value={{ control: form.control, registry }}>
|
||||
<form
|
||||
aria-label="Edit MCP server"
|
||||
aria-label={t("mcpServers.serverEdit.formLabel")}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void submitForm();
|
||||
|
|
@ -844,9 +852,9 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
>
|
||||
{isStdioTransport && !stdioEnabled && <StdioDisabledBanner />}
|
||||
<MountedFormField
|
||||
label="MCP Server Name"
|
||||
label={t("mcpServers.serverEdit.fields.serverName")}
|
||||
name="server_name"
|
||||
rules={{ validate: validatorRules({ validator: (_, value) => validateMCPServerName(value) }) }}
|
||||
rules={{ validate: validatorRules({ validator: (_, value) => validateMCPServerName(value, t) }) }}
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
|
|
@ -856,9 +864,9 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label="Alias"
|
||||
label={t("mcpServers.serverEdit.fields.alias")}
|
||||
name="alias"
|
||||
rules={{ validate: validatorRules({ validator: (_, value) => validateMCPServerName(value) }) }}
|
||||
rules={{ validate: validatorRules({ validator: (_, value) => validateMCPServerName(value, t) }) }}
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
|
|
@ -871,7 +879,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField label="Description" name="description">
|
||||
<MountedFormField label={t("mcpServers.serverEdit.fields.description")} name="description">
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
|
|
@ -881,10 +889,10 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
</MountedFormField>
|
||||
<MCPLogoSelector value={logoUrl} onChange={setLogoUrl} />
|
||||
<MountedFormField
|
||||
label="Transport Type"
|
||||
label={t("mcpServers.serverEdit.fields.transport")}
|
||||
name="transport"
|
||||
required
|
||||
rules={{ validate: { required: requiredRule("Transport Type is required") } }}
|
||||
rules={{ validate: { required: requiredRule(t("mcpServers.serverEdit.fields.transportRequired")) } }}
|
||||
>
|
||||
{(control) => (
|
||||
<Select
|
||||
|
|
@ -905,13 +913,13 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
{/* URL field - only for HTTP/SSE */}
|
||||
{isMCPTransport && (
|
||||
<MountedFormField
|
||||
label="MCP Server URL"
|
||||
label={t("mcpServers.serverEdit.fields.url")}
|
||||
name="url"
|
||||
required
|
||||
rules={{
|
||||
validate: {
|
||||
required: requiredRule("Please enter a server URL"),
|
||||
...validatorRules({ validator: (_, value) => validateMCPServerUrl(value) }),
|
||||
required: requiredRule(t("mcpServers.serverEdit.fields.urlRequired")),
|
||||
...validatorRules({ validator: (_, value) => validateMCPServerUrl(value, t) }),
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
|
@ -930,15 +938,15 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
OpenAPI Spec URL
|
||||
<SimpleTooltip content="URL to an OpenAPI specification (JSON or YAML). MCP tools will be automatically generated from the API endpoints defined in the spec.">
|
||||
{t("mcpServers.serverEdit.fields.specUrl")}
|
||||
<SimpleTooltip content={t("mcpServers.serverEdit.fields.specUrlTooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="spec_path"
|
||||
required
|
||||
rules={{ validate: { required: requiredRule("Please enter an OpenAPI spec URL") } }}
|
||||
rules={{ validate: { required: requiredRule(t("mcpServers.serverEdit.fields.specUrlRequired")) } }}
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
|
|
@ -953,8 +961,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
Max Concurrent Requests (optional)
|
||||
<SimpleTooltip content="Maximum number of tool calls LiteLLM will run against this server at the same time. Additional calls wait for a free slot. Leave blank for no limit.">
|
||||
{t("mcpServers.serverEdit.fields.maxConcurrentRequests")}
|
||||
<SimpleTooltip content={t("mcpServers.serverEdit.fields.maxConcurrentRequestsTooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
|
|
@ -966,7 +974,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
{...numberControl(control, 0)}
|
||||
min={1}
|
||||
step={1}
|
||||
placeholder="e.g. 10"
|
||||
placeholder={t("mcpServers.serverEdit.fields.maxConcurrentRequestsPlaceholder")}
|
||||
className="w-full rounded-lg"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -975,8 +983,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
RPM limit (all callers)
|
||||
<SimpleTooltip content="Max requests per minute to this server across all keys and teams. Leave empty for no limit">
|
||||
{t("mcpServers.serverEdit.fields.rpm")}
|
||||
<SimpleTooltip content={t("mcpServers.serverEdit.fields.rpmTooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
|
|
@ -988,7 +996,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
{...numberControl(control, 0)}
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder="e.g. 60"
|
||||
placeholder={t("mcpServers.serverEdit.fields.rpmPlaceholder")}
|
||||
className="w-full rounded-lg"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -998,10 +1006,12 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
{!isStdioTransport && (
|
||||
<>
|
||||
<MountedFormField
|
||||
label="Authentication"
|
||||
label={t("mcpServers.serverEdit.fields.authType")}
|
||||
name="auth_type"
|
||||
required
|
||||
rules={{ validate: { required: requiredRule("Authentication is required") } }}
|
||||
rules={{
|
||||
validate: { required: requiredRule(t("mcpServers.serverEdit.fields.authTypeRequired")) },
|
||||
}}
|
||||
>
|
||||
{(control) => (
|
||||
<Select {...selectControl<string>(control)} items={AUTH_TYPE_ITEMS}>
|
||||
|
|
@ -1038,42 +1048,42 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
|
||||
{isStdioTransport && (
|
||||
<div className="rounded-lg border border-border p-4 space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure the stdio transport used to launch the MCP server process. You can either fill in the
|
||||
fields below or paste a JSON configuration.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("mcpServers.serverEdit.stdio.description")}</p>
|
||||
|
||||
<MountedFormField
|
||||
label="Command"
|
||||
label={t("mcpServers.serverEdit.stdio.command")}
|
||||
name="command"
|
||||
required
|
||||
rules={{ validate: { required: requiredRule("Please enter a command for stdio transport") } }}
|
||||
rules={{ validate: { required: requiredRule(t("mcpServers.serverEdit.stdio.commandRequired")) } }}
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="e.g., npx"
|
||||
placeholder={t("mcpServers.serverEdit.stdio.commandPlaceholder")}
|
||||
className="rounded-lg border-border focus:border-info focus:ring-ring"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
||||
<MountedFormField label="Args" name="args">
|
||||
<MountedFormField label={t("mcpServers.serverEdit.stdio.args")} name="args">
|
||||
{(control) => (
|
||||
<MultiSelect
|
||||
{...tagsControl(control)}
|
||||
placeholder="Add args (press enter or comma)"
|
||||
{...tagsControl(control, t)}
|
||||
placeholder={t("mcpServers.serverEdit.stdio.argsPlaceholder")}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
||||
<MountedFormField
|
||||
label="Environment (JSON object)"
|
||||
label={t("mcpServers.serverEdit.stdio.env")}
|
||||
name="env_json"
|
||||
rules={{
|
||||
validate: {
|
||||
jsonObject: parsesAsJsonObject("Please enter valid JSON", "Env must be a JSON object"),
|
||||
jsonObject: parsesAsJsonObject(
|
||||
t("mcpServers.serverEdit.stdio.envInvalidJson"),
|
||||
t("mcpServers.serverEdit.stdio.envNotObject"),
|
||||
),
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
|
@ -1096,19 +1106,23 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
Authentication Value
|
||||
<SimpleTooltip content="Token, password, or header value to send with each request for the selected auth type.">
|
||||
{t("mcpServers.serverEdit.fields.authValue")}
|
||||
<SimpleTooltip content={t("mcpServers.serverEdit.fields.authValueTooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name={["credentials", "auth_value"]}
|
||||
rules={{ validate: { notWhitespace: notOnlyWhitespace("Authentication value cannot be empty") } }}
|
||||
rules={{
|
||||
validate: {
|
||||
notWhitespace: notOnlyWhitespace(t("mcpServers.serverEdit.fields.authValueWhitespace")),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(control) => (
|
||||
<PasswordInput
|
||||
{...textControl(control)}
|
||||
placeholder="Enter token or secret (leave blank to keep existing)"
|
||||
placeholder={t("mcpServers.serverEdit.fields.authValuePlaceholder")}
|
||||
groupClassName="rounded-lg border-border focus:border-info focus:ring-ring"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -1120,12 +1134,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
{!oauthFlowTypeValue && !isDelegateAuth && (
|
||||
<Alert variant="warning" className="mb-4 rounded-lg">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>This server has no OAuth flow set</AlertTitle>
|
||||
<AlertDescription>
|
||||
Choose Machine-to-Machine (M2M) or Interactive (PKCE) so LiteLLM authenticates it the way you
|
||||
intend, then save. Until it is set, LiteLLM falls back to interactive per-user auth and treats
|
||||
a machine-to-machine credential shape conservatively.
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t("mcpServers.serverEdit.missingOAuthFlow.title")}</AlertTitle>
|
||||
<AlertDescription>{t("mcpServers.serverEdit.missingOAuthFlow.description")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<OAuthFormFields
|
||||
|
|
@ -1148,21 +1158,21 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
{!isStdioTransport && isAwsSigV4AuthType && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
For MCP servers hosted on AWS Bedrock AgentCore.{" "}
|
||||
{t("mcpServers.serverEdit.aws.intro")}{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/mcp_aws_sigv4"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-info hover:text-info/80"
|
||||
>
|
||||
View docs →
|
||||
{t("mcpServers.serverEdit.aws.viewDocs")}
|
||||
</a>
|
||||
</p>
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
AWS Region
|
||||
<SimpleTooltip content="AWS region for SigV4 signing (e.g., us-east-1)">
|
||||
{t("mcpServers.serverEdit.aws.region.label")}
|
||||
<SimpleTooltip content={t("mcpServers.serverEdit.aws.region.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
|
|
@ -1172,7 +1182,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="us-east-1 (leave blank to keep existing)"
|
||||
placeholder={t("mcpServers.serverEdit.aws.region.placeholder")}
|
||||
className="rounded-lg border-border focus:border-info focus:ring-ring"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -1180,8 +1190,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
AWS Service Name
|
||||
<SimpleTooltip content="AWS service name for SigV4 signing. Defaults to 'bedrock-agentcore'.">
|
||||
{t("mcpServers.serverEdit.aws.serviceName.label")}
|
||||
<SimpleTooltip content={t("mcpServers.serverEdit.aws.serviceName.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
|
|
@ -1191,7 +1201,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="bedrock-agentcore (leave blank to keep existing)"
|
||||
placeholder={t("mcpServers.serverEdit.aws.serviceName.placeholder")}
|
||||
className="rounded-lg border-border focus:border-info focus:ring-ring"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -1199,8 +1209,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
AWS Access Key ID
|
||||
<SimpleTooltip content="Optional. If not provided, falls back to the boto3 credential chain (IAM role, env vars, etc.).">
|
||||
{t("mcpServers.serverEdit.aws.accessKeyId.label")}
|
||||
<SimpleTooltip content={t("mcpServers.serverEdit.aws.accessKeyId.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
|
|
@ -1210,7 +1220,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
{(control) => (
|
||||
<PasswordInput
|
||||
{...textControl(control)}
|
||||
placeholder="Leave blank to keep existing"
|
||||
placeholder={t("mcpServers.serverEdit.aws.keepExistingPlaceholder")}
|
||||
groupClassName="rounded-lg border-border focus:border-info focus:ring-ring"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -1218,8 +1228,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
AWS Secret Access Key
|
||||
<SimpleTooltip content="Optional. Required if AWS Access Key ID is provided.">
|
||||
{t("mcpServers.serverEdit.aws.secretAccessKey.label")}
|
||||
<SimpleTooltip content={t("mcpServers.serverEdit.aws.secretAccessKey.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
|
|
@ -1229,7 +1239,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
{(control) => (
|
||||
<PasswordInput
|
||||
{...textControl(control)}
|
||||
placeholder="Leave blank to keep existing"
|
||||
placeholder={t("mcpServers.serverEdit.aws.keepExistingPlaceholder")}
|
||||
groupClassName="rounded-lg border-border focus:border-info focus:ring-ring"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -1237,8 +1247,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
AWS Session Token
|
||||
<SimpleTooltip content="Optional. Only needed for temporary STS credentials.">
|
||||
{t("mcpServers.serverEdit.aws.sessionToken.label")}
|
||||
<SimpleTooltip content={t("mcpServers.serverEdit.aws.sessionToken.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
|
|
@ -1248,7 +1258,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
{(control) => (
|
||||
<PasswordInput
|
||||
{...textControl(control)}
|
||||
placeholder="Leave blank to keep existing"
|
||||
placeholder={t("mcpServers.serverEdit.aws.keepExistingPlaceholder")}
|
||||
groupClassName="rounded-lg border-border focus:border-info focus:ring-ring"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -1256,8 +1266,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
AWS Role ARN
|
||||
<SimpleTooltip content="Optional. IAM role ARN to assume via STS before signing. If set, LiteLLM calls sts:AssumeRole to get temporary credentials.">
|
||||
{t("mcpServers.serverEdit.aws.roleArn.label")}
|
||||
<SimpleTooltip content={t("mcpServers.serverEdit.aws.roleArn.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
|
|
@ -1267,7 +1277,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="Leave blank to keep existing"
|
||||
placeholder={t("mcpServers.serverEdit.aws.keepExistingPlaceholder")}
|
||||
className="rounded-lg border-border focus:border-info focus:ring-ring"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -1275,8 +1285,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
AWS Session Name
|
||||
<SimpleTooltip content="Optional. Session name for the AssumeRole call — appears in CloudTrail logs. Auto-generated if omitted.">
|
||||
{t("mcpServers.serverEdit.aws.sessionName.label")}
|
||||
<SimpleTooltip content={t("mcpServers.serverEdit.aws.sessionName.tooltip")}>
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
|
|
@ -1286,7 +1296,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="Leave blank to keep existing"
|
||||
placeholder={t("mcpServers.serverEdit.aws.keepExistingPlaceholder")}
|
||||
className="rounded-lg border-border focus:border-info focus:ring-ring"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -1348,10 +1358,10 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
{t("mcpServers.serverEdit.actions.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isOAuthPending}>
|
||||
Save Changes
|
||||
{t("mcpServers.serverEdit.actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -1365,10 +1375,10 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
{t("mcpServers.serverEdit.actions.cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => void submitForm()} disabled={isOAuthPending}>
|
||||
Save Changes
|
||||
{t("mcpServers.serverEdit.actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { mcpServersKeys } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
|
||||
import { ArrowLeft, Eye, EyeOff } from "lucide-react";
|
||||
|
|
@ -69,12 +70,13 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
}) => {
|
||||
// Open the editing Settings tab on first render when returning from the edit OAuth
|
||||
// redirect, so the "token fetched" feedback shows where the user left off (Settings=2).
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const canEdit = isProxyAdmin && !isViewOnly && !mcpServer.is_config;
|
||||
const returningFromEditOAuth = isReturningFromEditOAuth(canEdit, mcpServer.server_id);
|
||||
const [editing, setEditing] = useState(isEditing || returningFromEditOAuth);
|
||||
const [showFullUrl, setShowFullUrl] = useState(false);
|
||||
const networkAccess = getMCPNetworkAccess(mcpServer);
|
||||
const networkAccess = getMCPNetworkAccess(mcpServer, t);
|
||||
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
|
||||
const [selectedTabIndex, setSelectedTabIndex] = useState(returningFromEditOAuth ? 2 : initialTabIndex);
|
||||
const editFormShowsStdioBanner = selectedTabIndex === 2 && editing && canEdit;
|
||||
|
|
@ -117,14 +119,16 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
<div className="mb-6">
|
||||
<Button variant="ghost" className="mb-4" onClick={onBack}>
|
||||
<ArrowLeft />
|
||||
Back to All Servers
|
||||
{t("mcpServers.serverView.backToServers")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold">{mcpServer.server_name || mcpServer.alias || "Unnamed Server"}</h1>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{mcpServer.server_name || mcpServer.alias || t("mcpServers.serverView.unnamedServer")}
|
||||
</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Copy server name"
|
||||
aria-label={t("mcpServers.serverView.copyServerName")}
|
||||
onClick={() => copyToClipboard(mcpServer.server_name || mcpServer.alias, "mcp-server_name")}
|
||||
>
|
||||
{copiedStates["mcp-server_name"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
|
||||
|
|
@ -140,7 +144,7 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Copy server id"
|
||||
aria-label={t("mcpServers.serverView.copyServerId")}
|
||||
onClick={() => copyToClipboard(mcpServer.server_id, "mcp-server-id")}
|
||||
>
|
||||
{copiedStates["mcp-server-id"] ? <CheckIcon size={10} /> : <CopyIcon size={10} />}
|
||||
|
|
@ -154,19 +158,19 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
<Tabs value={String(selectedTabIndex)} onValueChange={(v: unknown) => setSelectedTabIndex(Number(v))}>
|
||||
<TabsList variant="line" className="mb-4 h-auto w-full justify-start rounded-none border-b p-0">
|
||||
<TabsTrigger value="0" className="flex-none rounded-none px-4 py-2">
|
||||
Overview
|
||||
{t("mcpServers.serverView.tabs.overview")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="1" className="flex-none rounded-none px-4 py-2">
|
||||
MCP Tools
|
||||
{t("mcpServers.serverView.tabs.tools")}
|
||||
</TabsTrigger>
|
||||
{isProxyAdmin && (
|
||||
<TabsTrigger value="2" className="flex-none rounded-none px-4 py-2">
|
||||
Settings
|
||||
{t("mcpServers.serverView.tabs.settings")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canViewUserCredentials && (
|
||||
<TabsTrigger value="3" className="flex-none rounded-none px-4 py-2">
|
||||
User Credentials
|
||||
{t("mcpServers.serverView.tabs.userCredentials")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
|
@ -175,19 +179,25 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
<TabsContent value="0" keepMounted>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card className="p-4">
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Transport</p>
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{t("mcpServers.serverView.overview.transport")}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
{getTransportBadge(handleTransport(mcpServer.transport ?? undefined, mcpServer.spec_path ?? undefined))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Authentication</p>
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{t("mcpServers.serverView.overview.authentication")}
|
||||
</p>
|
||||
<div className="mt-3">{getAuthBadge(handleAuth(mcpServer.auth_type ?? undefined))}</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Host URL</p>
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{t("mcpServers.serverView.overview.hostUrl")}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<p className="overflow-wrap-anywhere font-mono text-sm break-all">
|
||||
{renderUrlWithToggle(mcpServer.url, showFullUrl)}
|
||||
|
|
@ -201,7 +211,9 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={showFullUrl ? "Hide full URL" : "Show full URL"}
|
||||
aria-label={
|
||||
showFullUrl ? t("mcpServers.serverView.hideFullUrl") : t("mcpServers.serverView.showFullUrl")
|
||||
}
|
||||
onClick={() => setShowFullUrl(!showFullUrl)}
|
||||
>
|
||||
{showFullUrl ? <EyeOff /> : <Eye />}
|
||||
|
|
@ -211,7 +223,9 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
</Card>
|
||||
</div>
|
||||
<Card className="mt-4 p-4">
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Cost Configuration</p>
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{t("mcpServers.serverView.overview.costConfiguration")}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
|
||||
</div>
|
||||
|
|
@ -239,16 +253,16 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
<TabsContent value="2" keepMounted>
|
||||
<Card className="p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">MCP Server Settings</h2>
|
||||
<h2 className="text-lg font-medium">{t("mcpServers.serverView.settings.title")}</h2>
|
||||
{editing && canEdit ? null : (
|
||||
<Button variant="outline" disabled={!canEdit} onClick={() => setEditing(true)}>
|
||||
Edit Settings
|
||||
{t("mcpServers.serverView.settings.edit")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{mcpServer.is_config && (
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Defined in config. Edit your YAML configuration to make changes
|
||||
{t("mcpServers.serverView.settings.definedInConfig")}
|
||||
</p>
|
||||
)}
|
||||
{editing && canEdit ? (
|
||||
|
|
@ -265,32 +279,42 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Server Name</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.serverName")}
|
||||
</p>
|
||||
<div className="col-span-2 text-sm">
|
||||
{mcpServer.server_name || <span className="text-muted-foreground">—</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Alias</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.alias")}
|
||||
</p>
|
||||
<div className="col-span-2 font-mono text-sm">
|
||||
{mcpServer.alias || <span className="text-muted-foreground">—</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Description</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.description")}
|
||||
</p>
|
||||
<div className="col-span-2 text-sm">
|
||||
{mcpServer.description || <span className="text-muted-foreground">—</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">URL</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.url")}
|
||||
</p>
|
||||
<div className="col-span-2 flex items-center gap-2 font-mono text-sm break-all">
|
||||
{renderUrlWithToggle(mcpServer.url, showFullUrl)}
|
||||
{hasToken && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={showFullUrl ? "Hide full URL" : "Show full URL"}
|
||||
aria-label={
|
||||
showFullUrl ? t("mcpServers.serverView.hideFullUrl") : t("mcpServers.serverView.showFullUrl")
|
||||
}
|
||||
onClick={() => setShowFullUrl(!showFullUrl)}
|
||||
>
|
||||
{showFullUrl ? <EyeOff /> : <Eye />}
|
||||
|
|
@ -299,17 +323,23 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Transport</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.transport")}
|
||||
</p>
|
||||
<div className="col-span-2">
|
||||
{getTransportBadge(handleTransport(mcpServer.transport, mcpServer.spec_path))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Authentication</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.authentication")}
|
||||
</p>
|
||||
<div className="col-span-2">{getAuthBadge(handleAuth(mcpServer.auth_type))}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Extra Headers</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.extraHeaders")}
|
||||
</p>
|
||||
<div className="col-span-2 text-sm">
|
||||
{mcpServer.extra_headers && mcpServer.extra_headers.length > 0 ? (
|
||||
mcpServer.extra_headers.join(", ")
|
||||
|
|
@ -319,20 +349,24 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Allow All Keys</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.allowAllKeys")}
|
||||
</p>
|
||||
<div className="col-span-2">
|
||||
{mcpServer.allow_all_keys ? (
|
||||
<Badge variant="outline">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-success" />
|
||||
Enabled
|
||||
{t("mcpServers.serverView.settings.status.enabled")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Disabled</Badge>
|
||||
<Badge variant="outline">{t("mcpServers.serverView.settings.status.disabled")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Network access</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.networkAccess")}
|
||||
</p>
|
||||
<div className="col-span-2">
|
||||
<Badge variant="outline">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${networkAccess.dotClassName}`} />
|
||||
|
|
@ -343,15 +377,17 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
</div>
|
||||
{handleAuth(mcpServer.auth_type) === "oauth2" && (
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Delegate Auth to Upstream</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.delegateAuth")}
|
||||
</p>
|
||||
<div className="col-span-2">
|
||||
{mcpServer.delegate_auth_to_upstream ? (
|
||||
<Badge variant="outline">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-success" />
|
||||
Enabled (PKCE passthrough)
|
||||
{t("mcpServers.serverView.settings.status.enabledPkcePassthrough")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Disabled</Badge>
|
||||
<Badge variant="outline">{t("mcpServers.serverView.settings.status.disabled")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -360,21 +396,25 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
Array.isArray(mcpServer.extra_headers) &&
|
||||
mcpServer.extra_headers.some((h) => typeof h === "string" && h.toLowerCase() === "authorization") && (
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">OAuth Pass-through</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.oauthPassthrough")}
|
||||
</p>
|
||||
<div className="col-span-2">
|
||||
{mcpServer.oauth_passthrough ? (
|
||||
<Badge variant="outline">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-success" />
|
||||
Enabled
|
||||
{t("mcpServers.serverView.settings.status.enabled")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Disabled</Badge>
|
||||
<Badge variant="outline">{t("mcpServers.serverView.settings.status.disabled")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Access Groups</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.accessGroups")}
|
||||
</p>
|
||||
<div className="col-span-2">
|
||||
{mcpServer.mcp_access_groups && mcpServer.mcp_access_groups.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
|
|
@ -390,7 +430,9 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Allowed Tools</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.allowedTools")}
|
||||
</p>
|
||||
<div className="col-span-2">
|
||||
{mcpServer.allowed_tools && mcpServer.allowed_tools.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
|
|
@ -401,12 +443,14 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Badge variant="outline">All tools enabled</Badge>
|
||||
<Badge variant="outline">{t("mcpServers.serverView.settings.status.allToolsEnabled")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Cost</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.cost")}
|
||||
</p>
|
||||
<div className="col-span-2">
|
||||
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { isAdminRole, isProxyAdminRole, isProxyAdminTierRole } from "@/utils/roles";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import { CircleHelp, Plug, Search } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -19,6 +20,7 @@ import {
|
|||
import React, { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
|
||||
import { useMCPServerHealth } from "@/app/(dashboard)/hooks/mcpServers/useMCPServerHealth";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
|
@ -53,11 +55,11 @@ import { listMCPUserEnvVarStatus } from "@/components/networking";
|
|||
|
||||
export type SortKey = "created_desc" | "updated_desc" | "name_asc" | "health";
|
||||
|
||||
const SORT_OPTIONS: { value: SortKey; label: string }[] = [
|
||||
{ value: "created_desc", label: "Recently created" },
|
||||
{ value: "updated_desc", label: "Recently updated" },
|
||||
{ value: "name_asc", label: "Name (A→Z)" },
|
||||
{ value: "health", label: "Health (unhealthy first)" },
|
||||
const SORT_OPTIONS: { value: SortKey; labelKey: ParseKeys }[] = [
|
||||
{ value: "created_desc", labelKey: "mcpServers.servers.sort.options.createdDesc" },
|
||||
{ value: "updated_desc", labelKey: "mcpServers.servers.sort.options.updatedDesc" },
|
||||
{ value: "name_asc", labelKey: "mcpServers.servers.sort.options.nameAsc" },
|
||||
{ value: "health", labelKey: "mcpServers.servers.sort.options.health" },
|
||||
];
|
||||
|
||||
const HEALTH_RANK: Record<string, number> = {
|
||||
|
|
@ -129,32 +131,31 @@ function DeleteServerDialog({
|
|||
isDeleting: boolean;
|
||||
onConfirm: () => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete MCP Server?</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("mcpServers.servers.deleteDialog.title")}</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This action is permanent and cannot be undone. All associated configurations will be removed.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("mcpServers.servers.deleteDialog.body")}</p>
|
||||
|
||||
{server && (
|
||||
<dl className="mt-3 space-y-1 rounded-lg border border-border bg-muted p-4">
|
||||
{server.server_name && (
|
||||
<div className="flex gap-2">
|
||||
<dt className="text-sm text-muted-foreground">Name</dt>
|
||||
<dt className="text-sm text-muted-foreground">{t("mcpServers.servers.deleteDialog.fields.name")}</dt>
|
||||
<dd className="text-sm font-semibold">{server.server_name}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<dt className="text-sm text-muted-foreground">ID</dt>
|
||||
<dt className="text-sm text-muted-foreground">{t("mcpServers.servers.deleteDialog.fields.id")}</dt>
|
||||
<dd className="font-mono text-xs">{server.server_id}</dd>
|
||||
</div>
|
||||
{server.url && (
|
||||
<div className="flex gap-2">
|
||||
<dt className="text-sm text-muted-foreground">URL</dt>
|
||||
<dt className="text-sm text-muted-foreground">{t("mcpServers.servers.deleteDialog.fields.url")}</dt>
|
||||
<dd className="font-mono text-xs break-all">{server.url}</dd>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -162,9 +163,9 @@ function DeleteServerDialog({
|
|||
)}
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={isDeleting}>{t("mcpServers.servers.deleteDialog.cancel")}</AlertDialogCancel>
|
||||
<Button variant="destructive" disabled={isDeleting} onClick={onConfirm}>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
{isDeleting ? t("mcpServers.servers.deleteDialog.deleting") : t("mcpServers.servers.deleteDialog.delete")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
@ -173,6 +174,7 @@ function DeleteServerDialog({
|
|||
}
|
||||
|
||||
const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID, isViewOnly = false }) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: mcpServers, isLoading: isLoadingServers, refetch } = useMCPServers();
|
||||
|
||||
// Fetch health status for all servers
|
||||
|
|
@ -315,11 +317,13 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID, i
|
|||
// Get unique MCP access groups from all servers
|
||||
const teamSelectItems = React.useMemo(
|
||||
() => ({
|
||||
all: isInternalUser ? "All Available Servers" : "All Servers",
|
||||
personal: "Personal",
|
||||
all: isInternalUser
|
||||
? t("mcpServers.servers.filters.team.allAvailableServers")
|
||||
: t("mcpServers.servers.filters.team.allServers"),
|
||||
personal: t("mcpServers.servers.filters.team.personal"),
|
||||
...Object.fromEntries(uniqueTeams.map((team) => [team.team_id, team.team_alias || team.team_id])),
|
||||
}),
|
||||
[isInternalUser, uniqueTeams],
|
||||
[isInternalUser, uniqueTeams, t],
|
||||
);
|
||||
|
||||
const uniqueMcpAccessGroups = React.useMemo(() => {
|
||||
|
|
@ -335,12 +339,14 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID, i
|
|||
|
||||
const accessGroupSelectItems = React.useMemo(
|
||||
() => ({
|
||||
all: "All Access Groups",
|
||||
all: t("mcpServers.servers.filters.accessGroup.all"),
|
||||
...Object.fromEntries(uniqueMcpAccessGroups.map((group) => [group, group])),
|
||||
}),
|
||||
[uniqueMcpAccessGroups],
|
||||
[uniqueMcpAccessGroups, t],
|
||||
);
|
||||
|
||||
const sortOptions = useMemo(() => SORT_OPTIONS.map(({ value, labelKey }) => ({ value, label: t(labelKey) })), [t]);
|
||||
|
||||
// Filtering logic for both team and access group
|
||||
const filterServers = useCallback(
|
||||
(teamId: string, group: string) => {
|
||||
|
|
@ -409,7 +415,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID, i
|
|||
try {
|
||||
setIsDeletingServer(true);
|
||||
await deleteMCPServer(accessToken, serverIdToDelete);
|
||||
toast.success("Deleted MCP Server successfully");
|
||||
toast.success(t("mcpServers.servers.toasts.deleted"));
|
||||
// If the user is currently viewing the detail page of the server they
|
||||
// just deleted, return them to the All Servers list. Otherwise the
|
||||
// detail view would stay mounted, fall back to an empty stub server,
|
||||
|
|
@ -470,7 +476,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID, i
|
|||
}, [refetch]);
|
||||
|
||||
if (!accessToken || !userRole || !userID) {
|
||||
return <div className="p-6 text-center text-muted-foreground">Missing required authentication parameters.</div>;
|
||||
return <div className="p-6 text-center text-muted-foreground">{t("mcpServers.servers.missingAuth")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -503,23 +509,23 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID, i
|
|||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-semibold">MCP Servers</h1>
|
||||
<h1 className="text-xl font-semibold">{t("mcpServers.servers.header.title")}</h1>
|
||||
{filteredServers.length > 0 && <Badge variant="secondary">{filteredServers.length}</Badge>}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Configure and manage your MCP servers</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("mcpServers.servers.header.subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Link href={uiHref("connect")} className={cn(buttonVariants({ variant: "outline" }), "shrink-0")}>
|
||||
<Plug />
|
||||
My Connections
|
||||
{t("mcpServers.servers.header.myConnections")}
|
||||
</Link>
|
||||
{isAdminRole(userRole) ? (
|
||||
<>
|
||||
<Button className="shrink-0" variant="secondary" onClick={() => setImportVisible(true)}>
|
||||
Import from JSON
|
||||
{t("mcpServers.servers.header.importFromJson")}
|
||||
</Button>
|
||||
<Button className="shrink-0" onClick={() => setDiscoveryVisible(true)}>
|
||||
+ Add New MCP Server
|
||||
{t("mcpServers.servers.header.addServer")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -531,7 +537,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID, i
|
|||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
+ Submit MCP Server
|
||||
{t("mcpServers.servers.header.submitServer")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -560,36 +566,36 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID, i
|
|||
<Tabs defaultValue="servers" className="mt-2 w-full">
|
||||
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
|
||||
<TabsTrigger value="servers" className="flex-none rounded-none px-4 py-2">
|
||||
All Servers
|
||||
{t("mcpServers.servers.tabs.allServers")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="toolsets" className="flex-none rounded-none px-4 py-2">
|
||||
Toolsets
|
||||
{t("mcpServers.servers.tabs.toolsets")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="connect" className="flex-none rounded-none px-4 py-2">
|
||||
Connect
|
||||
{t("mcpServers.servers.tabs.connect")}
|
||||
</TabsTrigger>
|
||||
{isAdminRole(userRole) && (
|
||||
<>
|
||||
<TabsTrigger value="semantic-filter" className="flex-none rounded-none px-4 py-2">
|
||||
Semantic Filter
|
||||
{t("mcpServers.servers.tabs.semanticFilter")}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger value="tool-search" className="flex-none rounded-none px-4 py-2">
|
||||
Tool Search
|
||||
{t("mcpServers.servers.tabs.toolSearch")}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger value="network-settings" className="flex-none rounded-none px-4 py-2">
|
||||
Network Settings
|
||||
{t("mcpServers.servers.tabs.networkSettings")}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger value="submitted" className="flex-none rounded-none px-4 py-2">
|
||||
Submitted MCPs
|
||||
{t("mcpServers.servers.tabs.submitted")}
|
||||
</TabsTrigger>
|
||||
</>
|
||||
)}
|
||||
{isProxyAdminTierRole(userRole) && (
|
||||
<TabsTrigger value="connections" className="flex-none rounded-none px-4 py-2">
|
||||
Live Connections
|
||||
{t("mcpServers.servers.tabs.liveConnections")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
|
@ -616,18 +622,20 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID, i
|
|||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex items-center gap-6 rounded-lg border border-border bg-card px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium whitespace-nowrap text-muted-foreground">Team</p>
|
||||
<p className="text-sm font-medium whitespace-nowrap text-muted-foreground">
|
||||
{t("mcpServers.servers.filters.team.label")}
|
||||
</p>
|
||||
<Select
|
||||
items={teamSelectItems}
|
||||
value={selectedTeam}
|
||||
onValueChange={(v: string | null) => handleTeamChange(v ?? "all")}
|
||||
>
|
||||
<SelectTrigger className="w-55" aria-label="Team">
|
||||
<SelectTrigger className="w-55" aria-label={t("mcpServers.servers.filters.team.label")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{teamSelectItems.all}</SelectItem>
|
||||
<SelectItem value="personal">Personal</SelectItem>
|
||||
<SelectItem value="personal">{t("mcpServers.servers.filters.team.personal")}</SelectItem>
|
||||
{uniqueTeams.map((team) => (
|
||||
<SelectItem key={team.team_id} value={team.team_id}>
|
||||
{team.team_alias || team.team_id}
|
||||
|
|
@ -639,20 +647,17 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID, i
|
|||
<div className="h-6 w-px bg-border" />
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="flex items-center text-sm font-medium whitespace-nowrap text-muted-foreground">
|
||||
Access Group
|
||||
{t("mcpServers.servers.filters.accessGroup.label")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<CircleHelp
|
||||
className="ml-1 size-3.5 text-muted-foreground"
|
||||
aria-label="About access groups"
|
||||
aria-label={t("mcpServers.servers.filters.accessGroup.helpLabel")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
An MCP Access Group is a set of users or teams that have permission to access specific MCP
|
||||
servers. Use access groups to control and organize who can connect to which servers.
|
||||
</TooltipContent>
|
||||
<TooltipContent>{t("mcpServers.servers.filters.accessGroup.help")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</p>
|
||||
<Select
|
||||
|
|
@ -660,11 +665,14 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID, i
|
|||
value={selectedMcpAccessGroup}
|
||||
onValueChange={(v: string | null) => handleMcpAccessGroupChange(v ?? "all")}
|
||||
>
|
||||
<SelectTrigger className="w-55" aria-label="Access Group">
|
||||
<SelectTrigger
|
||||
className="w-55"
|
||||
aria-label={t("mcpServers.servers.filters.accessGroup.label")}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Access Groups</SelectItem>
|
||||
<SelectItem value="all">{t("mcpServers.servers.filters.accessGroup.all")}</SelectItem>
|
||||
{uniqueMcpAccessGroups.map((group) => (
|
||||
<SelectItem key={group} value={group}>
|
||||
{group}
|
||||
|
|
@ -682,15 +690,17 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID, i
|
|||
<Search className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search by name, alias, URL, or ID"
|
||||
placeholder={t("mcpServers.servers.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium whitespace-nowrap text-muted-foreground">Sort</p>
|
||||
<p className="text-sm font-medium whitespace-nowrap text-muted-foreground">
|
||||
{t("mcpServers.servers.sort.label")}
|
||||
</p>
|
||||
<Select
|
||||
items={SORT_OPTIONS}
|
||||
items={sortOptions}
|
||||
value={sortKey}
|
||||
onValueChange={(v: string | null) => setSortKey((v ?? "created_desc") as SortKey)}
|
||||
>
|
||||
|
|
@ -698,7 +708,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID, i
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
{sortOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
|
|
@ -707,21 +717,24 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID, i
|
|||
</Select>
|
||||
</div>
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
{displayedServers.length} of {filteredServers.length} servers
|
||||
{t("mcpServers.servers.resultCount", {
|
||||
shown: displayedServers.length,
|
||||
total: filteredServers.length,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 w-full">
|
||||
{isLoadingServers ? (
|
||||
<div className="flex items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-card p-12">
|
||||
<UiLoadingSpinner className="size-6 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Loading MCP servers...</p>
|
||||
<p className="text-sm text-muted-foreground">{t("mcpServers.servers.loading")}</p>
|
||||
</div>
|
||||
) : displayedServers.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border bg-card p-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredServers.length === 0
|
||||
? "No MCP servers configured. Click '+ Add New MCP Server' to get started."
|
||||
: "No servers match the current filters or search."}
|
||||
? t("mcpServers.servers.empty.noServers")
|
||||
: t("mcpServers.servers.empty.noMatches")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Wrench, CircleCheck, Search, Pencil } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -69,6 +70,7 @@ const ToolRow: React.FC<ToolRowProps> = ({
|
|||
onDisplayNameChange,
|
||||
onDescriptionChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const displayNameValue = toolNameToDisplayName[tool.name] || "";
|
||||
const isDisplayNameInvalid = displayNameValue !== "" && !TOOL_DISPLAY_NAME_PATTERN.test(displayNameValue);
|
||||
|
||||
|
|
@ -85,8 +87,14 @@ const ToolRow: React.FC<ToolRowProps> = ({
|
|||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">{toolNameToDisplayName[tool.name] || tool.name}</p>
|
||||
<Badge variant={isEnabled ? "secondary" : "outline"}>{isEnabled ? "Enabled" : "Disabled"}</Badge>
|
||||
{toolNameToDisplayName[tool.name] && <Badge variant="secondary">Custom name</Badge>}
|
||||
<Badge variant={isEnabled ? "secondary" : "outline"}>
|
||||
{isEnabled
|
||||
? t("mcpServers.toolConfiguration.toolRow.enabled")
|
||||
: t("mcpServers.toolConfiguration.toolRow.disabled")}
|
||||
</Badge>
|
||||
{toolNameToDisplayName[tool.name] && (
|
||||
<Badge variant="secondary">{t("mcpServers.toolConfiguration.toolRow.customName")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{(toolNameToDescription[tool.name] || tool.description) && (
|
||||
<p className="mt-1 block text-sm text-muted-foreground">
|
||||
|
|
@ -94,14 +102,16 @@ const ToolRow: React.FC<ToolRowProps> = ({
|
|||
</p>
|
||||
)}
|
||||
<p className="mt-1 block text-xs text-muted-foreground">
|
||||
{isEnabled ? "✓ Users can call this tool" : "✗ Users cannot call this tool"}
|
||||
{isEnabled
|
||||
? t("mcpServers.toolConfiguration.toolRow.callable")
|
||||
: t("mcpServers.toolConfiguration.toolRow.notCallable")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={(e) => onToggleExpand(tool.name, e)}
|
||||
title="Edit display name and description"
|
||||
title={t("mcpServers.toolConfiguration.toolRow.editTitle")}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
|
|
@ -113,7 +123,9 @@ const ToolRow: React.FC<ToolRowProps> = ({
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div>
|
||||
<p className="mb-1 block text-xs font-medium">Display Name</p>
|
||||
<p className="mb-1 block text-xs font-medium">
|
||||
{t("mcpServers.toolConfiguration.toolRow.displayNameLabel")}
|
||||
</p>
|
||||
<Input
|
||||
placeholder={tool.name}
|
||||
value={toolNameToDisplayName[tool.name] || ""}
|
||||
|
|
@ -122,25 +134,27 @@ const ToolRow: React.FC<ToolRowProps> = ({
|
|||
/>
|
||||
{isDisplayNameInvalid ? (
|
||||
<p className="mt-1 block text-xs text-destructive">
|
||||
Only letters, digits, underscores, and hyphens are allowed (no spaces).
|
||||
{t("mcpServers.toolConfiguration.toolRow.displayNameInvalid")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 block text-xs text-muted-foreground">
|
||||
Override how this tool's name appears to users. Leave blank to use original.
|
||||
{t("mcpServers.toolConfiguration.toolRow.displayNameHelp")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 block text-xs font-medium">Description</p>
|
||||
<p className="mb-1 block text-xs font-medium">
|
||||
{t("mcpServers.toolConfiguration.toolRow.descriptionLabel")}
|
||||
</p>
|
||||
<Textarea
|
||||
className="field-sizing-fixed"
|
||||
placeholder={tool.description || "No description"}
|
||||
placeholder={tool.description || t("mcpServers.toolConfiguration.toolRow.noDescription")}
|
||||
value={toolNameToDescription[tool.name] || ""}
|
||||
onChange={(e) => onDescriptionChange(tool.name, e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
<p className="mt-1 block text-xs text-muted-foreground">
|
||||
Override the tool description shown to users. Leave blank to use original.
|
||||
{t("mcpServers.toolConfiguration.toolRow.descriptionHelp")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -169,6 +183,7 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
|||
externalCanFetch,
|
||||
isEditMode = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const previousToolsRef = useRef<ToolEntry[]>([]);
|
||||
const [toolSearchTerm, setToolSearchTerm] = useState("");
|
||||
const [viewMode, setViewMode] = useState<"crud" | "flat">("crud");
|
||||
|
|
@ -398,7 +413,7 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
|||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wrench className="size-4 text-muted-foreground" />
|
||||
<h3 className="text-lg font-medium">Tool Configuration</h3>
|
||||
<h3 className="text-lg font-medium">{t("mcpServers.toolConfiguration.title")}</h3>
|
||||
{tools.length > 0 && <Badge variant="secondary">{tools.length}</Badge>}
|
||||
</div>
|
||||
{tools.length > 0 && (
|
||||
|
|
@ -408,14 +423,14 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
|||
variant={viewMode === "crud" ? "default" : "outline"}
|
||||
onClick={() => setViewMode("crud")}
|
||||
>
|
||||
Risk Groups
|
||||
{t("mcpServers.toolConfiguration.viewModes.riskGroups")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={viewMode === "flat" ? "default" : "outline"}
|
||||
onClick={() => setViewMode("flat")}
|
||||
>
|
||||
Flat List
|
||||
{t("mcpServers.toolConfiguration.viewModes.flatList")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -424,8 +439,7 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
|||
{/* Description */}
|
||||
<div className="rounded-lg border border-border bg-muted p-3">
|
||||
<p className="text-sm">
|
||||
<strong>Select which tools users can call:</strong> Only checked tools will be available for users to
|
||||
invoke. Unchecked tools will be blocked from execution.
|
||||
<Trans i18nKey="mcpServers.toolConfiguration.intro" components={{ strong: <strong /> }} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -433,7 +447,7 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
|||
{isLoadingTools && (
|
||||
<div className="flex items-center justify-center gap-3 py-6">
|
||||
<UiLoadingSpinner className="size-6 text-muted-foreground" />
|
||||
<p className="text-sm">Loading tools...</p>
|
||||
<p className="text-sm">{t("mcpServers.toolConfiguration.loading")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -447,7 +461,7 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
|||
{toolsError && !isLoadingTools && !isPreviewForbidden && (
|
||||
<div className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 py-6 text-center">
|
||||
<Wrench className="mx-auto mb-2 size-6 text-destructive" />
|
||||
<p className="text-sm font-medium text-destructive">Unable to load tools</p>
|
||||
<p className="text-sm font-medium text-destructive">{t("mcpServers.toolConfiguration.loadError")}</p>
|
||||
<p className="text-sm text-destructive">{toolsError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -460,14 +474,18 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
|||
(keyTools && keyTools.length > 0 ? (
|
||||
<div className="rounded-lg border border-dashed py-4 text-center text-muted-foreground">
|
||||
<Wrench className="mx-auto mb-2 size-6" />
|
||||
<p className="text-sm">No tools loaded from spec</p>
|
||||
<p className="mt-1 block text-sm">Expected tools: {keyTools.map((t) => t.name).join(", ")}</p>
|
||||
<p className="text-sm">{t("mcpServers.toolConfiguration.noToolsFromSpec")}</p>
|
||||
<p className="mt-1 block text-sm">
|
||||
{t("mcpServers.toolConfiguration.expectedTools", {
|
||||
tools: keyTools.map((keyTool) => keyTool.name).join(", "),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed py-6 text-center text-muted-foreground">
|
||||
<Wrench className="mx-auto mb-2 size-6" />
|
||||
<p className="text-sm">No tools available for configuration</p>
|
||||
<p className="text-sm">Connect to an MCP server with tools to configure them</p>
|
||||
<p className="text-sm">{t("mcpServers.toolConfiguration.noTools.title")}</p>
|
||||
<p className="text-sm">{t("mcpServers.toolConfiguration.noTools.hint")}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
|
@ -475,8 +493,8 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
|||
{!canFetchTools && (formValues.url || formValues.spec_path) && (
|
||||
<div className="rounded-lg border border-dashed py-6 text-center text-muted-foreground">
|
||||
<Wrench className="mx-auto mb-2 size-6" />
|
||||
<p className="text-sm">Complete required fields to configure tools</p>
|
||||
<p className="text-sm">Fill in URL, Transport, and Authentication to load available tools</p>
|
||||
<p className="text-sm">{t("mcpServers.toolConfiguration.incomplete.title")}</p>
|
||||
<p className="text-sm">{t("mcpServers.toolConfiguration.incomplete.hint")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -486,8 +504,10 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
|||
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted p-3">
|
||||
<CircleCheck className="size-4" />
|
||||
<p className="text-sm font-medium">
|
||||
{effectiveAllowedTools.length} of {tools.length} {tools.length === 1 ? "tool" : "tools"} enabled for
|
||||
user access
|
||||
{t("mcpServers.toolConfiguration.enabledSummary", {
|
||||
enabled: effectiveAllowedTools.length,
|
||||
count: tools.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -497,7 +517,7 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
|||
<Search className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search tools by name or description..."
|
||||
placeholder={t("mcpServers.toolConfiguration.searchPlaceholder")}
|
||||
value={toolSearchTerm}
|
||||
onChange={(e) => setToolSearchTerm(e.target.value)}
|
||||
/>
|
||||
|
|
@ -519,7 +539,9 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
|||
{filteredTools.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed py-6 text-center text-muted-foreground">
|
||||
<Search className="mx-auto mb-2 size-6" />
|
||||
<p className="text-sm">No tools found matching "{toolSearchTerm}"</p>
|
||||
<p className="text-sm">
|
||||
{t("mcpServers.toolConfiguration.noMatches", { searchTerm: toolSearchTerm })}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
|
|
@ -527,14 +549,14 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
|||
<>
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<p className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
Suggested tools
|
||||
{t("mcpServers.toolConfiguration.suggestedTools")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="link" size="sm" onClick={handleEnableSuggested}>
|
||||
Enable all
|
||||
{t("mcpServers.toolConfiguration.enableAll")}
|
||||
</Button>
|
||||
<Button variant="link" size="sm" onClick={handleDisableSuggested}>
|
||||
Disable all
|
||||
{t("mcpServers.toolConfiguration.disableAll")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -558,14 +580,16 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
|||
<>
|
||||
<div className="flex items-center justify-between px-1 pt-2">
|
||||
<p className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
{pinnedFiltered.length > 0 ? "All tools" : "Tools"}
|
||||
{pinnedFiltered.length > 0
|
||||
? t("mcpServers.toolConfiguration.allTools")
|
||||
: t("mcpServers.toolConfiguration.tools")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="link" size="sm" onClick={handleEnableRest}>
|
||||
Enable all
|
||||
{t("mcpServers.toolConfiguration.enableAll")}
|
||||
</Button>
|
||||
<Button variant="link" size="sm" onClick={handleDisableRest}>
|
||||
Disable all
|
||||
{t("mcpServers.toolConfiguration.disableAll")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ToolTestPanel } from "./ToolTestPanel";
|
||||
import { resolveLogoSrc } from "@/lib/assetPaths";
|
||||
import {
|
||||
|
|
@ -39,6 +40,7 @@ const MCPToolsViewer = ({
|
|||
serverAlias,
|
||||
extraHeaders,
|
||||
}: MCPToolsViewerProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedTool, setSelectedTool] = useState<MCPTool | null>(null);
|
||||
const [toolResult, setToolResult] = useState<MCPContent[] | null>(null);
|
||||
const [toolError, setToolError] = useState<Error | null>(null);
|
||||
|
|
@ -294,7 +296,7 @@ const MCPToolsViewer = ({
|
|||
<div className="grid h-auto w-full grid-cols-4 gap-4">
|
||||
{/* Left Sidebar with Controls */}
|
||||
<div className="col-span-1 flex flex-col bg-muted p-4">
|
||||
<h2 className="mt-2 mb-6 text-xl font-semibold">MCP Tools</h2>
|
||||
<h2 className="mt-2 mb-6 text-xl font-semibold">{t("mcpServers.tools.title")}</h2>
|
||||
|
||||
<div className="flex flex-col flex-1">
|
||||
{/* Extra Headers Input Section */}
|
||||
|
|
@ -303,17 +305,15 @@ const MCPToolsViewer = ({
|
|||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Key className="mr-2 size-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">Additional Headers</p>
|
||||
<p className="text-sm font-medium">{t("mcpServers.tools.additionalHeaders")}</p>
|
||||
</div>
|
||||
<Button variant="link" size="sm" onClick={() => setShowHeaderInput(!showHeaderInput)}>
|
||||
{showHeaderInput ? "Hide" : "Configure"}
|
||||
{showHeaderInput ? t("mcpServers.tools.hide") : t("mcpServers.tools.configure")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!showHeaderInput && Object.keys(passthroughHeaders).length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This server requires additional headers. Click "Configure" to provide values.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("mcpServers.tools.headersRequired")}</p>
|
||||
)}
|
||||
|
||||
{showHeaderInput && (
|
||||
|
|
@ -326,7 +326,7 @@ const MCPToolsViewer = ({
|
|||
<Key className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder={`Enter ${headerName}`}
|
||||
placeholder={t("mcpServers.tools.headerPlaceholder", { headerName })}
|
||||
value={passthroughHeaders[headerName] || ""}
|
||||
onChange={(e) => {
|
||||
setPassthroughHeaders({
|
||||
|
|
@ -347,7 +347,7 @@ const MCPToolsViewer = ({
|
|||
disabled={Object.values(passthroughHeaders).every((v) => !v || !v.trim())}
|
||||
className="mt-2 w-full"
|
||||
>
|
||||
Load Tools
|
||||
{t("mcpServers.tools.loadTools")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -356,7 +356,9 @@ const MCPToolsViewer = ({
|
|||
<div className="mt-2">
|
||||
<p className="flex items-center text-xs text-muted-foreground">
|
||||
<span className="mr-2 inline-block size-2 rounded-full bg-success" />
|
||||
{Object.keys(passthroughHeaders).length} header(s) configured
|
||||
{t("mcpServers.tools.headersConfigured", {
|
||||
headerCount: Object.keys(passthroughHeaders).length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -366,7 +368,7 @@ const MCPToolsViewer = ({
|
|||
{/* Tool Selection - Show tools first */}
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<p className="mb-3 flex items-center text-sm font-medium">
|
||||
<Wrench className="mr-2 size-4" /> Available Tools
|
||||
<Wrench className="mr-2 size-4" /> {t("mcpServers.tools.availableTools")}
|
||||
{toolsData.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{toolsData.length}
|
||||
|
|
@ -378,14 +380,14 @@ const MCPToolsViewer = ({
|
|||
{usesBrowserHeldToken && !oauthToken && (
|
||||
<div className="rounded-lg border border-border bg-card p-4 text-center">
|
||||
<Lock className="mx-auto mb-2 size-6 text-muted-foreground" />
|
||||
<p className="mb-1 text-xs font-medium">Authentication required</p>
|
||||
<p className="mb-3 text-xs text-muted-foreground">Authenticate to view available tools</p>
|
||||
<p className="mb-1 text-xs font-medium">{t("mcpServers.tools.authRequired")}</p>
|
||||
<p className="mb-3 text-xs text-muted-foreground">{t("mcpServers.tools.authenticateToView")}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={startOAuthFlow}
|
||||
disabled={!accessToken || oauthStatus === "authorizing" || oauthStatus === "exchanging"}
|
||||
>
|
||||
Authorize
|
||||
{t("mcpServers.tools.authorize")}
|
||||
</Button>
|
||||
{oauthError && <p className="mt-2 text-xs text-destructive">{oauthError}</p>}
|
||||
</div>
|
||||
|
|
@ -399,16 +401,14 @@ const MCPToolsViewer = ({
|
|||
{(authorizationCodeNeedsAuth || authorizationCodeTokenRejected) && (
|
||||
<div className="rounded-lg border border-border bg-card p-4 text-center">
|
||||
<Lock className="mx-auto mb-2 size-6 text-muted-foreground" />
|
||||
<p className="mb-1 text-xs font-medium">Authentication required</p>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Authenticate with the upstream provider to view available tools
|
||||
</p>
|
||||
<p className="mb-1 text-xs font-medium">{t("mcpServers.tools.authRequired")}</p>
|
||||
<p className="mb-3 text-xs text-muted-foreground">{t("mcpServers.tools.authenticateUpstream")}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={startAuthorizationCodeAuthorize}
|
||||
disabled={!accessToken || dbOAuthStatus === "authorizing" || dbOAuthStatus === "exchanging"}
|
||||
>
|
||||
Authorize
|
||||
{t("mcpServers.tools.authorize")}
|
||||
</Button>
|
||||
{dbOAuthError && <p className="mt-2 text-xs text-destructive">{dbOAuthError}</p>}
|
||||
</div>
|
||||
|
|
@ -424,7 +424,7 @@ const MCPToolsViewer = ({
|
|||
<Search className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search tools..."
|
||||
placeholder={t("mcpServers.tools.searchPlaceholder")}
|
||||
value={toolSearchTerm}
|
||||
onChange={(e) => setToolSearchTerm(e.target.value)}
|
||||
/>
|
||||
|
|
@ -436,7 +436,7 @@ const MCPToolsViewer = ({
|
|||
{toolsAreaLoading && (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-border bg-card py-8">
|
||||
<UiLoadingSpinner className="mb-3 size-6 text-muted-foreground" />
|
||||
<p className="text-xs font-medium">Loading tools...</p>
|
||||
<p className="text-xs font-medium">{t("mcpServers.tools.loading")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -444,7 +444,9 @@ const MCPToolsViewer = ({
|
|||
{(mcpToolsResponse?.error || mcpToolsError) && !toolsAreaLoading && !toolsData.length && (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-xs text-destructive">
|
||||
<p className="font-medium">
|
||||
Error: {mcpToolsResponse?.message || (mcpToolsError as Error)?.message}
|
||||
{t("mcpServers.tools.error", {
|
||||
message: mcpToolsResponse?.message || (mcpToolsError as Error)?.message,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -470,8 +472,8 @@ const MCPToolsViewer = ({
|
|||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mb-1 text-xs font-medium">No tools available</p>
|
||||
<p className="text-xs text-muted-foreground">No tools found for this server</p>
|
||||
<p className="mb-1 text-xs font-medium">{t("mcpServers.tools.noTools.title")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("mcpServers.tools.noTools.description")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -481,8 +483,10 @@ const MCPToolsViewer = ({
|
|||
{filteredTools.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-card p-4 text-center">
|
||||
<Search className="mx-auto mb-2 size-6 text-muted-foreground" />
|
||||
<p className="mb-1 text-xs font-medium">No tools found</p>
|
||||
<p className="text-xs text-muted-foreground">No tools match "{toolSearchTerm}"</p>
|
||||
<p className="mb-1 text-xs font-medium">{t("mcpServers.tools.noMatches.title")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("mcpServers.tools.noMatches.description", { searchTerm: toolSearchTerm })}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mcp-tools-scrollable max-h-100 min-h-0 flex-1 space-y-2 overflow-y-auto">
|
||||
|
|
@ -505,7 +509,7 @@ const MCPToolsViewer = ({
|
|||
{tool.mcp_info.logo_url && (
|
||||
<img
|
||||
src={resolveLogoSrc(tool.mcp_info.logo_url)}
|
||||
alt={`${tool.mcp_info.server_name} logo`}
|
||||
alt={t("mcpServers.tools.logoAlt", { serverName: tool.mcp_info.server_name })}
|
||||
className="w-4 h-4 object-contain shrink-0 mt-0.5"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -529,7 +533,7 @@ const MCPToolsViewer = ({
|
|||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Selected
|
||||
{t("mcpServers.tools.selected")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -548,7 +552,7 @@ const MCPToolsViewer = ({
|
|||
{/* Main Testing Area */}
|
||||
<div className="col-span-3 flex flex-col">
|
||||
<div className="flex items-center justify-between border-b border-border p-4">
|
||||
<h2 className="mb-0 text-xl font-semibold">Tool Testing Playground</h2>
|
||||
<h2 className="mb-0 text-xl font-semibold">{t("mcpServers.tools.playground.title")}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
|
|
@ -556,10 +560,8 @@ const MCPToolsViewer = ({
|
|||
/* Empty State */
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<Bot className="mb-4 size-12" />
|
||||
<p className="mb-2 text-lg font-medium">Select a Tool to Test</p>
|
||||
<p className="max-w-md text-center text-sm">
|
||||
Choose a tool from the left sidebar to start testing its functionality with custom inputs.
|
||||
</p>
|
||||
<p className="mb-2 text-lg font-medium">{t("mcpServers.tools.playground.emptyTitle")}</p>
|
||||
<p className="max-w-md text-center text-sm">{t("mcpServers.tools.playground.emptyDescription")}</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Tool Test Panel */
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { TFunction } from "i18next";
|
||||
import type { Resolver, ResolverResult } from "react-hook-form";
|
||||
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { InputSchema, InputSchemaProperty } from "@/components/mcp_tools/types";
|
||||
|
||||
export interface ToolArgumentField {
|
||||
|
|
@ -51,27 +53,31 @@ const isBlank = (value: unknown): boolean => value === undefined || value === nu
|
|||
const isUnsetArgument = (prop: InputSchemaProperty, value: unknown): boolean =>
|
||||
prop.type === "string" && prop.enum ? value == null : isBlank(typeof value === "string" ? value.trim() : value);
|
||||
|
||||
export const validateToolArgument = (field: ToolArgumentField, value: unknown): string | undefined => {
|
||||
export const validateToolArgument = (
|
||||
field: ToolArgumentField,
|
||||
value: unknown,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string | undefined => {
|
||||
const prop = resolveSchemaProperty(field.prop);
|
||||
if (field.required && isUnsetArgument(prop, value)) {
|
||||
return `Please enter ${field.key}`;
|
||||
return t("mcpServers.toolCallArguments.validation.required", { field: field.key });
|
||||
}
|
||||
if (prop.type === "string" && prop.enum) {
|
||||
if (!isUnsetArgument(prop, value) && !prop.enum.includes(String(value)))
|
||||
return `Please select a valid ${field.key}`;
|
||||
return t("mcpServers.toolCallArguments.validation.invalidOption", { field: field.key });
|
||||
}
|
||||
if (!isJsonField(prop) || (isBlank(value) && !field.required)) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = parseJson(value);
|
||||
if (parsed.kind === "invalid") {
|
||||
return "Invalid JSON";
|
||||
return t("mcpServers.toolCallArguments.validation.invalidJson");
|
||||
}
|
||||
if (prop.type === "object" && !isPlainObject(parsed.value)) {
|
||||
return "Please enter a JSON object";
|
||||
return t("mcpServers.toolCallArguments.validation.expectedObject");
|
||||
}
|
||||
if (prop.type === "array" && !Array.isArray(parsed.value)) {
|
||||
return "Please enter a JSON array";
|
||||
return t("mcpServers.toolCallArguments.validation.expectedArray");
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
|
@ -115,10 +121,10 @@ export const buildToolCallArguments = (
|
|||
);
|
||||
|
||||
export const toolArgumentsResolver =
|
||||
(fields: readonly ToolArgumentField[]): Resolver<ToolArgumentsFormValues> =>
|
||||
(fields: readonly ToolArgumentField[], t: TFunction = i18n.t.bind(i18n)): Resolver<ToolArgumentsFormValues> =>
|
||||
(values): ResolverResult<ToolArgumentsFormValues> => {
|
||||
const issues = fields
|
||||
.map((field, index) => ({ index, message: validateToolArgument(field, values.args[index]) }))
|
||||
.map((field, index) => ({ index, message: validateToolArgument(field, values.args[index], t) }))
|
||||
.filter((issue): issue is { index: number; message: string } => issue.message !== undefined);
|
||||
|
||||
if (issues.length === 0) {
|
||||
|
|
|
|||
|
|
@ -1,35 +1,37 @@
|
|||
import type { TFunction } from "i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { MCPEnvVar, MCPEnvVarScope, type MCPServer } from "@/components/mcp_tools/types";
|
||||
|
||||
export const getMCPNetworkAccess = (
|
||||
server: Pick<MCPServer, "available_on_public_internet" | "mcp_info">,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): {
|
||||
readonly label: "All Networks" | "Internal Only" | "Unknown";
|
||||
readonly label: string;
|
||||
readonly dotClassName: string;
|
||||
readonly description: string;
|
||||
} => {
|
||||
const explicitlyPublished = server.mcp_info?.is_public_explicit;
|
||||
if (server.available_on_public_internet === true || explicitlyPublished === true) {
|
||||
return {
|
||||
label: "All Networks",
|
||||
label: t("mcpServers.utils.networkAccess.allNetworks.label"),
|
||||
dotClassName: "bg-success",
|
||||
description:
|
||||
server.available_on_public_internet === true
|
||||
? "Allows requests from public and internal IPs. Authentication and access permissions still apply"
|
||||
: "Allows requests from public and internal IPs because this server is published in MCP Hub. Authentication and access permissions still apply",
|
||||
? t("mcpServers.utils.networkAccess.allNetworks.publicDescription")
|
||||
: t("mcpServers.utils.networkAccess.allNetworks.hubDescription"),
|
||||
};
|
||||
}
|
||||
if (server.available_on_public_internet === false && explicitlyPublished === false) {
|
||||
return {
|
||||
label: "Internal Only",
|
||||
label: t("mcpServers.utils.networkAccess.internalOnly.label"),
|
||||
dotClassName: "bg-warning",
|
||||
description:
|
||||
"Allows requests only from internal/private IP ranges. Authentication and access permissions still apply",
|
||||
description: t("mcpServers.utils.networkAccess.internalOnly.description"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "Unknown",
|
||||
label: t("mcpServers.utils.networkAccess.unknown.label"),
|
||||
dotClassName: "bg-border",
|
||||
description: "The proxy did not report enough network and publication settings to determine allowed client IPs",
|
||||
description: t("mcpServers.utils.networkAccess.unknown.description"),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -72,26 +74,24 @@ export const getMaskedAndFullUrl = (url: string): { maskedUrl: string; hasToken:
|
|||
};
|
||||
|
||||
// Validation utilities for MCP server forms
|
||||
export const validateMCPServerUrl = (value: string) => {
|
||||
export const validateMCPServerUrl = (value: string, t: TFunction = i18n.t.bind(i18n)) => {
|
||||
if (!value) return Promise.resolve();
|
||||
// More flexible URL validation that allows Kubernetes service names and various URL formats
|
||||
const urlPattern = /^https?:\/\/[^\s/$.?#].[^\s]*$/i;
|
||||
return urlPattern.test(value)
|
||||
? Promise.resolve()
|
||||
: Promise.reject("Please enter a valid URL (e.g., http://service-name.domain:1234/path or https://example.com)");
|
||||
return urlPattern.test(value) ? Promise.resolve() : Promise.reject(t("mcpServers.utils.validation.invalidUrl"));
|
||||
};
|
||||
|
||||
export const validateMCPServerName = (value: string) => {
|
||||
export const validateMCPServerName = (value: string, t: TFunction = i18n.t.bind(i18n)) => {
|
||||
return value && (value.includes("-") || value.includes(" "))
|
||||
? Promise.reject("Cannot contain '-' (hyphen) or spaces. Please use '_' (underscore) instead.")
|
||||
? Promise.reject(t("mcpServers.utils.validation.invalidServerName"))
|
||||
: Promise.resolve();
|
||||
};
|
||||
|
||||
export const TOOL_DISPLAY_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
export const validateToolDisplayName = (value: string) => {
|
||||
export const validateToolDisplayName = (value: string, t: TFunction = i18n.t.bind(i18n)) => {
|
||||
return value && !TOOL_DISPLAY_NAME_PATTERN.test(value)
|
||||
? Promise.reject("Only letters, digits, underscores, and hyphens are allowed (no spaces).")
|
||||
? Promise.reject(t("mcpServers.utils.validation.invalidToolDisplayName"))
|
||||
: Promise.resolve();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { Suspense, useEffect, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
|
||||
|
||||
// Written to sessionStorage so the admin hook (useMcpOAuthFlow), the user hook
|
||||
|
|
@ -28,6 +29,7 @@ const resolveDefaultRedirect = () => {
|
|||
};
|
||||
|
||||
const McpOAuthCallbackContent = () => {
|
||||
const { t } = useTranslation();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const payload = useMemo(() => {
|
||||
|
|
@ -80,20 +82,19 @@ const McpOAuthCallbackContent = () => {
|
|||
<div className="min-h-screen flex items-center justify-center bg-muted p-6">
|
||||
<div className="max-w-lg w-full rounded-lg bg-card shadow-md p-8 text-center space-y-4">
|
||||
<h1 className="text-xl font-semibold text-foreground">LiteLLM MCP OAuth</h1>
|
||||
<p className="text-sm text-foreground">
|
||||
Authorization complete. You may close this window and return to the LiteLLM dashboard.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If the window does not close automatically, everything is still saved—you can close it manually.
|
||||
</p>
|
||||
<p className="text-sm text-foreground">{t("mcpOAuthCallback.authorizationComplete")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("mcpOAuthCallback.manualCloseHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const McpOAuthCallbackPage = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">Loading...</div>}>
|
||||
<Suspense
|
||||
fallback={<div className="min-h-screen flex items-center justify-center">{t("mcpOAuthCallback.loading")}</div>}
|
||||
>
|
||||
<McpOAuthCallbackContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { CircleCheck, CircleHelp, Info, Save, X } from "lucide-react";
|
|||
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
|
|
@ -81,6 +82,7 @@ const parseTopK = (raw: string, rawAsNumber: number): number | null =>
|
|||
raw === "" || Number.isNaN(rawAsNumber) ? null : rawAsNumber;
|
||||
|
||||
const SaveSuccessAlert = () => {
|
||||
const { t } = useTranslation();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
if (dismissed) {
|
||||
|
|
@ -90,9 +92,14 @@ const SaveSuccessAlert = () => {
|
|||
return (
|
||||
<Alert variant="success" className="mb-4">
|
||||
<CircleCheck />
|
||||
<AlertTitle>Settings saved successfully</AlertTitle>
|
||||
<AlertTitle>{t("mcpServers.semanticFilterSettings.saveSuccess.title")}</AlertTitle>
|
||||
<AlertAction>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="Close" onClick={() => setDismissed(true)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t("mcpServers.semanticFilterSettings.saveSuccess.close")}
|
||||
onClick={() => setDismissed(true)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</AlertAction>
|
||||
|
|
@ -101,6 +108,7 @@ const SaveSuccessAlert = () => {
|
|||
};
|
||||
|
||||
export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFilterSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError, error } = useMCPSemanticFilterSettings();
|
||||
const {
|
||||
mutate: updateSettings,
|
||||
|
|
@ -164,7 +172,7 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi
|
|||
setIsDirty(false);
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 3000);
|
||||
toast.success("Settings updated successfully. Changes will be applied across all pods within 10 seconds.");
|
||||
toast.success(t("mcpServers.semanticFilterSettings.toasts.updated"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.fromError(error);
|
||||
|
|
@ -177,19 +185,24 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi
|
|||
return;
|
||||
}
|
||||
|
||||
await runSemanticFilterTest({
|
||||
accessToken,
|
||||
testModel,
|
||||
testQuery,
|
||||
setIsTesting,
|
||||
setTestResult,
|
||||
setTestError,
|
||||
});
|
||||
await runSemanticFilterTest(
|
||||
{
|
||||
accessToken,
|
||||
testModel,
|
||||
testQuery,
|
||||
setIsTesting,
|
||||
setTestResult,
|
||||
setTestError,
|
||||
},
|
||||
t,
|
||||
);
|
||||
};
|
||||
|
||||
if (!accessToken) {
|
||||
return (
|
||||
<div className="p-6 text-center text-muted-foreground">Please log in to configure semantic filter settings.</div>
|
||||
<div className="p-6 text-center text-muted-foreground">
|
||||
{t("mcpServers.semanticFilterSettings.loginRequired")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -204,26 +217,22 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi
|
|||
</div>
|
||||
) : isError ? (
|
||||
<Alert variant="error" className="mb-6">
|
||||
<AlertTitle>Could not load MCP Semantic Filter settings</AlertTitle>
|
||||
<AlertTitle>{t("mcpServers.semanticFilterSettings.loadError.title")}</AlertTitle>
|
||||
{error instanceof Error && <AlertDescription>{error.message}</AlertDescription>}
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Alert variant="info" className="mb-6">
|
||||
<Info />
|
||||
<AlertTitle>Semantic Tool Filtering</AlertTitle>
|
||||
<AlertDescription>
|
||||
Filter MCP tools semantically based on query relevance. This reduces context window size and improves tool
|
||||
selection accuracy. Click 'Save Settings' to apply changes across all pods (takes effect within
|
||||
10 seconds).
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t("mcpServers.semanticFilterSettings.intro.title")}</AlertTitle>
|
||||
<AlertDescription>{t("mcpServers.semanticFilterSettings.intro.description")}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{saveSuccess && <SaveSuccessAlert />}
|
||||
|
||||
{updateError && (
|
||||
<Alert variant="error" className="mb-4">
|
||||
<AlertTitle>Could not update settings</AlertTitle>
|
||||
<AlertTitle>{t("mcpServers.semanticFilterSettings.updateError.title")}</AlertTitle>
|
||||
{updateError instanceof Error && <AlertDescription>{updateError.message}</AlertDescription>}
|
||||
</Alert>
|
||||
)}
|
||||
|
|
@ -240,8 +249,8 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi
|
|||
control={form.control}
|
||||
name="enabled"
|
||||
label={labelWithHint(
|
||||
"Enable Semantic Filtering",
|
||||
"When enabled, only the most relevant MCP tools will be included in requests based on semantic similarity",
|
||||
t("mcpServers.semanticFilterSettings.fields.enabled.label"),
|
||||
t("mcpServers.semanticFilterSettings.fields.enabled.hint"),
|
||||
)}
|
||||
description={schema?.properties?.enabled?.description}
|
||||
>
|
||||
|
|
@ -261,7 +270,7 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi
|
|||
|
||||
<Card className="mb-4">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Configuration</CardTitle>
|
||||
<CardTitle>{t("mcpServers.semanticFilterSettings.configuration.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
|
|
@ -269,8 +278,8 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi
|
|||
control={form.control}
|
||||
name="embedding_model"
|
||||
label={labelWithHint(
|
||||
"Embedding Model",
|
||||
"The model used to generate embeddings for semantic matching",
|
||||
t("mcpServers.semanticFilterSettings.fields.embeddingModel.label"),
|
||||
t("mcpServers.semanticFilterSettings.fields.embeddingModel.hint"),
|
||||
)}
|
||||
>
|
||||
{({ value, onChange, id }) => (
|
||||
|
|
@ -283,8 +292,16 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi
|
|||
value={value}
|
||||
onValueChange={(selected) => commitChange(onChange, selected)}
|
||||
allowClear={false}
|
||||
placeholder={loadingModels ? "Loading models..." : "Select embedding model"}
|
||||
emptyText={loadingModels ? "Loading..." : "No embedding models available"}
|
||||
placeholder={
|
||||
loadingModels
|
||||
? t("mcpServers.semanticFilterSettings.fields.embeddingModel.loadingPlaceholder")
|
||||
: t("mcpServers.semanticFilterSettings.fields.embeddingModel.placeholder")
|
||||
}
|
||||
emptyText={
|
||||
loadingModels
|
||||
? t("mcpServers.semanticFilterSettings.fields.embeddingModel.loadingEmpty")
|
||||
: t("mcpServers.semanticFilterSettings.fields.embeddingModel.empty")
|
||||
}
|
||||
disabled={isUpdating || loadingModels}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -293,7 +310,10 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="top_k"
|
||||
label={labelWithHint("Top K Results", "Maximum number of tools to return after filtering")}
|
||||
label={labelWithHint(
|
||||
t("mcpServers.semanticFilterSettings.fields.topK.label"),
|
||||
t("mcpServers.semanticFilterSettings.fields.topK.hint"),
|
||||
)}
|
||||
>
|
||||
{({ ref, value, onChange, onBlur, id }) => (
|
||||
<Input
|
||||
|
|
@ -319,8 +339,8 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi
|
|||
control={form.control}
|
||||
name="similarity_threshold"
|
||||
label={labelWithHint(
|
||||
"Similarity Threshold",
|
||||
"Minimum similarity score (0-1) for a tool to be included",
|
||||
t("mcpServers.semanticFilterSettings.fields.similarityThreshold.label"),
|
||||
t("mcpServers.semanticFilterSettings.fields.similarityThreshold.hint"),
|
||||
)}
|
||||
>
|
||||
{({ value, onChange, id }) => (
|
||||
|
|
@ -359,7 +379,7 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi
|
|||
disabled={!isDirty || isUpdating}
|
||||
>
|
||||
{isUpdating ? <UiLoadingSpinner className="size-4" /> : <Save />}
|
||||
Save Settings
|
||||
{t("mcpServers.semanticFilterSettings.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Code, CircleAlert, CirclePlay, Info } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
|
@ -34,22 +35,23 @@ export default function MCPSemanticFilterTestPanel({
|
|||
testError,
|
||||
curlCommand,
|
||||
}: MCPSemanticFilterTestPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const canRunTest = testQuery && testModel && filterEnabled;
|
||||
const testDisabled = isTesting || !canRunTest;
|
||||
|
||||
return (
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Test Configuration</CardTitle>
|
||||
<CardTitle>{t("mcpServers.semanticFilterTestPanel.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="test">
|
||||
<TabsList>
|
||||
<TabsTrigger value="test" className="flex-none">
|
||||
Test
|
||||
{t("mcpServers.semanticFilterTestPanel.tabs.test")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="api" className="flex-none">
|
||||
API Usage
|
||||
{t("mcpServers.semanticFilterTestPanel.tabs.api")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
|
@ -57,11 +59,11 @@ export default function MCPSemanticFilterTestPanel({
|
|||
<div className="flex w-full flex-col gap-6">
|
||||
<div>
|
||||
<p className="mb-2 flex items-center gap-1.5 font-medium">
|
||||
<CirclePlay className="size-4" /> Test Query
|
||||
<CirclePlay className="size-4" /> {t("mcpServers.semanticFilterTestPanel.testQuery.label")}
|
||||
</p>
|
||||
<Textarea
|
||||
className="field-sizing-fixed"
|
||||
placeholder="Enter a test query to see which tools would be selected..."
|
||||
placeholder={t("mcpServers.semanticFilterTestPanel.testQuery.placeholder")}
|
||||
value={testQuery}
|
||||
onChange={(e) => setTestQuery(e.target.value)}
|
||||
rows={4}
|
||||
|
|
@ -76,45 +78,56 @@ export default function MCPSemanticFilterTestPanel({
|
|||
onChange={setTestModel}
|
||||
disabled={isTesting}
|
||||
showLabel={true}
|
||||
labelText="Select Model"
|
||||
labelText={t("mcpServers.semanticFilterTestPanel.modelLabel")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button className="w-full" onClick={onTest} disabled={testDisabled}>
|
||||
<CirclePlay />
|
||||
Test Filter
|
||||
{t("mcpServers.semanticFilterTestPanel.runButton")}
|
||||
</Button>
|
||||
|
||||
{!filterEnabled && (
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertTitle>Semantic filtering is disabled</AlertTitle>
|
||||
<AlertDescription>Enable semantic filtering and save settings to test the filter.</AlertDescription>
|
||||
<AlertTitle>{t("mcpServers.semanticFilterTestPanel.disabledAlert.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("mcpServers.semanticFilterTestPanel.disabledAlert.description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{testError && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<CircleAlert />
|
||||
<AlertTitle>Semantic filtering did not run</AlertTitle>
|
||||
<AlertTitle>{t("mcpServers.semanticFilterTestPanel.errorAlert.title")}</AlertTitle>
|
||||
<AlertDescription>{testError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{testResult && (
|
||||
<div>
|
||||
<h5 className="mb-2 text-base font-medium">Results</h5>
|
||||
<h5 className="mb-2 text-base font-medium">
|
||||
{t("mcpServers.semanticFilterTestPanel.results.title")}
|
||||
</h5>
|
||||
<Alert className="mb-4">
|
||||
<Info />
|
||||
<AlertTitle>
|
||||
{testResult.selectedTools} of {testResult.totalTools} tools selected
|
||||
{t("mcpServers.semanticFilterTestPanel.results.summary", {
|
||||
selected: testResult.selectedTools,
|
||||
total: testResult.totalTools,
|
||||
})}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{testResult.totalTools - testResult.selectedTools} tools filtered out
|
||||
{t("mcpServers.semanticFilterTestPanel.results.filteredOut", {
|
||||
filtered: testResult.totalTools - testResult.selectedTools,
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div>
|
||||
<p className="mb-2 block font-medium">Selected Tools:</p>
|
||||
<p className="mb-2 block font-medium">
|
||||
{t("mcpServers.semanticFilterTestPanel.results.selectedToolsLabel")}
|
||||
</p>
|
||||
<ul className="m-0 list-disc pl-5">
|
||||
{testResult.tools.map((tool, index) => (
|
||||
<li key={index} className="mb-1">
|
||||
|
|
@ -124,7 +137,9 @@ export default function MCPSemanticFilterTestPanel({
|
|||
</ul>
|
||||
{testResult.selectedTools > testResult.tools.length && (
|
||||
<p className="mt-2 block text-sm text-muted-foreground">
|
||||
+{testResult.selectedTools - testResult.tools.length} more selected tools not shown
|
||||
{t("mcpServers.semanticFilterTestPanel.results.moreNotShown", {
|
||||
more: testResult.selectedTools - testResult.tools.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -137,21 +152,29 @@ export default function MCPSemanticFilterTestPanel({
|
|||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Code className="size-4" />
|
||||
<p className="font-medium">API Usage</p>
|
||||
<p className="font-medium">{t("mcpServers.semanticFilterTestPanel.api.title")}</p>
|
||||
</div>
|
||||
<p className="mb-2 block text-sm text-muted-foreground">
|
||||
Use this curl command to test the semantic filter with your current configuration.
|
||||
{t("mcpServers.semanticFilterTestPanel.api.description")}
|
||||
</p>
|
||||
<p className="mb-2 block font-medium">Response headers to check:</p>
|
||||
<p className="mb-2 block font-medium">{t("mcpServers.semanticFilterTestPanel.api.headersTitle")}</p>
|
||||
<ul className="mt-0 mr-0 mb-3 ml-0 list-disc pl-5">
|
||||
<li>
|
||||
<span>x-litellm-semantic-filter: shows total tools → selected tools</span>
|
||||
<span className="block text-sm text-muted-foreground">Example: 10→3</span>
|
||||
<span>
|
||||
{t("mcpServers.semanticFilterTestPanel.api.filterHeader", { header: "x-litellm-semantic-filter" })}
|
||||
</span>
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
{t("mcpServers.semanticFilterTestPanel.api.filterHeaderExample")}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span>x-litellm-semantic-filter-tools: CSV of selected tool names</span>
|
||||
<span>
|
||||
{t("mcpServers.semanticFilterTestPanel.api.toolsHeader", {
|
||||
header: "x-litellm-semantic-filter-tools",
|
||||
})}
|
||||
</span>
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
Example: wikipedia-fetch,github-search,slack-post
|
||||
{t("mcpServers.semanticFilterTestPanel.api.toolsHeaderExample")}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { TFunction } from "i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { testMCPSemanticFilter } from "@/components/networking";
|
||||
|
||||
|
|
@ -23,23 +25,26 @@ const parseFilterHeaders = (headers: FilterHeaders): TestResult | null => {
|
|||
return { totalTools: total, selectedTools: selected, tools };
|
||||
};
|
||||
|
||||
export const runSemanticFilterTest = async ({
|
||||
accessToken,
|
||||
testModel,
|
||||
testQuery,
|
||||
setIsTesting,
|
||||
setTestResult,
|
||||
setTestError,
|
||||
}: {
|
||||
accessToken: string;
|
||||
testModel: string | null;
|
||||
testQuery: string;
|
||||
setIsTesting: (value: boolean) => void;
|
||||
setTestResult: (result: TestResult | null) => void;
|
||||
setTestError: (error: string | null) => void;
|
||||
}) => {
|
||||
export const runSemanticFilterTest = async (
|
||||
{
|
||||
accessToken,
|
||||
testModel,
|
||||
testQuery,
|
||||
setIsTesting,
|
||||
setTestResult,
|
||||
setTestError,
|
||||
}: {
|
||||
accessToken: string;
|
||||
testModel: string | null;
|
||||
testQuery: string;
|
||||
setIsTesting: (value: boolean) => void;
|
||||
setTestResult: (result: TestResult | null) => void;
|
||||
setTestError: (error: string | null) => void;
|
||||
},
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
) => {
|
||||
if (!testQuery || !testModel || !accessToken) {
|
||||
toast.error("Please enter a query and select a model");
|
||||
toast.error(t("mcpServers.semanticFilterTestUtils.missingInput"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -52,17 +57,18 @@ export const runSemanticFilterTest = async ({
|
|||
const parsedResult = parseFilterHeaders(headers);
|
||||
|
||||
if (!parsedResult) {
|
||||
toast.warning("Semantic filter is not enabled or no tools were filtered");
|
||||
toast.warning(t("mcpServers.semanticFilterTestUtils.notFiltered"));
|
||||
return;
|
||||
}
|
||||
|
||||
setTestResult(parsedResult);
|
||||
toast.success("Semantic filter test completed successfully");
|
||||
toast.success(t("mcpServers.semanticFilterTestUtils.completed"));
|
||||
} catch (error) {
|
||||
console.error("Test failed:", error);
|
||||
const message = error instanceof Error && error.message ? error.message : "Failed to test semantic filter";
|
||||
const message =
|
||||
error instanceof Error && error.message ? error.message : t("mcpServers.semanticFilterTestUtils.failed");
|
||||
setTestError(message);
|
||||
toast.error("Failed to test semantic filter");
|
||||
toast.error(t("mcpServers.semanticFilterTestUtils.failed"));
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { CircleHelp, Info, Save } from "lucide-react";
|
|||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
|
|
@ -48,6 +49,7 @@ const labelWithHint = (label: string, hint: string): React.ReactNode => (
|
|||
);
|
||||
|
||||
export default function MCPToolSearchSettings({ accessToken }: MCPToolSearchSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError, error } = useMCPToolSearchSettings();
|
||||
const { mutate: updateSettings, isPending: isUpdating } = useUpdateMCPToolSearchSettings();
|
||||
const form = useForm<ToolSearchFormValues>({ defaultValues: DEFAULT_FORM_VALUES });
|
||||
|
|
@ -73,14 +75,16 @@ export default function MCPToolSearchSettings({ accessToken }: MCPToolSearchSett
|
|||
updateSettings(formToPayload(formValues), {
|
||||
onSuccess: () => {
|
||||
form.reset(formValues);
|
||||
toast.success("Settings updated successfully. Changes will be applied across all pods within 10 seconds.");
|
||||
toast.success(t("mcpServers.toolSearchSettings.toasts.updated"));
|
||||
},
|
||||
onError: (saveError) => toast.fromError(saveError),
|
||||
});
|
||||
};
|
||||
|
||||
if (!accessToken) {
|
||||
return <div className="p-6 text-center text-muted-foreground">Please log in to configure tool search.</div>;
|
||||
return (
|
||||
<div className="p-6 text-center text-muted-foreground">{t("mcpServers.toolSearchSettings.loginRequired")}</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
|
|
@ -96,7 +100,7 @@ export default function MCPToolSearchSettings({ accessToken }: MCPToolSearchSett
|
|||
if (isError) {
|
||||
return (
|
||||
<Alert variant="error" className="mb-6">
|
||||
<AlertTitle>Could not load MCP tool search settings</AlertTitle>
|
||||
<AlertTitle>{t("mcpServers.toolSearchSettings.loadError.title")}</AlertTitle>
|
||||
{error instanceof Error && <AlertDescription>{error.message}</AlertDescription>}
|
||||
</Alert>
|
||||
);
|
||||
|
|
@ -106,12 +110,9 @@ export default function MCPToolSearchSettings({ accessToken }: MCPToolSearchSett
|
|||
<div className="w-full">
|
||||
<Alert variant="info" className="mb-6">
|
||||
<Info />
|
||||
<AlertTitle>Native MCP Tool Search</AlertTitle>
|
||||
<AlertTitle>{t("mcpServers.toolSearchSettings.intro.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
Controls the <code>mcp_tool_search</code> virtual tool that native MCP clients call to discover tools. With an
|
||||
embedding model set, tools are ranked by the meaning of their name and description, so a query like
|
||||
"FX" finds a "foreign exchange rates" tool. Without one, keyword matching is used. Callers
|
||||
only ever see tools their key, team and server permissions already allow.
|
||||
<Trans i18nKey="mcpServers.toolSearchSettings.intro.description" components={{ code: <code /> }} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
|
@ -119,7 +120,7 @@ export default function MCPToolSearchSettings({ accessToken }: MCPToolSearchSett
|
|||
<form onSubmit={(event) => event.preventDefault()} noValidate>
|
||||
<Card className="mb-4">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Ranking</CardTitle>
|
||||
<CardTitle>{t("mcpServers.toolSearchSettings.ranking.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
|
|
@ -127,8 +128,8 @@ export default function MCPToolSearchSettings({ accessToken }: MCPToolSearchSett
|
|||
control={form.control}
|
||||
name="embedding_model"
|
||||
label={labelWithHint(
|
||||
"Embedding Model",
|
||||
"Embedding model from your model list used to rank tools by meaning. Clear it to fall back to keyword matching.",
|
||||
t("mcpServers.toolSearchSettings.fields.embeddingModel.label"),
|
||||
t("mcpServers.toolSearchSettings.fields.embeddingModel.hint"),
|
||||
)}
|
||||
>
|
||||
{({ value, onChange, id }) => (
|
||||
|
|
@ -138,8 +139,16 @@ export default function MCPToolSearchSettings({ accessToken }: MCPToolSearchSett
|
|||
value={value}
|
||||
onValueChange={onChange}
|
||||
allowClear
|
||||
placeholder={loadingModels ? "Loading models..." : "Keyword matching (no embedding model)"}
|
||||
emptyText={loadingModels ? "Loading..." : "No embedding models available"}
|
||||
placeholder={
|
||||
loadingModels
|
||||
? t("mcpServers.toolSearchSettings.fields.embeddingModel.loadingPlaceholder")
|
||||
: t("mcpServers.toolSearchSettings.fields.embeddingModel.placeholder")
|
||||
}
|
||||
emptyText={
|
||||
loadingModels
|
||||
? t("mcpServers.toolSearchSettings.fields.embeddingModel.loadingEmpty")
|
||||
: t("mcpServers.toolSearchSettings.fields.embeddingModel.empty")
|
||||
}
|
||||
disabled={isUpdating || loadingModels}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -149,8 +158,8 @@ export default function MCPToolSearchSettings({ accessToken }: MCPToolSearchSett
|
|||
control={form.control}
|
||||
name="top_k"
|
||||
label={labelWithHint(
|
||||
"Top K Results",
|
||||
"Most ranked tools a search returns. A smaller top_k in the tool call wins. Core tools do not count.",
|
||||
t("mcpServers.toolSearchSettings.fields.topK.label"),
|
||||
t("mcpServers.toolSearchSettings.fields.topK.hint"),
|
||||
)}
|
||||
>
|
||||
{({ ref, value, onChange, onBlur, id }) => (
|
||||
|
|
@ -175,8 +184,8 @@ export default function MCPToolSearchSettings({ accessToken }: MCPToolSearchSett
|
|||
control={form.control}
|
||||
name="similarity_threshold"
|
||||
label={labelWithHint(
|
||||
"Similarity Threshold",
|
||||
"Lowest cosine similarity a tool needs to appear in semantic results. 0 means no cutoff.",
|
||||
t("mcpServers.toolSearchSettings.fields.similarityThreshold.label"),
|
||||
t("mcpServers.toolSearchSettings.fields.similarityThreshold.hint"),
|
||||
)}
|
||||
>
|
||||
{({ value, onChange, id }) => (
|
||||
|
|
@ -206,7 +215,7 @@ export default function MCPToolSearchSettings({ accessToken }: MCPToolSearchSett
|
|||
|
||||
<Card className="mb-4">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Core Tools</CardTitle>
|
||||
<CardTitle>{t("mcpServers.toolSearchSettings.coreTools.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
|
|
@ -214,8 +223,8 @@ export default function MCPToolSearchSettings({ accessToken }: MCPToolSearchSett
|
|||
control={form.control}
|
||||
name="core_tools_text"
|
||||
label={labelWithHint(
|
||||
"Always Returned First",
|
||||
"One tool name per line, e.g. my_server-get_rates. Listed before ranked results whenever the caller is allowed to use them.",
|
||||
t("mcpServers.toolSearchSettings.fields.coreTools.label"),
|
||||
t("mcpServers.toolSearchSettings.fields.coreTools.hint"),
|
||||
)}
|
||||
>
|
||||
{({ ref, value, onChange, onBlur, id }) => (
|
||||
|
|
@ -241,7 +250,7 @@ export default function MCPToolSearchSettings({ accessToken }: MCPToolSearchSett
|
|||
disabled={!isDirty || isUpdating}
|
||||
>
|
||||
{isUpdating ? <UiLoadingSpinner className="size-4" /> : <Save />}
|
||||
Save Settings
|
||||
{t("mcpServers.toolSearchSettings.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers"
|
|||
import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets";
|
||||
import { MultiSelect, type MultiSelectOption } from "@/components/shared/MultiSelect";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ALL_PROXY_MCP_SERVERS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "@/components/mcp_tools/constants";
|
||||
import type { MCPServer } from "@/components/mcp_tools/types";
|
||||
import { accessGroupNamesOf } from "./effectiveMcpServers";
|
||||
|
|
@ -45,13 +46,14 @@ const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
|
|||
value,
|
||||
className,
|
||||
accessToken,
|
||||
placeholder = "Select MCP servers",
|
||||
placeholder,
|
||||
disabled = false,
|
||||
teamId,
|
||||
userId,
|
||||
allowNoMcpServers = false,
|
||||
allowAllProxyMcpServers = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: mcpServers = [], isLoading: serversLoading } = useMCPServers(teamId, userId);
|
||||
const { data: accessGroups = [], isLoading: groupsLoading } = useMCPAccessGroups();
|
||||
const { data: toolsets = [], isLoading: toolsetsLoading } = useMCPToolsets();
|
||||
|
|
@ -66,17 +68,17 @@ const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
|
|||
...accessGroups.map((group) => ({
|
||||
label: group,
|
||||
value: group,
|
||||
description: "Access Group",
|
||||
description: t("mcpServerManagement.serverSelector.optionTypes.accessGroup"),
|
||||
})),
|
||||
...visibleServers.map((server) => ({
|
||||
label: `${server.server_name || server.server_id} (${server.server_id})`,
|
||||
value: server.server_id,
|
||||
description: "MCP Server",
|
||||
description: t("mcpServerManagement.serverSelector.optionTypes.mcpServer"),
|
||||
})),
|
||||
...toolsets.map((toolset) => ({
|
||||
label: toolset.toolset_name,
|
||||
value: `${TOOLSET_PREFIX}${toolset.toolset_id}`,
|
||||
description: "Toolset",
|
||||
description: t("mcpServerManagement.serverSelector.optionTypes.toolset"),
|
||||
})),
|
||||
];
|
||||
|
||||
|
|
@ -112,10 +114,16 @@ const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
|
|||
|
||||
const selectOptions: MultiSelectOption[] = [
|
||||
...(allowAllProxyMcpServers || hasAllProxyMcpServersSelected
|
||||
? [{ label: "All Proxy MCP Servers", value: ALL_PROXY_MCP_SERVERS_SENTINEL }]
|
||||
? [{ label: t("mcpServerManagement.serverSelector.allProxyServers"), value: ALL_PROXY_MCP_SERVERS_SENTINEL }]
|
||||
: []),
|
||||
...(allowNoMcpServers
|
||||
? [{ label: "No MCP Servers", value: NO_MCP_SERVERS_SENTINEL, description: "Block all" }]
|
||||
? [
|
||||
{
|
||||
label: t("mcpServerManagement.serverSelector.noServers"),
|
||||
value: NO_MCP_SERVERS_SENTINEL,
|
||||
description: t("mcpServerManagement.serverSelector.blockAll"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...options.map((opt) => ({
|
||||
...opt,
|
||||
|
|
@ -129,8 +137,8 @@ const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
|
|||
options={selectOptions}
|
||||
value={selectedValues}
|
||||
onValueChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
emptyText="No MCP servers found"
|
||||
placeholder={placeholder ?? t("mcpServerManagement.serverSelector.placeholder")}
|
||||
emptyText={t("mcpServerManagement.serverSelector.empty")}
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
className={`w-full ${className ?? ""}`}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import React, { useEffect, useRef, useState, useMemo } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { listMCPTools } from "../networking";
|
||||
import { MCPTool } from "../mcp_tools/types";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
|
|
@ -40,16 +42,25 @@ interface InheritedBadge {
|
|||
readonly className: string;
|
||||
}
|
||||
|
||||
const inheritedBadgeFor = (source: McpGrantSource): InheritedBadge | null => {
|
||||
const inheritedBadgeFor = (source: McpGrantSource, t: TFunction): InheritedBadge | null => {
|
||||
switch (source.kind) {
|
||||
case "direct":
|
||||
return null;
|
||||
case "accessGroup":
|
||||
return { label: `Via access group: ${source.name}`, className: "text-green-700 bg-green-50 border-green-200" };
|
||||
return {
|
||||
label: t("mcpServerManagement.toolPermissions.inherited.accessGroup", { name: source.name }),
|
||||
className: "text-green-700 bg-green-50 border-green-200",
|
||||
};
|
||||
case "toolset":
|
||||
return { label: `Via toolset: ${source.name}`, className: "text-purple-700 bg-purple-50 border-purple-200" };
|
||||
return {
|
||||
label: t("mcpServerManagement.toolPermissions.inherited.toolset", { name: source.name }),
|
||||
className: "text-purple-700 bg-purple-50 border-purple-200",
|
||||
};
|
||||
case "toolPermission":
|
||||
return { label: "Via tool permissions", className: "text-amber-700 bg-amber-50 border-amber-200" };
|
||||
return {
|
||||
label: t("mcpServerManagement.toolPermissions.inherited.toolPermission"),
|
||||
className: "text-amber-700 bg-amber-50 border-amber-200",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -63,6 +74,7 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
|||
disabled = false,
|
||||
preserveUnrestrictedServers = NO_SELECTION,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data: allServers = [],
|
||||
isError: serversFailed,
|
||||
|
|
@ -120,7 +132,10 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
|||
const response = await listMCPTools(token, serverId);
|
||||
|
||||
if (response.error) {
|
||||
setToolErrors((prev) => ({ ...prev, [serverId]: response.message || "Failed to fetch tools" }));
|
||||
setToolErrors((prev) => ({
|
||||
...prev,
|
||||
[serverId]: response.message || t("mcpServerManagement.toolPermissions.fetchFailed"),
|
||||
}));
|
||||
setServerTools((prev) => ({ ...prev, [serverId]: [] }));
|
||||
} else {
|
||||
const fetchedTools: MCPTool[] = response.tools || [];
|
||||
|
|
@ -151,7 +166,7 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
|||
}
|
||||
} catch (err) {
|
||||
console.error(`Error fetching tools for server ${serverId}:`, err);
|
||||
setToolErrors((prev) => ({ ...prev, [serverId]: "Failed to fetch tools" }));
|
||||
setToolErrors((prev) => ({ ...prev, [serverId]: t("mcpServerManagement.toolPermissions.fetchFailed") }));
|
||||
setServerTools((prev) => ({ ...prev, [serverId]: [] }));
|
||||
} finally {
|
||||
setLoadingTools((prev) => ({ ...prev, [serverId]: false }));
|
||||
|
|
@ -211,10 +226,11 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
|||
<div className="space-y-4">
|
||||
{serversFailed && (
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800 font-medium">Unable to load MCP servers</p>
|
||||
<p className="text-sm text-yellow-800 font-medium">
|
||||
{t("mcpServerManagement.toolPermissions.serversFailed.title")}
|
||||
</p>
|
||||
<p className="text-sm text-yellow-700 mt-1">
|
||||
This list is incomplete; servers granted directly or through an access group may be missing. Reload before
|
||||
changing tool permissions
|
||||
{t("mcpServerManagement.toolPermissions.serversFailed.description")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -223,19 +239,25 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
|||
accessGroupsLoaded &&
|
||||
emptyMcpAccessGroups(allServers, populatedAccessGroups, selectedAccessGroups).map((group) => (
|
||||
<div key={group} className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800 font-medium">Access group "{group}" has 0 servers</p>
|
||||
<p className="text-sm text-yellow-800 font-medium">
|
||||
{t("mcpServerManagement.toolPermissions.emptyAccessGroup.title", { group })}
|
||||
</p>
|
||||
<p className="text-sm text-yellow-700 mt-1">
|
||||
No MCP server lists this group, so it grants nothing. A server defined in config.yaml joins a group
|
||||
through its <code>access_groups</code> key; <code>mcp_access_groups</code> is ignored there
|
||||
<Trans
|
||||
i18nKey="mcpServerManagement.toolPermissions.emptyAccessGroup.description"
|
||||
components={{ code: <code /> }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{toolsetsFailed && selectedToolsets.length > 0 && (
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800 font-medium">Unable to load toolsets</p>
|
||||
<p className="text-sm text-yellow-800 font-medium">
|
||||
{t("mcpServerManagement.toolPermissions.toolsetsFailed.title")}
|
||||
</p>
|
||||
<p className="text-sm text-yellow-700 mt-1">
|
||||
Servers reached through the selected toolsets are not listed below
|
||||
{t("mcpServerManagement.toolPermissions.toolsetsFailed.description")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -243,7 +265,9 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
|||
{serversLoading && (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<UiLoadingSpinner />
|
||||
<p className="ml-3 text-sm text-muted-foreground">Loading MCP servers...</p>
|
||||
<p className="ml-3 text-sm text-muted-foreground">
|
||||
{t("mcpServerManagement.toolPermissions.loadingServers")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -257,7 +281,7 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
|||
const isLoading = loadingTools[serverId];
|
||||
const error = toolErrors[serverId];
|
||||
const viewMode = viewModes[serverId] ?? "crud";
|
||||
const inherited = inheritedBadgeFor(entry.source);
|
||||
const inherited = inheritedBadgeFor(entry.source, t);
|
||||
// The backend adds a toolset's tools to whatever this map allows, so these stay on however
|
||||
// the boxes are ticked. Locking them is what keeps the matrix an honest picture of the grant.
|
||||
const toolsetTools = entry.toolsetTools ?? [];
|
||||
|
|
@ -280,19 +304,22 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
|||
{server.description && <p className="text-sm text-muted-foreground">{server.description}</p>}
|
||||
{grantsAll && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
All tools allowed, including tools added to this server later
|
||||
{t("mcpServerManagement.toolPermissions.allToolsAllowed")}
|
||||
</p>
|
||||
)}
|
||||
{entry.ambiguousKeys.length > 0 && (
|
||||
<p className="text-sm text-amber-700 mt-1">
|
||||
{`Also granted by ${entry.ambiguousKeys.map((key) => `"${key}"`).join(", ")}, which names another server too. Those tools stay allowed here until the servers no longer share that name`}
|
||||
{t("mcpServerManagement.toolPermissions.ambiguousKeys", {
|
||||
keys: entry.ambiguousKeys.map((key) => `"${key}"`).join(", "),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{toolsetTools.length > 0 && (
|
||||
<p className="text-sm text-purple-700 mt-1">
|
||||
{toolsetTools.length === 1
|
||||
? `${toolsetTools[0]} is granted by a selected toolset, so it stays allowed here; edit the toolset to revoke it`
|
||||
: `${toolsetTools.join(", ")} are granted by a selected toolset, so they stay allowed here; edit the toolset to revoke them`}
|
||||
{t("mcpServerManagement.toolPermissions.lockedByToolset", {
|
||||
count: toolsetTools.length,
|
||||
tools: toolsetTools.join(", "),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -305,11 +332,11 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
|||
>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem value="crud" />
|
||||
Risk Groups
|
||||
{t("mcpServerManagement.toolPermissions.viewModes.riskGroups")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem value="flat" />
|
||||
Flat List
|
||||
{t("mcpServerManagement.toolPermissions.viewModes.flatList")}
|
||||
</label>
|
||||
</RadioGroup>
|
||||
)}
|
||||
|
|
@ -321,7 +348,7 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
|||
onClick={() => handleSelectAll(entry)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Select All
|
||||
{t("mcpServerManagement.toolPermissions.selectAll")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -329,7 +356,7 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
|||
onClick={() => writeAllowedTools(entry, [])}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Deselect All
|
||||
{t("mcpServerManagement.toolPermissions.deselectAll")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -342,14 +369,18 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
|||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<UiLoadingSpinner />
|
||||
<p className="ml-3 text-sm text-muted-foreground">Loading tools...</p>
|
||||
<p className="ml-3 text-sm text-muted-foreground">
|
||||
{t("mcpServerManagement.toolPermissions.loadingTools")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && !isLoading && (
|
||||
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg text-center">
|
||||
<p className="text-sm text-destructive font-medium">Unable to load tools</p>
|
||||
<p className="text-sm text-destructive font-medium">
|
||||
{t("mcpServerManagement.toolPermissions.loadToolsFailed")}
|
||||
</p>
|
||||
<p className="text-sm text-destructive mt-1">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -390,7 +421,9 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
|||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-foreground">{tool.name}</p>
|
||||
<p className="text-sm text-muted-foreground">- {tool.description || "No description"}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
- {tool.description || t("mcpServerManagement.toolPermissions.noDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -402,7 +435,7 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
|||
{/* Empty State */}
|
||||
{!isLoading && !error && tools.length === 0 && (
|
||||
<div className="text-center py-6">
|
||||
<p className="text-sm text-muted-foreground">No tools available</p>
|
||||
<p className="text-sm text-muted-foreground">{t("mcpServerManagement.toolPermissions.noTools")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import React, { useId, useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { fetchClient } from "@/lib/http/api";
|
||||
import { ApiError } from "@/lib/http/client";
|
||||
|
|
@ -10,12 +12,12 @@ import { Dialog, DialogContent } from "@/components/ui/dialog";
|
|||
import { PasswordInput } from "@/components/shared/PasswordInput";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
const byokSaveErrorMessage = (e: unknown): string => {
|
||||
const byokSaveErrorMessage = (e: unknown, t: TFunction): string => {
|
||||
if (e instanceof ApiError) {
|
||||
const detail = (e.body as { detail?: { error?: string } } | null)?.detail?.error;
|
||||
if (detail) return detail;
|
||||
}
|
||||
return e instanceof Error && e.message ? e.message : "Failed to connect";
|
||||
return e instanceof Error && e.message ? e.message : t("mcpTools.byokCredentialModal.errors.connectFailed");
|
||||
};
|
||||
|
||||
interface ByokCredentialModalProps {
|
||||
|
|
@ -26,13 +28,14 @@ interface ByokCredentialModalProps {
|
|||
}
|
||||
|
||||
export const ByokCredentialModal: React.FC<ByokCredentialModalProps> = ({ server, open, onClose, onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [saveKey, setSaveKey] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const apiKeyInputId = useId();
|
||||
|
||||
const serverDisplayName = server.alias || server.server_name || "Service";
|
||||
const serverDisplayName = server.alias || server.server_name || t("mcpTools.byokCredentialModal.fallbackServiceName");
|
||||
const firstLetter = serverDisplayName.charAt(0).toUpperCase();
|
||||
|
||||
const handleClose = () => {
|
||||
|
|
@ -45,7 +48,7 @@ export const ByokCredentialModal: React.FC<ByokCredentialModalProps> = ({ server
|
|||
|
||||
const handleAuthorize = async () => {
|
||||
if (!apiKey.trim()) {
|
||||
toast.error("Please enter your API key");
|
||||
toast.error(t("mcpTools.byokCredentialModal.toasts.apiKeyRequired"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
|
|
@ -54,11 +57,11 @@ export const ByokCredentialModal: React.FC<ByokCredentialModalProps> = ({ server
|
|||
params: { path: { server_id: server.server_id } },
|
||||
body: { credential: apiKey.trim(), save: saveKey },
|
||||
});
|
||||
toast.success(`Connected to ${serverDisplayName}`);
|
||||
toast.success(t("mcpTools.byokCredentialModal.toasts.connected", { server: serverDisplayName }));
|
||||
onSuccess(server.server_id);
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
toast.error(byokSaveErrorMessage(e));
|
||||
toast.error(byokSaveErrorMessage(e, t));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -78,7 +81,7 @@ export const ByokCredentialModal: React.FC<ByokCredentialModalProps> = ({ server
|
|||
onClick={() => setStep(1)}
|
||||
className="flex items-center gap-1 text-muted-foreground hover:text-foreground text-sm"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" /> Back
|
||||
<ArrowLeft className="size-3.5" /> {t("mcpTools.byokCredentialModal.back")}
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
|
|
@ -105,9 +108,11 @@ export const ByokCredentialModal: React.FC<ByokCredentialModalProps> = ({ server
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">Connect {serverDisplayName}</h2>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">
|
||||
{t("mcpTools.byokCredentialModal.intro.title", { server: serverDisplayName })}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
LiteLLM needs access to {serverDisplayName} to complete your request.
|
||||
{t("mcpTools.byokCredentialModal.intro.description", { server: serverDisplayName })}
|
||||
</p>
|
||||
|
||||
{/* How it works */}
|
||||
|
|
@ -120,10 +125,11 @@ export const ByokCredentialModal: React.FC<ByokCredentialModalProps> = ({ server
|
|||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-foreground mb-1">How it works</p>
|
||||
<p className="font-semibold text-foreground mb-1">
|
||||
{t("mcpTools.byokCredentialModal.intro.howItWorksTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
LiteLLM acts as a secure bridge. Your requests are routed through our MCP client directly to{" "}
|
||||
{serverDisplayName}'s API.
|
||||
{t("mcpTools.byokCredentialModal.intro.howItWorks", { server: serverDisplayName })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -137,7 +143,7 @@ export const ByokCredentialModal: React.FC<ByokCredentialModalProps> = ({ server
|
|||
<path d="M12 2L12 22M2 12L22 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
Requested Access
|
||||
{t("mcpTools.byokCredentialModal.intro.requestedAccess")}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{server.byok_description.map((item, i) => (
|
||||
|
|
@ -154,13 +160,13 @@ export const ByokCredentialModal: React.FC<ByokCredentialModalProps> = ({ server
|
|||
onClick={() => setStep(2)}
|
||||
className="w-full bg-gray-900 hover:bg-gray-700 text-white font-medium py-3 px-6 rounded-xl flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
Continue to Authentication <ArrowRight className="size-4" />
|
||||
{t("mcpTools.byokCredentialModal.intro.continue")} <ArrowRight className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="mt-3 w-full text-muted-foreground hover:text-foreground text-sm py-2"
|
||||
>
|
||||
Cancel
|
||||
{t("mcpTools.byokCredentialModal.intro.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -170,18 +176,20 @@ export const ByokCredentialModal: React.FC<ByokCredentialModalProps> = ({ server
|
|||
<Key className="size-5 text-info" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">Provide API Key</h2>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">
|
||||
{t("mcpTools.byokCredentialModal.apiKey.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Enter your {serverDisplayName} API key to authorize this connection.
|
||||
{t("mcpTools.byokCredentialModal.apiKey.description", { server: serverDisplayName })}
|
||||
</p>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor={apiKeyInputId} className="block text-sm font-semibold text-foreground mb-2">
|
||||
{serverDisplayName} API Key
|
||||
{t("mcpTools.byokCredentialModal.apiKey.label", { server: serverDisplayName })}
|
||||
</label>
|
||||
<PasswordInput
|
||||
id={apiKeyInputId}
|
||||
placeholder="Enter your API key"
|
||||
placeholder={t("mcpTools.byokCredentialModal.apiKey.placeholder")}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
groupClassName="rounded-lg"
|
||||
|
|
@ -193,7 +201,7 @@ export const ByokCredentialModal: React.FC<ByokCredentialModalProps> = ({ server
|
|||
rel="noopener noreferrer"
|
||||
className="text-info hover:text-info/80 text-sm mt-2 flex items-center gap-1"
|
||||
>
|
||||
Where do I find my API key? <Link2 className="size-3.5" />
|
||||
{t("mcpTools.byokCredentialModal.apiKey.helpLink")} <Link2 className="size-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -207,17 +215,21 @@ export const ByokCredentialModal: React.FC<ByokCredentialModalProps> = ({ server
|
|||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-foreground">Save key for future use</span>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t("mcpTools.byokCredentialModal.apiKey.saveForFuture")}
|
||||
</span>
|
||||
</div>
|
||||
<Switch checked={saveKey} onCheckedChange={setSaveKey} aria-label="Save key for future use" />
|
||||
<Switch
|
||||
checked={saveKey}
|
||||
onCheckedChange={setSaveKey}
|
||||
aria-label={t("mcpTools.byokCredentialModal.apiKey.saveForFuture")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Security note */}
|
||||
<div className="bg-info/10 rounded-xl p-4 flex items-start gap-3 mb-6">
|
||||
<Lock className="mt-0.5 size-4 shrink-0 text-info" />
|
||||
<p className="text-sm text-info">
|
||||
Your key is stored securely and transmitted over HTTPS. It is never shared with third parties.
|
||||
</p>
|
||||
<p className="text-sm text-info">{t("mcpTools.byokCredentialModal.apiKey.securityNote")}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
|
@ -225,7 +237,7 @@ export const ByokCredentialModal: React.FC<ByokCredentialModalProps> = ({ server
|
|||
disabled={loading}
|
||||
className="w-full bg-info hover:bg-info/80 disabled:opacity-60 text-info-foreground font-medium py-3 px-6 rounded-xl flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<Lock className="size-4" /> Connect & Authorize
|
||||
<Lock className="size-4" /> {t("mcpTools.byokCredentialModal.apiKey.authorize")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
*/
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { CrudOp, MCPToolEntry, CRUD_GROUP_META, groupToolsByCrud } from "../../utils/mcpToolCrudClassification";
|
||||
|
|
@ -76,6 +77,7 @@ const McpCrudPermissionPanel: React.FC<McpCrudPermissionPanelProps> = ({
|
|||
readOnly = false,
|
||||
searchFilter = "",
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [collapsed, setCollapsed] = useState<Record<CrudOp, boolean>>({
|
||||
read: false,
|
||||
create: false,
|
||||
|
|
@ -182,26 +184,33 @@ const McpCrudPermissionPanel: React.FC<McpCrudPermissionPanelProps> = ({
|
|||
<span className="font-semibold text-foreground text-sm">{meta.label}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${RISK_BADGE[meta.risk]}`}>
|
||||
{meta.risk === "high"
|
||||
? "High Risk"
|
||||
? t("mcpTools.crudPermissionPanel.risk.high")
|
||||
: meta.risk === "medium"
|
||||
? "Medium Risk"
|
||||
? t("mcpTools.crudPermissionPanel.risk.medium")
|
||||
: meta.risk === "low"
|
||||
? "Safe"
|
||||
: "Unclassified"}
|
||||
? t("mcpTools.crudPermissionPanel.risk.low")
|
||||
: t("mcpTools.crudPermissionPanel.risk.unknown")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
{group.filter((t) => effectiveAllowed.has(t.name)).length}/{group.length} allowed
|
||||
{t("mcpTools.crudPermissionPanel.allowedCount", {
|
||||
allowed: group.filter((tool) => effectiveAllowed.has(tool.name)).length,
|
||||
total: group.length,
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{fullyAllowed ? "All on" : partial ? "Partial" : "All off"}
|
||||
{fullyAllowed
|
||||
? t("mcpTools.crudPermissionPanel.groupState.allOn")
|
||||
: partial
|
||||
? t("mcpTools.crudPermissionPanel.groupState.partial")
|
||||
: t("mcpTools.crudPermissionPanel.groupState.allOff")}
|
||||
</p>
|
||||
{/* Checkbox supports `indeterminate`; Switch does not. */}
|
||||
<Checkbox
|
||||
aria-label={`Allow all ${meta.label} tools`}
|
||||
aria-label={t("mcpTools.crudPermissionPanel.allowAllAria", { label: meta.label })}
|
||||
checked={fullyAllowed}
|
||||
indeterminate={partial}
|
||||
onCheckedChange={(checked) => toggleGroup(op, checked)}
|
||||
|
|
@ -256,7 +265,9 @@ const McpCrudPermissionPanel: React.FC<McpCrudPermissionPanelProps> = ({
|
|||
allowed ? "bg-success/15 text-success" : "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{allowed ? "on" : "off"}
|
||||
{allowed
|
||||
? t("mcpTools.crudPermissionPanel.toolState.on")
|
||||
: t("mcpTools.crudPermissionPanel.toolState.off")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "@/lib/toast";
|
||||
import {
|
||||
buildMcpOAuthAuthorizeUrl,
|
||||
|
|
@ -82,6 +83,7 @@ export const useMcpOAuthFlow = ({
|
|||
onBeforeRedirect,
|
||||
flowSource,
|
||||
}: UseMcpOAuthFlowOptions): UseMcpOAuthFlowResult => {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<McpOAuthStatus>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tokenResponse, setTokenResponse] = useState<Record<string, any> | null>(null);
|
||||
|
|
@ -155,14 +157,14 @@ export const useMcpOAuthFlow = ({
|
|||
const credentials = getCredentials() || {};
|
||||
|
||||
if (!accessToken) {
|
||||
setError("Missing admin token");
|
||||
toast.error("Access token missing. Please re-authenticate and try again.");
|
||||
setError(t("mcpServers.useMcpOAuthFlow.errors.missingAdminToken"));
|
||||
toast.error(t("mcpServers.useMcpOAuthFlow.errors.accessTokenMissing"));
|
||||
return;
|
||||
}
|
||||
|
||||
const temporaryPayload = getTemporaryPayload();
|
||||
if (!temporaryPayload || !temporaryPayload.url || !temporaryPayload.transport) {
|
||||
const message = "Please complete server URL and transport before starting OAuth.";
|
||||
const message = t("mcpServers.useMcpOAuthFlow.errors.incompleteServer");
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
|
|
@ -250,7 +252,7 @@ export const useMcpOAuthFlow = ({
|
|||
setError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
}, [accessToken, getCredentials, getTemporaryPayload, onBeforeRedirect]);
|
||||
}, [accessToken, getCredentials, getTemporaryPayload, onBeforeRedirect, t]);
|
||||
|
||||
const resumeOAuthFlow = useCallback(async () => {
|
||||
if (typeof window === "undefined") {
|
||||
|
|
@ -288,9 +290,9 @@ export const useMcpOAuthFlow = ({
|
|||
} catch (err) {
|
||||
clearStoredFlow();
|
||||
processingRef.current = false;
|
||||
setError("Failed to resume OAuth flow. Please retry.");
|
||||
setError(t("mcpServers.useMcpOAuthFlow.errors.resumeFailed"));
|
||||
setStatus("error");
|
||||
toast.error("Failed to resume OAuth flow. Please retry.");
|
||||
toast.error(t("mcpServers.useMcpOAuthFlow.errors.resumeFailed"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -360,7 +362,7 @@ export const useMcpOAuthFlow = ({
|
|||
setTokenResponse(token);
|
||||
setStatus("success");
|
||||
setError(null);
|
||||
toast.success("OAuth token retrieved successfully");
|
||||
toast.success(t("mcpServers.useMcpOAuthFlow.toasts.tokenRetrieved"));
|
||||
} catch (err) {
|
||||
if (resetVersion !== resetVersionRef.current) {
|
||||
return;
|
||||
|
|
@ -378,7 +380,7 @@ export const useMcpOAuthFlow = ({
|
|||
}, 1000);
|
||||
}
|
||||
}
|
||||
}, [onTokenReceived]);
|
||||
}, [onTokenReceived, t]);
|
||||
|
||||
useEffect(() => {
|
||||
resumeOAuthFlow();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { testMCPToolsListRequest } from "../components/networking";
|
||||
import { AUTH_TYPE, OAUTH_FLOW, TRANSPORT, isClientForwardedTokenMode } from "@/components/mcp_tools/types";
|
||||
|
||||
|
|
@ -47,6 +48,7 @@ export const useTestMCPConnection = ({
|
|||
formValues,
|
||||
enabled = true,
|
||||
}: UseTestMCPConnectionProps): UseTestMCPConnectionReturn => {
|
||||
const { t } = useTranslation();
|
||||
const [tools, setTools] = useState<any[]>([]);
|
||||
const [isLoadingTools, setIsLoadingTools] = useState(false);
|
||||
const [toolsError, setToolsError] = useState<string | null>(null);
|
||||
|
|
@ -165,7 +167,7 @@ export const useTestMCPConnection = ({
|
|||
setHasShownSuccessMessage(true);
|
||||
}
|
||||
} else {
|
||||
const errorMessage = toolsResponse.message || "Failed to retrieve tools list";
|
||||
const errorMessage = toolsResponse.message || t("mcpServers.useTestMcpConnection.errors.toolsListFailed");
|
||||
setToolsError(errorMessage);
|
||||
setToolsErrorStatus(typeof toolsResponse.status === "number" ? toolsResponse.status : null);
|
||||
setToolsErrorStackTrace(toolsResponse.status === 403 ? null : toolsResponse.stack_trace || null);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { buildMcpOAuthAuthorizeUrl, exchangeMcpOAuthToken, registerMcpOAuthClient } from "@/components/networking";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
|
|
@ -71,6 +72,7 @@ export const useToolsOAuthFlow = ({
|
|||
gatewayMintsClient,
|
||||
onSuccess,
|
||||
}: UseToolsOAuthFlowOptions): UseToolsOAuthFlowResult => {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<ToolsOAuthStatus>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const processingRef = useRef(false);
|
||||
|
|
@ -173,7 +175,7 @@ export const useToolsOAuthFlow = ({
|
|||
payload = JSON.parse(storedResult);
|
||||
flowState = peeked;
|
||||
} catch (_) {
|
||||
setError("Failed to resume OAuth flow. Please retry.");
|
||||
setError(t("mcpServers.useToolsOAuthFlow.errors.resumeFailed"));
|
||||
setStatus("error");
|
||||
processingRef.current = false;
|
||||
clearStorage(FLOW_STATE_KEY);
|
||||
|
|
@ -218,7 +220,7 @@ export const useToolsOAuthFlow = ({
|
|||
|
||||
setStatus("success");
|
||||
setError(null);
|
||||
toast.success("Connected successfully");
|
||||
toast.success(t("mcpServers.useToolsOAuthFlow.toasts.connected"));
|
||||
onSuccessRef.current(token.access_token);
|
||||
} catch (err) {
|
||||
const msg = extractErrorMessage(err);
|
||||
|
|
@ -231,7 +233,7 @@ export const useToolsOAuthFlow = ({
|
|||
processingRef.current = false;
|
||||
}, 1000);
|
||||
}
|
||||
}, [accessToken, serverId, userId]);
|
||||
}, [accessToken, serverId, userId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
resumeOAuthFlow();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue